Skip to main content

fathomdb_engine/
lib.rs

1pub mod lifecycle;
2mod pcache2;
3
4use std::collections::{BTreeMap, BTreeSet, VecDeque};
5use std::error::Error;
6use std::fmt::{Display, Formatter};
7use std::fs::{File, OpenOptions};
8use std::io::{Seek, SeekFrom, Write};
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
11use std::sync::mpsc::{self, Receiver, SyncSender};
12use std::sync::Once;
13use std::sync::{Arc, Condvar, Mutex};
14use std::thread::{self, JoinHandle};
15use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
16
17use fathomdb_embedder::{EmbedderEvent, MeanRecomputeTrigger};
18use fathomdb_embedder_api::{Embedder, EmbedderError as RuntimeEmbedderError, EmbedderIdentity};
19use fathomdb_query::compile_text_query;
20use fathomdb_schema::{
21    migrate_with_event_sink, MigrationError as SchemaMigrationError, MigrationStepReport,
22    CANONICAL_TABLES, LOCK_SUFFIX, MIGRATIONS, SCHEMA_VERSION,
23};
24use jsonschema::JSONSchema;
25use rusqlite::{params, Connection, OptionalExtension};
26use serde_json::Value;
27use sha2::Digest;
28use sqlite_vec::sqlite3_vec_init;
29
30#[cfg(unix)]
31use std::os::unix::fs::OpenOptionsExt;
32
33// EU-5b lock-flip: the engine's default embedder identity is now the
34// pinned bge-small variant. Pre-existing 0.7.0 workspaces opened with
35// `EmbedderChoice::Default` will fail-closed on identity mismatch per
36// ADR-0.6.0-vector-identity-embedder-owned; callers can still hold an
37// older noop profile by supplying `EmbedderChoice::Caller(NoopEmbedder)`.
38const DEFAULT_EMBEDDER_NAME: &str = "fathomdb-bge-small-en-v1.5";
39const DEFAULT_EMBEDDER_REVISION: &str = "5c38ec7c405ec4b44b94cc5a9bb96e735b38267a";
40const DEFAULT_EMBEDDER_DIMENSION: u32 = 384;
41
42/// Identity name of the bge-small embedder. `OpenReport.embedder_mean_centering_required`
43/// is `true` iff the live embedder identity reports this name. NoopEmbedder
44/// is `false`. Lifted out as a constant so the EU-5b lock-flip (when the
45/// engine's default identity becomes bge-small) is a single-line change.
46///
47/// TODO(EU-5b): when `DEFAULT_EMBEDDER_NAME` flips to this constant, the
48/// Default path will populate `embedder_mean_centering_required = true`
49/// without further engine work. Caller-supplied bge-small (rare today)
50/// already does the right thing.
51const BGE_SMALL_EMBEDDER_NAME: &str = "fathomdb-bge-small-en-v1.5";
52
53/// REQ-006a / AC-007a default slow-statement threshold. Mutated at runtime
54/// via [`Engine::set_slow_threshold_ms`].
55const DEFAULT_SLOW_THRESHOLD_MS: u64 = 100;
56const DEFAULT_VECTOR_PROFILE: &str = "default";
57const DEFAULT_VECTOR_PARTITION: &str = "vector_default";
58/// Default drain budget for `rebuild_projections` / `rebuild_vec0`. The
59/// rebuild path freezes the scheduler before truncating shadow rows, so
60/// the only outstanding work is whatever workers were mid-flight when
61/// the call landed; 30 s is generous for normal job sizes and bounded
62/// for tests.
63const REBUILD_DRAIN_TIMEOUT_MS: u64 = 30_000;
64const DEFAULT_PROVENANCE_ROW_CAP: u64 = 1_000_000;
65const PROJECTION_CURSOR_KEY: &str = "projection_cursor";
66const PROJECTION_WORKERS: usize = 2;
67/// PR-9 — ADR-0.6.0-embedder-protocol **Invariant 5** default per-`embed()`
68/// watchdog deadline. Every projection-path embed runs under this timeout;
69/// a hung embed surfaces `RuntimeEmbedderError::Timeout` (engaging the
70/// existing retry/failure path) rather than parking a worker forever. The
71/// EU-5f `catch_unwind` only catches *panics*; this catches *hangs*.
72const DEFAULT_EMBED_TIMEOUT_MS: u64 = 30_000;
73/// PR-9 — embed circuit-breaker threshold: the maximum number of watchdog
74/// embed threads allowed alive at once before the breaker latches and
75/// projection jobs fail fast (see `embed_circuit_open` / `live_embed_threads`).
76/// Healthy serialized operation keeps the live count at 0–1, so reaching this
77/// many concurrently-alive embed threads means timed-out embeds are piling up
78/// (a hung/wedged embedder); the breaker then caps the abandoned-thread leak
79/// at roughly this count.
80const DEFAULT_EMBED_CIRCUIT_THRESHOLD: u64 = 8;
81const PROJECTION_COMMIT_BATCH: usize = 16;
82// Each worker should be able to grab a full commit batch while another
83// worker has the same waiting in the queue. Below this, the dispatcher
84// throttles below the workers' commit-batch capacity.
85const PROJECTION_INFLIGHT_LIMIT: usize = PROJECTION_WORKERS * PROJECTION_COMMIT_BATCH;
86// SQL fetch cap inside the dispatcher: enough to fill the in-flight
87// budget in a single scan so we don't pay one SQL roundtrip per job.
88const PROJECTION_SCAN_FETCH: usize = PROJECTION_INFLIGHT_LIMIT;
89const DEFAULT_PROJECTION_RETRY_DELAYS_MS: [u64; 3] = [1_000, 4_000, 16_000];
90
91/// Reader pool size. Per `dev/design/engine.md` § Writer / reader split,
92/// reader connections are pooled and never serialize behind one
93/// connection. AC-021 exercises 8 concurrent readers.
94const READER_POOL_SIZE: usize = 8;
95
96/// Per-reader-connection lookaside slot size, in bytes. Pack 6.G G.1.
97/// Picked from G.0 telemetry (`allocator_lookaside` 26.67% conc cycles
98/// with 3.89× ratio) + the SQLite docs' typical-workload sizing
99/// guidance (https://www.sqlite.org/malloc.html §3): 1200-byte slots
100/// cover the small allocations from `sqlite3DbMallocRaw`,
101/// `sqlite3Fts5ExprNew`, and `vec0Filter_knn` visible at the top of the
102/// concurrent profile.
103const READER_LOOKASIDE_SLOT_SIZE: std::os::raw::c_int = 1200;
104
105/// Per-reader-connection lookaside slot count. SQLite default is 128;
106/// we use 500 to absorb the per-statement allocation footprint of the
107/// hybrid search workload across a sticky worker connection without
108/// falling back to the glibc malloc-arena mutex.
109const READER_LOOKASIDE_SLOT_COUNT: std::os::raw::c_int = 500;
110
111pub struct Engine {
112    path: PathBuf,
113    next_cursor: AtomicU64,
114    closed: AtomicBool,
115    lock: Mutex<Option<File>>,
116    connection: Mutex<Option<Connection>>,
117    reader_pool: ReaderWorkerPool,
118    counters: lifecycle::Counters,
119    subscribers: Arc<lifecycle::SubscriberRegistry>,
120    profiling_enabled: Arc<AtomicBool>,
121    slow_threshold_ms: Arc<AtomicU64>,
122    runtime_embedder: Option<Arc<dyn Embedder>>,
123    runtime_embedder_identity: EmbedderIdentity,
124    projection_runtime: ProjectionRuntime,
125    provenance_row_cap: AtomicU64,
126    /// Per-connection profile-callback contexts. Each box's pointer is
127    /// installed into the connection's `sqlite3_profile` userdata; the
128    /// box must outlive the connection so the callback never reads
129    /// freed memory. Connections are dropped before this vec on
130    /// `close`/`Drop`, so the lifetime ordering holds.
131    ///
132    /// Why `Box<ProfileContext>` and not `ProfileContext` directly: the
133    /// FFI pointer captured during `install_profile_callback` MUST
134    /// remain stable for the connection's lifetime; pushing onto a
135    /// `Vec<ProfileContext>` could reallocate and invalidate that
136    /// pointer.
137    #[allow(clippy::vec_box)]
138    profile_contexts: Mutex<Vec<Box<ProfileContext>>>,
139    /// Pack 6.G G.1 — `sqlite3_db_config(LOOKASIDE)` rc per reader
140    /// worker, captured at open time before any PRAGMA / prepare ran
141    /// on the connection. Read only by the debug-only test accessor
142    /// `reader_lookaside_config_rcs_for_test`; held in release builds
143    /// too because the field is set unconditionally at open and a cfg
144    /// gate would force two open-locked return shapes.
145    #[allow(dead_code)]
146    reader_lookaside_rcs: Vec<i32>,
147    #[cfg(debug_assertions)]
148    force_next_commit_failure: AtomicBool,
149}
150
151#[derive(Clone, Debug)]
152struct ProjectionJob {
153    cursor: u64,
154    kind: String,
155    body: String,
156}
157
158#[derive(Debug, Default)]
159struct ProjectionRuntimeState {
160    active_jobs: usize,
161    queued_jobs: usize,
162    frozen: bool,
163    pending_scan: bool,
164    stopping: bool,
165    in_flight: BTreeSet<u64>,
166}
167
168struct ProjectionRuntimeShared {
169    path: PathBuf,
170    embedder: Option<Arc<dyn Embedder>>,
171    embedder_identity: EmbedderIdentity,
172    state: Mutex<ProjectionRuntimeState>,
173    state_cvar: Condvar,
174    queue: Mutex<VecDeque<ProjectionJob>>,
175    queue_cvar: Condvar,
176    retry_delays_ms: Mutex<Vec<u64>>,
177    /// PR-9 — ADR-0.6.0-embedder-protocol Invariant 5 per-`embed()` watchdog
178    /// deadline (ms). Read lock-free on the projection hot path. Default
179    /// `DEFAULT_EMBED_TIMEOUT_MS` (30s); the test seam
180    /// `set_embed_timeout_ms_for_test` lowers it so the hanging-embedder
181    /// test need not wait 30s. A hung embed surfaces
182    /// `RuntimeEmbedderError::Timeout`, engaging the existing retry/failure
183    /// path in `run_projection_job`.
184    embed_timeout_ms: AtomicU64,
185    /// PR-9 — engine-side embed serialization guard. The pool runs
186    /// `PROJECTION_WORKERS` workers; this guard ensures the shared
187    /// `Arc<dyn Embedder>` is invoked by at most one worker at a time.
188    ///
189    /// Rationale is SAFETY, not throughput. The engine accepts arbitrary
190    /// caller-supplied embedders (the pyo3 / napi bridges, per ADR-0.6.0)
191    /// whose `embed` is `Sync` only by trait contract; many real impls (a
192    /// GIL-bound Python model, a non-reentrant native lib, an internal cache)
193    /// are not actually safe under concurrent calls. Serializing engine-side
194    /// makes the projection robust to embedders that are not truly
195    /// concurrency-safe, without the engine having to trust each impl. The
196    /// default `CandleBgeEmbedder` was shown safe under concurrent forwards
197    /// in the PR-9 pre-flight, so for it the guard is belt-and-suspenders.
198    ///
199    /// Throughput is ~neutral: `candle` fans every `BertModel::forward` onto a
200    /// single process-wide rayon pool, so two concurrent forwards merely
201    /// share that pool (trading per-embed latency, not aggregate work) rather
202    /// than getting 2x — serializing avoids some scheduler/cache thrash but is
203    /// not a large win. (An earlier "~13x" figure compared a debug-build
204    /// unserialized run against a release-build number and was withdrawn; a
205    /// PR-9 micro-benchmark put release embeds at ~14 ms short / ~960 ms for a
206    /// 512-token doc, watchdog overhead ~0.)
207    ///
208    /// Commit/IO stays parallel across workers (see `commit_gate`); this guard
209    /// wraps only the embed call. It is held by the worker across the watchdog
210    /// call and released here, so a timed-out (abandoned) embed frees it and
211    /// cannot stall the pool — the guard owns no data, so a panic-resumed
212    /// embed that poisons it is recovered via `into_inner`.
213    ///
214    /// Deliberate trade-off (codex PR-9 CONCERN-1, accepted): on the *timeout*
215    /// path the worker drops this guard while the abandoned detached embed
216    /// thread is still running lock-free, so serialization is briefly relaxed
217    /// until that thread finishes. This is the prescribed choice over holding
218    /// the guard inside the embed thread — which would let a genuinely-hung
219    /// embed hold it forever and deadlock the whole pool, exactly the wedge
220    /// ADR-0.6.0 Invariant 5 and this slice's spec forbid. Timeouts are the
221    /// fault path only; the embed circuit breaker (`embed_circuit_open`) caps
222    /// how many such abandoned threads can be alive at once. A future slice may
223    /// replace this hard serialize with an operator-configurable embed
224    /// concurrency limit (ADR-0.6.0 Invariant 4 pool-size override) for I/O-
225    /// or GPU-bound embedders; that knob is out of PR-9 scope.
226    embed_serialize: Mutex<()>,
227    /// PR-9 — embed circuit breaker. `live_embed_threads` counts watchdog embed
228    /// threads currently alive (incremented when one is spawned, decremented
229    /// when it finishes — see `embed_with_watchdog`). Under healthy serialized
230    /// operation this is 0 or 1; it only grows when timed-out embeds are
231    /// abandoned and keep running (ADR-0.6.0 Invariant 5 forbids aborting a
232    /// running embed). When a new embed would push the live count to
233    /// `embed_circuit_threshold`, the breaker latches `embed_circuit_open` and
234    /// projection jobs fail fast WITHOUT spawning further embeds — bounding the
235    /// abandoned-thread leak to ~threshold REGARDLESS of whether the embedder
236    /// hangs on every input or only intermittently (a returning embed
237    /// decrements the count rather than resetting a streak, so an
238    /// intermittently-hanging embedder still latches as its hung threads pile
239    /// up, and a merely-slow-but-returning embedder self-clears and never
240    /// false-trips). Latches for the engine session (a reopen resets it); a
241    /// half-open/cool-down retry is future work. `threshold == 0` disables it.
242    live_embed_threads: Arc<AtomicU64>,
243    embed_circuit_open: AtomicBool,
244    embed_circuit_threshold: AtomicU64,
245    /// EU-5b — streaming mean accumulator for the per-workspace mean
246    /// pinning lifecycle (`dev/design/embedder.md` §0.3). `Some(_)` iff
247    /// the identity is MC-required AND no mean has been pinned yet on
248    /// disk. The accumulator graduates to `None` after the at-pin
249    /// commit; subsequent docs feed nothing.
250    mean_accumulator: Mutex<Option<MeanAccumulator>>,
251    /// EU-5b — `MeanVecPinned` events queued by the projection-commit
252    /// transaction for the next test-seam drain. Production callers
253    /// consume these via the `OpenReport.embedder_events` channel; the
254    /// drain seam is `Engine::drain_mean_centering_events_for_test`.
255    pending_events: Mutex<Vec<EmbedderEvent>>,
256    /// EU-5f — serializes the body of `commit_projection_outcomes` across
257    /// the `PROJECTION_WORKERS` worker connections. Each worker commits on
258    /// its own connection; holding this gate for the whole commit makes the
259    /// commit transactions totally ordered, which is what makes the at-pin
260    /// re-quantize pass provably complete (every row is wholly before or
261    /// after the unique pin tx, so none can survive un-centered). Embedding
262    /// (`run_projection_job`) runs OUTSIDE the gate and stays parallel.
263    commit_gate: Mutex<()>,
264    /// 0.7.2 PR-2bc S1 fix-1 — overridable phase-2 rerank `LIMIT` for the
265    /// search hot path. Equals `SEARCH_RERANK_LIMIT` (10) in production; a
266    /// test seam (`set_search_limit_for_test`) can RAISE it (clamped to >=10,
267    /// so it can never shrink below production semantics) so the recall
268    /// harness can pull top-(10+slack) and exclude the self-retrieving
269    /// query-source doc before truncating to 10. Production reads this atomic
270    /// (default 10) — there is NO env var read on the hot path.
271    search_limit_override: AtomicUsize,
272    /// 0.7.2 PR-2b — debug-only fault injection: when set, `recompute_mean_in_tx`
273    /// errors AFTER writing `mean_vec` but BEFORE finishing the re-quantize
274    /// pass, so the crash-atomicity test can prove the whole recompute rolls
275    /// back (no half-recentered corpus). One-shot (cleared on consume).
276    #[cfg(debug_assertions)]
277    force_recompute_failure: AtomicBool,
278}
279
280impl std::fmt::Debug for ProjectionRuntimeShared {
281    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
282        f.debug_struct("ProjectionRuntimeShared")
283            .field("path", &self.path)
284            .field("embedder_identity", &self.embedder_identity)
285            .finish_non_exhaustive()
286    }
287}
288
289#[derive(Debug)]
290struct ProjectionRuntime {
291    shared: Arc<ProjectionRuntimeShared>,
292    dispatcher: Mutex<Option<JoinHandle<()>>>,
293    workers: Mutex<Vec<JoinHandle<()>>>,
294}
295
296impl std::fmt::Debug for Engine {
297    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
298        f.debug_struct("Engine")
299            .field("path", &self.path)
300            .field("closed", &self.closed.load(Ordering::SeqCst))
301            .field("runtime_embedder_identity", &self.runtime_embedder_identity)
302            .finish_non_exhaustive()
303    }
304}
305
306/// Per-connection profile-callback context.
307///
308/// Holds the registry handle the callback dispatches to, plus shared
309/// references to the engine's profiling toggle and slow-statement
310/// threshold. The `Arc` clones here mirror the same atomics held by
311/// `Engine`, so `set_profiling` / `set_slow_threshold_ms` mutations are
312/// visible inside the callback without restart (REQ-006a / AC-005a /
313/// AC-007b runtime-toggle contract).
314#[derive(Debug)]
315struct ProfileContext {
316    subscribers: Arc<lifecycle::SubscriberRegistry>,
317    profiling_enabled: Arc<AtomicBool>,
318    slow_threshold_ms: Arc<AtomicU64>,
319}
320
321/// Thread-affine reader worker pool (Pack 6 F.0).
322///
323/// Per `dev/design/engine.md` § Writer / reader split, reader connections
324/// must not serialize behind a single mutex. Each worker thread owns
325/// exactly one read-only `Connection` for its lifetime; `Connection`
326/// objects never cross thread boundaries after startup. `Engine::search`
327/// dispatches a request via a per-worker bounded channel using a
328/// lock-free round-robin counter on the hot path.
329struct ReaderWorkerPool {
330    senders: Vec<SyncSender<ReaderRequest>>,
331    handles: Mutex<Option<Vec<JoinHandle<()>>>>,
332    next: AtomicUsize,
333    shutdown: AtomicBool,
334    live_workers: Arc<AtomicUsize>,
335}
336
337impl std::fmt::Debug for ReaderWorkerPool {
338    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
339        f.debug_struct("ReaderWorkerPool")
340            .field("worker_count", &self.senders.len())
341            .field("live_workers", &self.live_workers.load(Ordering::Relaxed))
342            .field("shutdown", &self.shutdown.load(Ordering::Relaxed))
343            .finish()
344    }
345}
346
347/// One request handled by exactly one reader worker. The response is
348/// returned through a fresh oneshot channel so requests cannot be
349/// routed to or duplicated across workers.
350enum ReaderRequest {
351    Search {
352        compiled: fathomdb_query::CompiledQuery,
353        /// Un-centered f32 query vector serialized for `vec_f32`. Phase 2
354        /// f32 rerank uses this verbatim.
355        query_vector: Option<String>,
356        /// EU-5a2 — (possibly centered) f32 query vector for the phase 1
357        /// `vec_quantize_binary` sign-quant. Equal to `query_vector` for
358        /// non-MC-required identities (the EU-5a2 default).
359        query_vector_bin: Option<String>,
360        /// 0.7.2 PR-2bc S1 fix-1 — phase-2 rerank `LIMIT`. Read from
361        /// `ProjectionRuntimeShared::search_limit_override` (default
362        /// `SEARCH_RERANK_LIMIT` = 10, clamped >=10) by `search_inner`
363        /// before dispatch, so the worker never reads any env var.
364        search_limit: usize,
365        respond: SyncSender<ReaderResponse>,
366    },
367    Shutdown,
368    /// Pack 6.G G.1 — debug-only request that asks a worker to read its
369    /// own connection's `SQLITE_DBSTATUS_LOOKASIDE_USED` and return the
370    /// high-water mark (`hiwtr` out-param). Used solely by the integration
371    /// test that asserts post-warmup lookaside slots were consumed; not
372    /// on any production path.
373    #[cfg(debug_assertions)]
374    LookasideStatus {
375        respond: SyncSender<i32>,
376    },
377    /// Pack 6.G G.3.5 — debug-only request that asks a worker to read
378    /// `SQLITE_DBSTATUS_CACHE_HIT`, `_CACHE_MISS`, and `_CACHE_USED`
379    /// off its own connection and return them as `(hit, miss, used_bytes)`.
380    /// `snapshot_label` is opaque to the worker; the caller uses it to
381    /// distinguish pre/post snapshots in its own bookkeeping.
382    #[cfg(debug_assertions)]
383    CacheStatus {
384        snapshot_label: String,
385        respond: SyncSender<(String, i32, i32, i32)>,
386    },
387}
388
389type ReaderResponse = rusqlite::Result<(u64, Option<SoftFallback>, Vec<String>)>;
390
391/// Pack 6.G G.3.5 — per-worker cache-pressure snapshot. Carried only on
392/// the debug-only `CacheStatus` broadcast path and the test accessor;
393/// not part of the public 0.6.0 surface.
394#[cfg(debug_assertions)]
395#[doc(hidden)]
396#[derive(Clone, Debug)]
397pub struct CacheStatusReply {
398    pub worker_idx: usize,
399    pub snapshot_label: String,
400    pub cache_hit: i32,
401    pub cache_miss: i32,
402    pub cache_used_bytes: i32,
403}
404
405/// Per-worker outbound channel capacity. Round-robin dispatch keeps
406/// queue depth at ~0 on hot paths; the small slack absorbs jitter
407/// without a runtime mutex.
408const READER_WORKER_CHANNEL_CAPACITY: usize = 4;
409
410impl ReaderWorkerPool {
411    fn new(connections: Vec<Connection>) -> Self {
412        let live_workers = Arc::new(AtomicUsize::new(0));
413        let mut senders = Vec::with_capacity(connections.len());
414        let mut handles = Vec::with_capacity(connections.len());
415        for (idx, connection) in connections.into_iter().enumerate() {
416            let (tx, rx) = mpsc::sync_channel::<ReaderRequest>(READER_WORKER_CHANNEL_CAPACITY);
417            let live = Arc::clone(&live_workers);
418            let handle = thread::Builder::new()
419                .name(format!("fathomdb-reader-{idx}"))
420                .spawn(move || reader_worker_loop(connection, rx, live))
421                .expect("spawn reader worker");
422            senders.push(tx);
423            handles.push(handle);
424        }
425        Self {
426            senders,
427            handles: Mutex::new(Some(handles)),
428            next: AtomicUsize::new(0),
429            shutdown: AtomicBool::new(false),
430            live_workers,
431        }
432    }
433
434    fn worker_count(&self) -> usize {
435        self.senders.len()
436    }
437
438    fn live_count(&self) -> usize {
439        self.live_workers.load(Ordering::SeqCst)
440    }
441
442    /// Pack 6.G G.1 — broadcast a `LookasideStatus` request to every
443    /// worker (not round-robin) and collect each worker's
444    /// `SQLITE_DBSTATUS_LOOKASIDE_USED`. Used only by the debug
445    /// integration test for post-warmup lookaside-slot consumption.
446    #[cfg(debug_assertions)]
447    fn lookaside_used_per_worker(&self) -> Vec<i32> {
448        let mut results = Vec::with_capacity(self.senders.len());
449        for sender in &self.senders {
450            let (tx, rx) = mpsc::sync_channel::<i32>(1);
451            if sender.send(ReaderRequest::LookasideStatus { respond: tx }).is_ok() {
452                results.push(rx.recv().unwrap_or(-1));
453            } else {
454                results.push(-1);
455            }
456        }
457        results
458    }
459
460    /// Pack 6.G G.3.5 — broadcast a `CacheStatus` request to every
461    /// worker and collect each worker's `(cache_hit, cache_miss,
462    /// cache_used_bytes)` triple. Same broadcast pattern as G.1's
463    /// `lookaside_used_per_worker`. Returns one `CacheStatusReply` per
464    /// worker in worker-index order.
465    #[cfg(debug_assertions)]
466    fn cache_status_per_worker(&self, snapshot_label: &str) -> Vec<CacheStatusReply> {
467        let mut results = Vec::with_capacity(self.senders.len());
468        for (idx, sender) in self.senders.iter().enumerate() {
469            let (tx, rx) = mpsc::sync_channel::<(String, i32, i32, i32)>(1);
470            let request = ReaderRequest::CacheStatus {
471                snapshot_label: snapshot_label.to_string(),
472                respond: tx,
473            };
474            if sender.send(request).is_ok() {
475                if let Ok((label, hit, miss, used)) = rx.recv() {
476                    results.push(CacheStatusReply {
477                        worker_idx: idx,
478                        snapshot_label: label,
479                        cache_hit: hit,
480                        cache_miss: miss,
481                        cache_used_bytes: used,
482                    });
483                    continue;
484                }
485            }
486            results.push(CacheStatusReply {
487                worker_idx: idx,
488                snapshot_label: snapshot_label.to_string(),
489                cache_hit: -1,
490                cache_miss: -1,
491                cache_used_bytes: -1,
492            });
493        }
494        results
495    }
496
497    /// Hot path. Lock-free dispatch: `AtomicUsize::fetch_add` selects
498    /// the worker, then a single `SyncSender::send` enqueues the
499    /// request. No global mutex is taken on the request path.
500    fn dispatch(&self, request: ReaderRequest) -> Result<(), ReaderRequest> {
501        if self.shutdown.load(Ordering::Relaxed) {
502            return Err(request);
503        }
504        let n = self.senders.len();
505        if n == 0 {
506            return Err(request);
507        }
508        let idx = self.next.fetch_add(1, Ordering::Relaxed) % n;
509        self.senders[idx].send(request).map_err(|err| err.0)
510    }
511
512    /// Signal every worker to exit and join its thread. Idempotent —
513    /// safe to call from `Engine::close` and again from
514    /// `ReaderWorkerPool::Drop`.
515    fn shutdown(&self) {
516        if self.shutdown.swap(true, Ordering::SeqCst) {
517            return;
518        }
519        for sender in &self.senders {
520            let _ = sender.send(ReaderRequest::Shutdown);
521        }
522        if let Ok(mut slot) = self.handles.lock() {
523            if let Some(handles) = slot.take() {
524                for handle in handles {
525                    let _ = handle.join();
526                }
527            }
528        }
529    }
530}
531
532impl Drop for ReaderWorkerPool {
533    fn drop(&mut self) {
534        self.shutdown();
535    }
536}
537
538fn reader_worker_loop(
539    mut connection: Connection,
540    rx: Receiver<ReaderRequest>,
541    live_workers: Arc<AtomicUsize>,
542) {
543    live_workers.fetch_add(1, Ordering::SeqCst);
544    // Drop guard so the live counter decrements even on panic.
545    struct LiveGuard(Arc<AtomicUsize>);
546    impl Drop for LiveGuard {
547        fn drop(&mut self) {
548            self.0.fetch_sub(1, Ordering::SeqCst);
549        }
550    }
551    let _guard = LiveGuard(live_workers);
552
553    while let Ok(request) = rx.recv() {
554        match request {
555            ReaderRequest::Shutdown => break,
556            ReaderRequest::Search {
557                compiled,
558                query_vector,
559                query_vector_bin,
560                search_limit,
561                respond,
562            } => {
563                let result = read_search_in_tx(
564                    &mut connection,
565                    &compiled,
566                    query_vector.as_deref(),
567                    query_vector_bin.as_deref(),
568                    search_limit,
569                );
570                // Receiver may have been dropped if the caller went
571                // away; nothing to do in that case.
572                let _ = respond.send(result);
573            }
574            #[cfg(debug_assertions)]
575            ReaderRequest::LookasideStatus { respond } => {
576                let _ = respond.send(read_lookaside_used_hiwtr(&connection));
577            }
578            #[cfg(debug_assertions)]
579            ReaderRequest::CacheStatus { snapshot_label, respond } => {
580                let (hit, miss, used) = read_cache_status(&connection);
581                let _ = respond.send((snapshot_label, hit, miss, used));
582            }
583        }
584    }
585
586    // Per `dev/design/engine.md` § Close path, uninstall the profile
587    // callback before dropping the connection so SQLite cannot fire
588    // one last callback against a `ProfileContext` whose Box is about
589    // to free.
590    uninstall_profile_callback(&connection);
591    drop(connection);
592}
593
594impl ProjectionRuntime {
595    fn new(
596        path: PathBuf,
597        embedder: Option<Arc<dyn Embedder>>,
598        embedder_identity: EmbedderIdentity,
599        mean_already_pinned: bool,
600    ) -> Self {
601        // EU-5b/EU-5f — only allocate the streaming accumulator when the
602        // workspace's identity is MC-required AND no mean has been pinned
603        // yet on disk. Allocating it for an already-pinned workspace would
604        // let a later 256-doc run RE-pin and overwrite the compute-once
605        // mean (violating `dev/design/embedder.md` §0.3). Other identities
606        // pay no memory cost (`Option::None`).
607        let mc_required = identity_requires_mean_centering(&embedder_identity);
608        let mean_accumulator = if mc_required && !mean_already_pinned {
609            Some(MeanAccumulator::new(embedder_identity.dimension as usize))
610        } else {
611            None
612        };
613        let shared = Arc::new(ProjectionRuntimeShared {
614            path,
615            embedder,
616            embedder_identity,
617            state: Mutex::new(ProjectionRuntimeState::default()),
618            state_cvar: Condvar::new(),
619            queue: Mutex::new(VecDeque::new()),
620            queue_cvar: Condvar::new(),
621            retry_delays_ms: Mutex::new(DEFAULT_PROJECTION_RETRY_DELAYS_MS.to_vec()),
622            embed_timeout_ms: AtomicU64::new(DEFAULT_EMBED_TIMEOUT_MS),
623            embed_serialize: Mutex::new(()),
624            live_embed_threads: Arc::new(AtomicU64::new(0)),
625            embed_circuit_open: AtomicBool::new(false),
626            embed_circuit_threshold: AtomicU64::new(DEFAULT_EMBED_CIRCUIT_THRESHOLD),
627            mean_accumulator: Mutex::new(mean_accumulator),
628            pending_events: Mutex::new(Vec::new()),
629            commit_gate: Mutex::new(()),
630            search_limit_override: AtomicUsize::new(SEARCH_RERANK_LIMIT),
631            #[cfg(debug_assertions)]
632            force_recompute_failure: AtomicBool::new(false),
633        });
634
635        let dispatcher_shared = Arc::clone(&shared);
636        let dispatcher = thread::spawn(move || projection_dispatcher_loop(dispatcher_shared));
637
638        let mut workers = Vec::with_capacity(PROJECTION_WORKERS);
639        for _ in 0..PROJECTION_WORKERS {
640            let worker_shared = Arc::clone(&shared);
641            workers.push(thread::spawn(move || projection_worker_loop(worker_shared)));
642        }
643
644        Self { shared, dispatcher: Mutex::new(Some(dispatcher)), workers: Mutex::new(workers) }
645    }
646
647    fn notify_new_work(&self) {
648        if let Ok(mut state) = self.shared.state.lock() {
649            state.pending_scan = true;
650            self.shared.state_cvar.notify_all();
651        }
652    }
653
654    fn set_frozen(&self, frozen: bool) {
655        if let Ok(mut state) = self.shared.state.lock() {
656            state.frozen = frozen;
657            if !frozen {
658                state.pending_scan = true;
659            }
660            self.shared.state_cvar.notify_all();
661        }
662    }
663
664    fn wait_for_idle(&self, timeout_ms: u64) -> bool {
665        let deadline = Instant::now() + Duration::from_millis(timeout_ms);
666        let mut state = match self.shared.state.lock() {
667            Ok(state) => state,
668            Err(_) => return false,
669        };
670        loop {
671            if state.active_jobs == 0 && state.queued_jobs == 0 {
672                drop(state);
673                if !database_has_pending_projection_work(&self.shared.path).unwrap_or(true) {
674                    return true;
675                }
676                state = match self.shared.state.lock() {
677                    Ok(state) => state,
678                    Err(_) => return false,
679                };
680            }
681            let now = Instant::now();
682            if now >= deadline {
683                return false;
684            }
685            let wait = deadline.saturating_duration_since(now);
686            let Ok((next_state, _)) = self.shared.state_cvar.wait_timeout(state, wait) else {
687                return false;
688            };
689            state = next_state;
690        }
691    }
692
693    fn set_retry_delays_for_test(&self, delays_ms: &[u64]) {
694        if let Ok(mut delays) = self.shared.retry_delays_ms.lock() {
695            *delays = delays_ms.to_vec();
696        }
697    }
698
699    fn set_embed_timeout_ms_for_test(&self, timeout_ms: u64) {
700        self.shared.embed_timeout_ms.store(timeout_ms, Ordering::Relaxed);
701    }
702
703    fn set_embed_circuit_threshold_for_test(&self, threshold: u64) {
704        self.shared.embed_circuit_threshold.store(threshold, Ordering::Relaxed);
705    }
706
707    fn embed_circuit_open_for_test(&self) -> bool {
708        self.shared.embed_circuit_open.load(Ordering::Relaxed)
709    }
710
711    fn stop(&self) {
712        if let Ok(mut state) = self.shared.state.lock() {
713            if state.stopping {
714                return;
715            }
716            state.stopping = true;
717            state.pending_scan = false;
718            self.shared.state_cvar.notify_all();
719        }
720        if let Ok(mut queue) = self.shared.queue.lock() {
721            queue.clear();
722            self.shared.queue_cvar.notify_all();
723        }
724
725        if let Ok(mut dispatcher) = self.dispatcher.lock() {
726            if let Some(handle) = dispatcher.take() {
727                let _ = handle.join();
728            }
729        }
730        if let Ok(mut workers) = self.workers.lock() {
731            for handle in workers.drain(..) {
732                let _ = handle.join();
733            }
734        }
735    }
736}
737
738#[derive(Clone, Debug, Eq, PartialEq)]
739pub struct OpenReport {
740    pub schema_version_before: u32,
741    pub schema_version_after: u32,
742    pub migration_steps: Vec<MigrationStepReport>,
743    pub embedder_warmup_ms: u64,
744    pub query_backend: &'static str,
745    pub default_embedder: EmbedderIdentity,
746    /// Total wall time the loader spent materializing default-embedder
747    /// weights — covers HF GETs, sha256 verification, atomic rename,
748    /// parent-dir fsync (POSIX), and cache directory writes. This is
749    /// the "engine open paid by the embedder" envelope, useful for SLA
750    /// budgeting; it is intentionally wider than just the bytes-flowing
751    /// time so callers see the full first-use cost.
752    ///
753    /// `Some(ms)` when network bytes flowed (`bytes_downloaded > 0`);
754    /// `None` for caller-supplied embedders (loader bypassed) and on
755    /// full cache hits (no bytes flowed). For pure per-file network
756    /// analysis, use the `DefaultEmbedderDownload` events on
757    /// [`embedder_events`](Self::embedder_events) — each event carries
758    /// the file's bytes + sha256 + cache path.
759    pub embedder_download_ms: Option<u64>,
760    /// Structured loader events (`dev/design/embedder.md` §7). Empty for
761    /// caller-supplied embedders; populated from `LoadedWeights.events`
762    /// for the Default path.
763    pub embedder_events: Vec<EmbedderEvent>,
764    /// Static identity capability (`dev/design/embedder.md` §0.6). True
765    /// iff the live embedder identity is the bge-small default, which is
766    /// the only identity that ships with the EU-5a2 mean-centering apply
767    /// paths. `false` for `fathomdb-noop` and for any other
768    /// caller-supplied identity. EU-5b's identity flip makes the Default
769    /// path return `true` here.
770    pub embedder_mean_centering_required: bool,
771    /// Dynamic workspace state (`dev/design/embedder.md` §0.6). True iff
772    /// `_fathomdb_embedder_profiles.mean_vec IS NOT NULL` for the default
773    /// profile. EU-5a2 reads from the schema column added in migration
774    /// step 10; the value is dimension-validated (§0.2) at open time
775    /// and fails closed via `EmbedderIdentityMismatch` on drift.
776    pub embedder_mean_vec_pinned: bool,
777}
778
779#[derive(Debug)]
780pub struct OpenedEngine {
781    pub engine: Engine,
782    pub report: OpenReport,
783}
784
785/// EU-5b — loader-supplied open-time telemetry threaded into
786/// `OpenReport.embedder_download_ms` and `OpenReport.embedder_events`.
787#[derive(Clone, Debug)]
788struct LoaderInfo {
789    download_ms: Option<u64>,
790    events: Vec<EmbedderEvent>,
791}
792
793#[derive(Clone, Debug, Eq, PartialEq)]
794pub struct WriteReceipt {
795    pub cursor: u64,
796}
797
798/// Soft-fallback signal carried on hybrid `search` results.
799///
800/// Per `dev/design/retrieval.md` § Soft-fallback signal, this record is
801/// present only when one non-essential branch could not contribute. Total
802/// request failure is not expressed via this carrier.
803#[derive(Clone, Debug, Eq, PartialEq)]
804pub struct SoftFallback {
805    pub branch: SoftFallbackBranch,
806}
807
808/// Which retrieval branch could not contribute to a hybrid search.
809///
810/// `Vector` means the vector branch could not contribute; `Text` means the
811/// text branch could not contribute. Owned by `dev/design/retrieval.md`;
812/// the 0.6.0 enum is exactly these two members.
813#[derive(Clone, Copy, Debug, Eq, PartialEq)]
814pub enum SoftFallbackBranch {
815    Vector,
816    Text,
817}
818
819#[derive(Clone, Debug, Eq, PartialEq)]
820pub struct SearchResult {
821    pub projection_cursor: u64,
822    pub soft_fallback: Option<SoftFallback>,
823    pub results: Vec<String>,
824}
825
826/// Batch input shape for [`Engine::write`].
827///
828/// Marked `#[non_exhaustive]` per ADR-0.6.0-prepared-write-shape; new
829/// entity variants land in 0.6.x without a major bump. Adding fields to
830/// existing variants remains a binding-coordination change.
831#[non_exhaustive]
832#[derive(Clone, Debug, Eq, PartialEq)]
833pub enum PreparedWrite {
834    Node {
835        kind: String,
836        body: String,
837        /// REQ-026 / AC-028 / AC-042 recovery seam. `None` is the
838        /// back-compat default and lands as NULL on disk; callers that
839        /// participate in `excise_source` / `trace_source_ref` must
840        /// supply a stable identifier.
841        source_id: Option<String>,
842    },
843    Edge {
844        kind: String,
845        from: String,
846        to: String,
847        /// REQ-026 / AC-028 / AC-042 recovery seam — see Node.
848        source_id: Option<String>,
849    },
850    OpStore {
851        collection: String,
852        record_key: String,
853        schema_id: Option<String>,
854        body: String,
855    },
856    AdminSchema {
857        name: String,
858        kind: String,
859        schema_json: String,
860        retention_json: String,
861    },
862}
863
864/// Snapshot of engine-internal counters returned by [`Engine::counters`].
865///
866/// Public key set is owned by `dev/design/lifecycle.md` § Public key set
867/// and locked by AC-004a. Reading a snapshot is non-perturbing per
868/// AC-004c. The 0.6.0 surface exposes exactly these seven fields.
869#[derive(Clone, Debug, Default, Eq, PartialEq)]
870pub struct CounterSnapshot {
871    pub queries: u64,
872    pub writes: u64,
873    pub write_rows: u64,
874    pub errors_by_code: BTreeMap<String, u64>,
875    pub admin_ops: u64,
876    pub cache_hit: u64,
877    pub cache_miss: u64,
878}
879
880pub use lifecycle::Subscription;
881
882/// Stable corruption-on-open detail carried by
883/// [`EngineOpenError::Corruption`].
884///
885/// Layout owned by `dev/design/errors.md` § Corruption detail owner.
886#[derive(Clone, Debug, Eq, PartialEq)]
887pub struct CorruptionDetail {
888    pub kind: CorruptionKind,
889    pub stage: OpenStage,
890    pub locator: CorruptionLocator,
891    pub recovery_hint: RecoveryHint,
892}
893
894/// Open-path corruption category.
895///
896/// 0.6.0 emits exactly the four members below; per
897/// `dev/design/errors.md` § Engine.open corruption table, doctor-only
898/// finding codes are not represented here.
899#[derive(Clone, Copy, Debug, Eq, PartialEq)]
900pub enum CorruptionKind {
901    WalReplayFailure,
902    HeaderMalformed,
903    SchemaInconsistent,
904    EmbedderIdentityDrift,
905}
906
907/// `Engine.open` stage at which corruption was detected.
908///
909/// Per ADR-0.6.0-corruption-open-behavior, `LockAcquisition` is intentionally
910/// not a member here; lock contention is surfaced via
911/// [`EngineOpenError::DatabaseLocked`].
912#[derive(Clone, Copy, Debug, Eq, PartialEq)]
913pub enum OpenStage {
914    WalReplay,
915    HeaderProbe,
916    SchemaProbe,
917    EmbedderIdentity,
918}
919
920/// Locator pointing at the corrupted region of the database file.
921///
922/// Variant set owned by `dev/design/errors.md` § CorruptionLocator
923/// ownership. `OpaqueSqliteError` is the required fallback when SQLite
924/// surfaces corruption without a usable structured locator.
925#[derive(Clone, Copy, Debug, Eq, PartialEq)]
926pub enum CorruptionLocator {
927    FileOffset { offset: u64 },
928    PageId { page: u32 },
929    TableRow { table: &'static str, rowid: i64 },
930    Vec0ShadowRow { partition: &'static str, rowid: i64 },
931    MigrationStep { from: u32, to: u32 },
932    OpaqueSqliteError { sqlite_extended_code: i32 },
933}
934
935/// Recovery dispatch surface attached to a corruption detail.
936///
937/// `code` is the stable dispatch key used by bindings and doctor output;
938/// `doc_anchor` points at the documentation section that explains the
939/// remediation path.
940#[derive(Clone, Copy, Debug, Eq, PartialEq)]
941pub struct RecoveryHint {
942    pub code: &'static str,
943    pub doc_anchor: &'static str,
944}
945
946#[derive(Clone, Debug, Eq, PartialEq)]
947pub enum EngineOpenError {
948    DatabaseLocked {
949        holder_pid: Option<u32>,
950    },
951    Corruption(CorruptionDetail),
952    IncompatibleSchemaVersion {
953        seen: u32,
954        supported: u32,
955    },
956    MigrationError {
957        schema_version_before: u32,
958        schema_version_current: u32,
959        step_id: u32,
960    },
961    EmbedderIdentityMismatch {
962        stored: EmbedderIdentity,
963        supplied: EmbedderIdentity,
964    },
965    EmbedderDimensionMismatch {
966        stored: u32,
967        supplied: u32,
968    },
969    /// Embedder runtime returned a typed error during `Engine::open`.
970    Embedder(RuntimeEmbedderError),
971    Io {
972        message: String,
973    },
974}
975
976/// Caller-facing selector for the embedder used by an opened engine
977/// (`dev/design/embedder.md` §0).
978#[derive(Clone)]
979pub enum EmbedderChoice {
980    /// Use the engine's default embedder. With the `default-embedder`
981    /// Cargo feature enabled, this materializes a `CandleBgeEmbedder`
982    /// via the EU-3 loader at `Engine::open`; on first use the loader
983    /// downloads pinned bge-small-en-v1.5 weights from HuggingFace per
984    /// `ADR-0.7.1-default-embedder-weight-fetch`. Without the feature,
985    /// this returns `EmbedderError::Failed` directing the caller to
986    /// rebuild with `--features default-embedder` or supply
987    /// `EmbedderChoice::Caller`.
988    Default,
989    /// Caller supplies the embedder instance. The supplied embedder's
990    /// `identity()` becomes the workspace's default-profile identity.
991    Caller(Arc<dyn Embedder>),
992    /// No embedder configured. Engine opens; subsequent vector writes
993    /// fail with `EngineError::EmbedderNotConfigured`. Useful for
994    /// read-only or canonical-only flows.
995    None,
996}
997
998impl Display for EngineOpenError {
999    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1000        match self {
1001            Self::DatabaseLocked { holder_pid } => match holder_pid {
1002                Some(pid) => write!(f, "database is locked by process {pid}"),
1003                None => write!(f, "database is locked by another engine instance"),
1004            },
1005            Self::Corruption(detail) => {
1006                write!(
1007                    f,
1008                    "engine corruption at {:?} stage: {}",
1009                    detail.stage, detail.recovery_hint.code
1010                )
1011            }
1012            Self::IncompatibleSchemaVersion { seen, supported } => write!(
1013                f,
1014                "database schema version {seen} is incompatible with supported version {supported}"
1015            ),
1016            Self::MigrationError {
1017                schema_version_before,
1018                schema_version_current,
1019                step_id,
1020            } => write!(
1021                f,
1022                "schema migration failed at step {step_id}; schema version remained between {schema_version_before} and {schema_version_current}"
1023            ),
1024            Self::EmbedderIdentityMismatch { stored, supplied } => write!(
1025                f,
1026                "embedder identity mismatch: stored {}@{}, supplied {}@{}",
1027                stored.name, stored.revision, supplied.name, supplied.revision,
1028            ),
1029            Self::EmbedderDimensionMismatch { stored, supplied } => write!(
1030                f,
1031                "embedder vector dimension mismatch: stored {stored}, supplied {supplied}",
1032            ),
1033            Self::Embedder(err) => match err {
1034                RuntimeEmbedderError::Timeout => write!(f, "embedder timeout during open"),
1035                RuntimeEmbedderError::Failed { message } => {
1036                    write!(f, "embedder failure during open: {message}")
1037                }
1038            },
1039            Self::Io { message } => write!(f, "database I/O error: {message}"),
1040        }
1041    }
1042}
1043
1044impl Error for EngineOpenError {}
1045
1046#[derive(Clone, Debug, Eq, PartialEq)]
1047pub enum EngineError {
1048    Storage,
1049    Projection,
1050    Vector,
1051    Embedder,
1052    EmbedderNotConfigured,
1053    KindNotVectorIndexed,
1054    EmbedderDimensionMismatch { expected: u32, actual: u32 },
1055    Scheduler,
1056    OpStore,
1057    WriteValidation,
1058    SchemaValidation,
1059    Overloaded,
1060    Closing,
1061}
1062
1063impl Display for EngineError {
1064    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1065        match self {
1066            Self::Storage => write!(f, "storage error"),
1067            Self::Projection => write!(f, "projection error"),
1068            Self::Vector => write!(f, "vector error"),
1069            Self::Embedder => write!(f, "embedder error"),
1070            Self::EmbedderNotConfigured => write!(f, "embedder is not configured"),
1071            Self::KindNotVectorIndexed => write!(f, "kind is not configured for vector indexing"),
1072            Self::EmbedderDimensionMismatch { expected, actual } => {
1073                write!(f, "embedder dimension mismatch: expected {expected}, actual {actual}")
1074            }
1075            Self::Scheduler => write!(f, "scheduler error"),
1076            Self::OpStore => write!(f, "op-store error"),
1077            Self::WriteValidation => write!(f, "write validation error"),
1078            Self::SchemaValidation => write!(f, "schema validation error"),
1079            Self::Overloaded => write!(f, "engine overloaded"),
1080            Self::Closing => write!(f, "engine is closing"),
1081        }
1082    }
1083}
1084
1085impl EngineError {
1086    /// Stable machine-readable code for `errors_by_code` keys.
1087    ///
1088    /// Names match the binding-facing class stems in
1089    /// `dev/design/errors.md` § Binding-facing class matrix.
1090    fn stable_code(&self) -> &'static str {
1091        match self {
1092            Self::Storage => "StorageError",
1093            Self::Projection => "ProjectionError",
1094            Self::Vector => "VectorError",
1095            Self::Embedder => "EmbedderError",
1096            Self::EmbedderNotConfigured => "EmbedderNotConfiguredError",
1097            Self::KindNotVectorIndexed => "KindNotVectorIndexedError",
1098            Self::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
1099            Self::Scheduler => "SchedulerError",
1100            Self::OpStore => "OpStoreError",
1101            Self::WriteValidation => "WriteValidationError",
1102            Self::SchemaValidation => "SchemaValidationError",
1103            Self::Overloaded => "OverloadedError",
1104            Self::Closing => "ClosingError",
1105        }
1106    }
1107}
1108
1109impl Error for EngineError {}
1110
1111/// Doctor `check-integrity` invocation flags. `quick` and `round_trip`
1112/// are accepted in 0.6.0 but treated as default; only `full` activates
1113/// `PRAGMA integrity_check`. Per `dev/design/recovery.md` § Doctor-only
1114/// flags.
1115#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1116pub struct CheckIntegrityOpts {
1117    pub quick: bool,
1118    pub full: bool,
1119    pub round_trip: bool,
1120}
1121
1122/// One section of an [`IntegrityReport`]. Either every check in the
1123/// section was clean, or one or more typed [`Finding`]s describe the
1124/// detected issue. Per AC-043b.
1125#[derive(Clone, Debug, Eq, PartialEq)]
1126pub enum Section {
1127    Clean,
1128    Findings(Vec<Finding>),
1129}
1130
1131/// Single doctor finding record. Stable report-shape per AC-043c. The
1132/// `code` and `doc_anchor` strings are stable dispatch keys owned by
1133/// `dev/design/recovery.md` § Code-to-operator-action cross-reference.
1134#[derive(Clone, Debug, Eq, PartialEq)]
1135pub struct Finding {
1136    pub code: &'static str,
1137    pub stage: &'static str,
1138    pub locator: CorruptionLocator,
1139    pub doc_anchor: &'static str,
1140    pub detail: String,
1141}
1142
1143/// Three-section integrity report. AC-043a pins exactly these three
1144/// keys.
1145#[derive(Clone, Debug, Eq, PartialEq)]
1146pub struct IntegrityReport {
1147    pub physical: Section,
1148    pub logical: Section,
1149    pub semantic: Section,
1150}
1151
1152/// Result of a successful [`Engine::safe_export`] call. The returned
1153/// `manifest_sha256` equals the SHA-256 of the export file bytes (per
1154/// AC-039a) and matches the `sha256` field written into the manifest
1155/// JSON.
1156#[derive(Clone, Debug, Eq, PartialEq)]
1157pub struct SafeExportArtifact {
1158    pub export_path: PathBuf,
1159    pub manifest_path: PathBuf,
1160    pub manifest_sha256: String,
1161}
1162
1163/// Phase 9 Pack B trace report (AC-042). One event per canonical row
1164/// attributable to the requested `source_id`, ordered by `write_cursor`
1165/// ascending.
1166#[derive(Clone, Debug, Eq, PartialEq)]
1167pub struct TraceReport {
1168    pub source_ref: String,
1169    pub events: Vec<TraceEvent>,
1170}
1171
1172/// Single canonical-row tracing record. `table` is one of
1173/// `"canonical_nodes"` or `"canonical_edges"`.
1174#[derive(Clone, Debug, Eq, PartialEq)]
1175pub struct TraceEvent {
1176    pub write_cursor: u64,
1177    pub kind: String,
1178    pub table: &'static str,
1179}
1180
1181/// Which shadow-state surface a [`RebuildReport`] describes.
1182/// `Projections` covers the full FTS5 + vec0 + projection-terminal
1183/// rebuild emitted by [`Engine::rebuild_projections`]. `Vec0` covers
1184/// the vec0-only path emitted by [`Engine::rebuild_vec0`].
1185#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1186pub enum RebuildKind {
1187    Projections,
1188    Vec0,
1189}
1190
1191/// Structured result of a rebuild operation. `rows_invalidated` is the
1192/// total shadow-state rows truncated before re-derivation; `rows_rebuilt`
1193/// is the count of rows the synchronous rebuild loop re-materialised
1194/// (asynchronous re-enqueue work performed by the projection scheduler is
1195/// not counted here). `projection_cursor_after` is the post-rebuild value
1196/// of the projection cursor.
1197#[derive(Clone, Debug, Eq, PartialEq)]
1198pub struct RebuildReport {
1199    pub kind: RebuildKind,
1200    pub rows_invalidated: u64,
1201    pub rows_rebuilt: u64,
1202    pub projection_cursor_after: u64,
1203}
1204
1205/// Phase 9 Pack B excise report (AC-028a/b/c). Counts are post-excise
1206/// totals; `projections_invalidated` reports the shadow-row invalidation
1207/// total (FTS5 + vec0 + projection terminal) for the excised source.
1208#[derive(Clone, Debug, Eq, PartialEq)]
1209pub struct ExciseReport {
1210    pub source_ref: String,
1211    pub nodes_excised: u64,
1212    pub edges_excised: u64,
1213    pub projections_invalidated: u64,
1214}
1215
1216/// Typed outcome of [`Engine::verify_embedder`]. Mismatches do not raise
1217/// `EngineError`; the operator workflow needs to see the stored vs.
1218/// supplied pair to decide on next action.
1219#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1220pub enum VerifyEmbedderStatus {
1221    Match,
1222    IdentityMismatch,
1223    DimensionMismatch,
1224    BothMismatch,
1225}
1226
1227/// Result of [`Engine::verify_embedder`]. `stored_identity` is the
1228/// `name:revision` pair persisted in `_fathomdb_embedder_profiles`;
1229/// `supplied_identity` echoes the operator's input verbatim.
1230#[derive(Clone, Debug, Eq, PartialEq)]
1231pub struct VerifyEmbedderReport {
1232    pub stored_identity: String,
1233    pub stored_dimension: u32,
1234    pub supplied_identity: String,
1235    pub supplied_dimension: u32,
1236    pub status: VerifyEmbedderStatus,
1237}
1238
1239/// Single table or index entry emitted by [`Engine::dump_schema`].
1240#[derive(Clone, Debug, Eq, PartialEq)]
1241pub struct SchemaObject {
1242    pub name: String,
1243    pub sql: String,
1244}
1245
1246/// Result of [`Engine::dump_schema`]. `user_version` is the
1247/// `PRAGMA user_version` sentinel. Canonical tables appear first per
1248/// [`fathomdb_schema::CANONICAL_TABLES`], then remaining non-`sqlite_*`
1249/// tables alphabetically. Indexes follow the same alphabetical rule.
1250#[derive(Clone, Debug, Eq, PartialEq)]
1251pub struct DumpSchemaReport {
1252    pub user_version: u32,
1253    pub tables: Vec<SchemaObject>,
1254    pub indexes: Vec<SchemaObject>,
1255}
1256
1257/// Single canonical-table row count emitted by [`Engine::dump_row_counts`].
1258#[derive(Clone, Debug, Eq, PartialEq)]
1259pub struct TableRowCount {
1260    pub name: String,
1261    pub rows: u64,
1262}
1263
1264/// Result of [`Engine::dump_row_counts`]. Canonical tables only;
1265/// projection / FTS / vec0 shadow tables are excluded. Order matches
1266/// [`fathomdb_schema::CANONICAL_TABLES`].
1267#[derive(Clone, Debug, Eq, PartialEq)]
1268pub struct DumpRowCountsReport {
1269    pub counts: Vec<TableRowCount>,
1270}
1271
1272/// Result of [`Engine::dump_profile`]. Mirrors the open-time embedder
1273/// posture + the per-kind vector configuration registered in
1274/// `_fathomdb_vector_kinds`.
1275#[derive(Clone, Debug, Eq, PartialEq)]
1276pub struct DumpProfileReport {
1277    pub embedder_identity: String,
1278    pub embedder_dimension: u32,
1279    pub vectorized_kinds: Vec<String>,
1280}
1281
1282/// 0.7.2 PR-2b — result of [`Engine::recompute_mean`] (the manual
1283/// `doctor recompute-mean` path) and of the shared in-transaction
1284/// recompute core. `drift_cos_before` is the cosine between the freshly
1285/// derived corpus mean and the previously-pinned mean (1.0 when nothing
1286/// was pinned yet, i.e. a first pin). `mean_was_pinned` distinguishes a
1287/// refresh of an existing mean from an initial pin. See
1288/// `dev/design/embedder.md` §0.3.
1289#[derive(Clone, Debug, PartialEq)]
1290pub struct MeanRecomputeReport {
1291    pub dim: u32,
1292    pub old_doc_count: u64,
1293    pub doc_count_requantized: u64,
1294    pub drift_cos_before: f32,
1295    pub mean_was_pinned: bool,
1296    pub elapsed_ms: u64,
1297}
1298
1299/// Typed outcome of [`Engine::truncate_wal`]. `Done` matches SQLite's
1300/// `busy = 0` return from `PRAGMA wal_checkpoint(TRUNCATE)`; any other
1301/// value surfaces as `Busy`.
1302#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1303pub enum TruncateWalStatus {
1304    Done,
1305    Busy,
1306}
1307
1308/// Result of [`Engine::truncate_wal`]. Carries the three counters
1309/// returned by `PRAGMA wal_checkpoint(TRUNCATE)`: `busy`, `log_frames`,
1310/// `checkpointed_frames`.
1311#[derive(Clone, Debug, Eq, PartialEq)]
1312pub struct TruncateWalReport {
1313    pub status: TruncateWalStatus,
1314    pub busy: u32,
1315    pub log_frames: u32,
1316    pub checkpointed_frames: u32,
1317}
1318
1319impl Drop for Engine {
1320    fn drop(&mut self) {
1321        let _ = self.close();
1322    }
1323}
1324
1325impl Engine {
1326    pub fn open(path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
1327        Self::open_with_embedder_and_subscriber(
1328            path,
1329            default_embedder_identity(),
1330            None,
1331            None,
1332            None,
1333            &mut |_| {},
1334        )
1335    }
1336
1337    /// Open an engine with an explicit [`EmbedderChoice`].
1338    ///
1339    /// Per `dev/design/embedder.md` §0 + the 0.7.1 EU-5 campaign, this is
1340    /// the canonical entry point for selecting how the workspace's
1341    /// default embedder is supplied. See [`EmbedderChoice`] for the
1342    /// semantics of each variant; in particular `Default` materializes
1343    /// the pinned BGE embedder via the loader when the `default-embedder`
1344    /// feature is enabled.
1345    pub fn open_with_choice(
1346        path: impl Into<PathBuf>,
1347        choice: EmbedderChoice,
1348    ) -> Result<OpenedEngine, EngineOpenError> {
1349        match choice {
1350            EmbedderChoice::Default => Self::open_default_embedder(path),
1351            EmbedderChoice::Caller(embedder) => {
1352                let identity = embedder.identity();
1353                Self::open_with_embedder_and_subscriber(
1354                    path,
1355                    identity,
1356                    Some(embedder),
1357                    None,
1358                    None,
1359                    &mut |_| {},
1360                )
1361            }
1362            EmbedderChoice::None => Self::open_with_embedder_and_subscriber(
1363                path,
1364                default_embedder_identity(),
1365                None,
1366                None,
1367                None,
1368                &mut |_| {},
1369            ),
1370        }
1371    }
1372
1373    /// EU-5b: materialize the engine's pinned default embedder
1374    /// (`CandleBgeEmbedder` backed by the EU-3 loader) and open the
1375    /// workspace with it. Without the `default-embedder` feature, fails
1376    /// with a typed `Embedder` error rather than touching the network.
1377    #[cfg(feature = "default-embedder")]
1378    fn open_default_embedder(path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
1379        use std::time::Instant as DownloadInstant;
1380        let download_start = DownloadInstant::now();
1381        let weights = fathomdb_embedder::loader::load_pinned_default_embedder().map_err(|err| {
1382            EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
1383                message: format!("default embedder loader: {err}"),
1384            })
1385        })?;
1386        let events = weights.events.clone();
1387        let download_ms = if weights.bytes_downloaded > 0 {
1388            Some(u64::try_from(download_start.elapsed().as_millis()).unwrap_or(u64::MAX))
1389        } else {
1390            None
1391        };
1392        let embedder =
1393            fathomdb_embedder::CandleBgeEmbedder::new_from_weights(weights).map_err(|err| {
1394                EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
1395                    message: format!("default embedder construct: {err}"),
1396                })
1397            })?;
1398        let embedder: Arc<dyn Embedder> = Arc::new(embedder);
1399        let identity = embedder.identity();
1400        let loader_info = LoaderInfo { download_ms, events };
1401        Self::open_with_embedder_and_subscriber(
1402            path,
1403            identity,
1404            Some(embedder),
1405            Some(loader_info),
1406            None,
1407            &mut |_| {},
1408        )
1409    }
1410
1411    #[cfg(not(feature = "default-embedder"))]
1412    fn open_default_embedder(_path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
1413        Err(EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
1414            message: "EmbedderChoice::Default requires the `default-embedder` Cargo feature"
1415                .to_string(),
1416        }))
1417    }
1418
1419    pub fn open_with_migration_event_sink(
1420        path: impl Into<PathBuf>,
1421        mut emit_migration_event: impl FnMut(&MigrationStepReport),
1422    ) -> Result<OpenedEngine, EngineOpenError> {
1423        Self::open_with_embedder_and_subscriber(
1424            path,
1425            default_embedder_identity(),
1426            None,
1427            None,
1428            None,
1429            &mut emit_migration_event,
1430        )
1431    }
1432
1433    #[cfg(debug_assertions)]
1434    #[doc(hidden)]
1435    pub fn open_with_migrations_for_test(
1436        path: impl Into<PathBuf>,
1437        migrations: &'static [fathomdb_schema::Migration],
1438        mut emit_migration_event: impl FnMut(&MigrationStepReport),
1439    ) -> Result<OpenedEngine, EngineOpenError> {
1440        Self::open_with_migrations(
1441            path,
1442            migrations,
1443            default_embedder_identity(),
1444            None,
1445            None,
1446            &mut emit_migration_event,
1447            None,
1448        )
1449    }
1450
1451    #[doc(hidden)]
1452    pub fn open_with_subscriber_for_test(
1453        path: impl Into<PathBuf>,
1454        subscriber: Arc<dyn lifecycle::Subscriber>,
1455    ) -> Result<OpenedEngine, EngineOpenError> {
1456        Self::open_with_embedder_and_subscriber(
1457            path,
1458            default_embedder_identity(),
1459            None,
1460            None,
1461            Some(subscriber),
1462            &mut |_| {},
1463        )
1464    }
1465
1466    #[doc(hidden)]
1467    pub fn open_without_embedder_for_test(
1468        path: impl Into<PathBuf>,
1469    ) -> Result<OpenedEngine, EngineOpenError> {
1470        Self::open_with_embedder_and_subscriber(
1471            path,
1472            default_embedder_identity(),
1473            None,
1474            None,
1475            None,
1476            &mut |_| {},
1477        )
1478    }
1479
1480    #[doc(hidden)]
1481    pub fn open_with_embedder_for_test(
1482        path: impl Into<PathBuf>,
1483        embedder: Arc<dyn Embedder>,
1484    ) -> Result<OpenedEngine, EngineOpenError> {
1485        let identity = embedder.identity();
1486        Self::open_with_embedder_and_subscriber(
1487            path,
1488            identity,
1489            Some(embedder),
1490            None,
1491            None,
1492            &mut |_| {},
1493        )
1494    }
1495
1496    fn open_with_embedder_and_subscriber(
1497        path: impl Into<PathBuf>,
1498        embedder_identity: EmbedderIdentity,
1499        runtime_embedder: Option<Arc<dyn Embedder>>,
1500        loader_info: Option<LoaderInfo>,
1501        initial_subscriber: Option<Arc<dyn lifecycle::Subscriber>>,
1502        emit_migration_event: &mut impl FnMut(&MigrationStepReport),
1503    ) -> Result<OpenedEngine, EngineOpenError> {
1504        Self::open_with_migrations(
1505            path,
1506            MIGRATIONS,
1507            embedder_identity,
1508            runtime_embedder,
1509            loader_info,
1510            emit_migration_event,
1511            initial_subscriber,
1512        )
1513    }
1514
1515    fn open_with_migrations(
1516        path: impl Into<PathBuf>,
1517        migrations: &'static [fathomdb_schema::Migration],
1518        embedder_identity: EmbedderIdentity,
1519        runtime_embedder: Option<Arc<dyn Embedder>>,
1520        loader_info: Option<LoaderInfo>,
1521        emit_migration_event: &mut impl FnMut(&MigrationStepReport),
1522        initial_subscriber: Option<Arc<dyn lifecycle::Subscriber>>,
1523    ) -> Result<OpenedEngine, EngineOpenError> {
1524        let canonical_path = canonical_database_path(&path.into())?;
1525        let lock = acquire_lock(&canonical_path)?;
1526        let open_result = Self::open_locked(
1527            canonical_path.clone(),
1528            migrations,
1529            &embedder_identity,
1530            emit_migration_event,
1531        );
1532
1533        match open_result {
1534            Ok((connection, readers, mut report, reader_lookaside_rcs)) => {
1535                // EU-5b — splice the loader's measurements + structured
1536                // events into the report. The loader path is the only
1537                // surface that produces these today; caller-supplied
1538                // embedders and EmbedderChoice::None leave them as the
1539                // open_locked defaults (None / empty).
1540                if let Some(info) = loader_info {
1541                    if info.download_ms.is_some() {
1542                        report.embedder_download_ms = info.download_ms;
1543                    }
1544                    if !info.events.is_empty() {
1545                        report.embedder_events = info.events;
1546                    }
1547                }
1548                let next_cursor = load_next_cursor(&connection);
1549                let subscribers = Arc::new(lifecycle::SubscriberRegistry::new());
1550                let profiling_enabled = Arc::new(AtomicBool::new(false));
1551                let slow_threshold_ms = Arc::new(AtomicU64::new(DEFAULT_SLOW_THRESHOLD_MS));
1552                let mut profile_contexts: Vec<Box<ProfileContext>> = Vec::new();
1553                let projection_runtime = ProjectionRuntime::new(
1554                    canonical_path.clone(),
1555                    runtime_embedder.clone(),
1556                    embedder_identity.clone(),
1557                    report.embedder_mean_vec_pinned,
1558                );
1559
1560                install_profile_callback(
1561                    &connection,
1562                    &subscribers,
1563                    &profiling_enabled,
1564                    &slow_threshold_ms,
1565                    &mut profile_contexts,
1566                );
1567                for reader in &readers {
1568                    install_profile_callback(
1569                        reader,
1570                        &subscribers,
1571                        &profiling_enabled,
1572                        &slow_threshold_ms,
1573                        &mut profile_contexts,
1574                    );
1575                }
1576
1577                let opened = OpenedEngine {
1578                    engine: Self {
1579                        path: canonical_path.clone(),
1580                        next_cursor: AtomicU64::new(next_cursor),
1581                        closed: AtomicBool::new(false),
1582                        lock: Mutex::new(Some(lock)),
1583                        connection: Mutex::new(Some(connection)),
1584                        reader_pool: ReaderWorkerPool::new(readers),
1585                        counters: lifecycle::Counters::new(),
1586                        subscribers,
1587                        profiling_enabled,
1588                        slow_threshold_ms,
1589                        runtime_embedder,
1590                        runtime_embedder_identity: embedder_identity,
1591                        projection_runtime,
1592                        provenance_row_cap: AtomicU64::new(DEFAULT_PROVENANCE_ROW_CAP),
1593                        profile_contexts: Mutex::new(profile_contexts),
1594                        reader_lookaside_rcs,
1595                        #[cfg(debug_assertions)]
1596                        force_next_commit_failure: AtomicBool::new(false),
1597                    },
1598                    report,
1599                };
1600                if let Some(subscriber) = initial_subscriber {
1601                    opened.engine.subscribers.attach_persistent(subscriber);
1602                }
1603                if database_has_pending_projection_work(&canonical_path).unwrap_or(false) {
1604                    opened.engine.projection_runtime.notify_new_work();
1605                }
1606                Ok(opened)
1607            }
1608            Err(err) => {
1609                if let Some(subscriber) = initial_subscriber {
1610                    emit_open_error_event(&subscriber, &err);
1611                }
1612                drop(lock);
1613                Err(err)
1614            }
1615        }
1616    }
1617
1618    fn open_locked(
1619        path: PathBuf,
1620        migrations: &'static [fathomdb_schema::Migration],
1621        embedder_identity: &EmbedderIdentity,
1622        emit_migration_event: &mut impl FnMut(&MigrationStepReport),
1623    ) -> Result<(Connection, Vec<Connection>, OpenReport, Vec<i32>), EngineOpenError> {
1624        init_perf_experiments_runtime();
1625        register_sqlite_vec_extension();
1626        let mut connection = Connection::open(&path)
1627            .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))?;
1628        // Order pinned by `dev/design/errors.md` § OpenStage matrix: each
1629        // step routes its own SQLite-level error to a distinct
1630        // `CorruptionKind` (Header → WalReplay → Schema → EmbedderIdentity).
1631        // The schema and WAL probes both happen BEFORE `pragma WAL`
1632        // because that pragma also reads page 1 — letting it run first
1633        // would reclassify schema-side corruption as a WAL replay
1634        // failure, breaking the AC-035b stable-code contract.
1635        probe_database_header(&connection)?;
1636        probe_open_integrity(&connection)?;
1637        probe_wal_sidecar(&path)?;
1638        // 0.7.0 perf-experiments: apply writer-side experiment PRAGMAs
1639        // (page_size, etc.) BEFORE journal_mode + migrations. page_size
1640        // is silently ignored once any table exists; this is the only
1641        // legal window to set it on a fresh DB. Gated on
1642        // FATHOMDB_PERF_EXPERIMENTS=1; no-op in production.
1643        apply_perf_experiment_writer_pragmas(&connection);
1644        connection
1645            .pragma_update(None, "journal_mode", "WAL")
1646            .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
1647
1648        reject_legacy_shape(&connection)?;
1649        let migration = migrate_with_event_sink(&connection, migrations, emit_migration_event)
1650            .map_err(map_migration_error)?;
1651        let mut embedder_mean_vec_pinned = check_embedder_profile(&connection, embedder_identity)?;
1652        ensure_vector_partition(&mut connection, embedder_identity.dimension).map_err(|_| {
1653            EngineOpenError::Io { message: "could not initialize vector partition".to_string() }
1654        })?;
1655
1656        // EU-5f — recovery pin (`dev/design/embedder.md` §0.3, Hazard 4). If
1657        // the identity is MC-required, no mean is pinned, yet the workspace
1658        // already holds >= MEAN_VEC_PIN_THRESHOLD vector rows (e.g. a crash
1659        // between the threshold-crossing write and its pin commit), derive
1660        // the mean from the existing un-centered rows and pin+re-quantize
1661        // now, single-threaded, before the projection workers spawn. The
1662        // NULL guard makes this idempotent on subsequent opens.
1663        if identity_requires_mean_centering(embedder_identity) && !embedder_mean_vec_pinned {
1664            let row_count: u64 = connection
1665                .query_row("SELECT COUNT(*) FROM vector_default", [], |row| row.get(0))
1666                .unwrap_or(0);
1667            if row_count >= MEAN_VEC_PIN_THRESHOLD {
1668                recover_mean_vec_pin(&mut connection, embedder_identity).map_err(|_| {
1669                    EngineOpenError::Io {
1670                        message: "could not recover mean-centering pin".to_string(),
1671                    }
1672                })?;
1673                embedder_mean_vec_pinned = true;
1674            }
1675        }
1676
1677        let warmup_started = Instant::now();
1678        // Static identity capability — see `dev/design/embedder.md`
1679        // §0.6. Today only the bge-small identity reports `true`; the
1680        // noop scaffolding identity is `false`. EU-5b's identity flip
1681        // makes the Default path return `true` here automatically.
1682        let embedder_mean_centering_required = embedder_identity.name == BGE_SMALL_EMBEDDER_NAME;
1683        // EU-5a2 — populated from `_fathomdb_embedder_profiles.mean_vec`
1684        // by `check_embedder_profile` above (was hard-coded `false` in
1685        // EU-5a1). Dimension invariant (§0.2) enforced by that check.
1686        let report = OpenReport {
1687            schema_version_before: migration.schema_version_before,
1688            schema_version_after: migration.schema_version_after,
1689            migration_steps: migration.migration_steps,
1690            embedder_warmup_ms: u64::try_from(warmup_started.elapsed().as_millis())
1691                .unwrap_or(u64::MAX),
1692            query_backend: "fathomdb-query + sqlite-vec",
1693            default_embedder: embedder_identity.clone(),
1694            // TODO(EU-5b): surface `LoadedWeights.download_ms` from the
1695            // loader once the Default path materializes through it.
1696            embedder_download_ms: None,
1697            // TODO(EU-5b): surface `LoadedWeights.events` from the loader.
1698            embedder_events: Vec::new(),
1699            embedder_mean_centering_required,
1700            embedder_mean_vec_pinned,
1701        };
1702
1703        let mut readers = Vec::with_capacity(READER_POOL_SIZE);
1704        let mut lookaside_rcs: Vec<i32> = Vec::with_capacity(READER_POOL_SIZE);
1705        for _ in 0..READER_POOL_SIZE {
1706            let reader = Connection::open(&path)
1707                .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))?;
1708            // Pack 6.G G.1: configure per-connection lookaside BEFORE
1709            // any PRAGMA / prepare runs on this reader. Reordering this
1710            // after the journal-mode / query_only PRAGMAs would let
1711            // SQLite silently ignore the lookaside setting.
1712            let rc: i32 = configure_reader_lookaside(&reader);
1713            debug_assert_eq!(
1714                rc,
1715                rusqlite::ffi::SQLITE_OK,
1716                "sqlite3_db_config(LOOKASIDE) must return SQLITE_OK on a freshly opened reader",
1717            );
1718            lookaside_rcs.push(rc);
1719            reader
1720                .pragma_update(None, "journal_mode", "WAL")
1721                .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
1722            reader
1723                .pragma_update(None, "query_only", "ON")
1724                .map_err(|err| map_open_sqlite_error(err, OpenStage::SchemaProbe))?;
1725            apply_perf_experiment_reader_pragmas(&reader);
1726            readers.push(reader);
1727        }
1728
1729        Ok((connection, readers, report, lookaside_rcs))
1730    }
1731
1732    #[must_use]
1733    pub fn path(&self) -> &Path {
1734        &self.path
1735    }
1736
1737    pub fn write(&self, batch: &[PreparedWrite]) -> Result<WriteReceipt, EngineError> {
1738        let category = if batch_is_admin(batch) {
1739            lifecycle::EventCategory::Admin
1740        } else {
1741            lifecycle::EventCategory::Writer
1742        };
1743        self.emit_event(lifecycle::Phase::Started, category, None);
1744        let started = Instant::now();
1745        let outcome = self.write_inner(batch);
1746        self.detect_slow(started, category);
1747        match outcome {
1748            Ok(receipt) => {
1749                let rows = u64::try_from(batch.len()).unwrap_or(u64::MAX);
1750                if batch_is_admin(batch) {
1751                    self.counters.record_admin();
1752                } else {
1753                    self.counters.record_write(rows);
1754                }
1755                self.emit_event(lifecycle::Phase::Finished, category, None);
1756                Ok(receipt)
1757            }
1758            Err(err) => {
1759                let code = err.stable_code();
1760                self.counters.record_error(code);
1761                // AC-003d: capture-ordinal < raise-ordinal — Failed and Error
1762                // events both fire before the EngineError returns to the caller.
1763                self.emit_event(lifecycle::Phase::Failed, category, Some(code));
1764                self.emit_event(
1765                    lifecycle::Phase::Failed,
1766                    lifecycle::EventCategory::Error,
1767                    Some(code),
1768                );
1769                Err(err)
1770            }
1771        }
1772    }
1773
1774    fn write_inner(&self, batch: &[PreparedWrite]) -> Result<WriteReceipt, EngineError> {
1775        self.ensure_open()?;
1776
1777        if batch.is_empty() {
1778            return Err(EngineError::WriteValidation);
1779        }
1780
1781        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
1782        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
1783        let plans = validate_batch(connection, batch)?;
1784        let projection_jobs = collect_projection_jobs(connection, batch)?;
1785        #[cfg(debug_assertions)]
1786        if self.force_next_commit_failure.swap(false, Ordering::SeqCst) {
1787            return Err(EngineError::Storage);
1788        }
1789        // One cursor per row. `base_cursor` is the last committed cursor;
1790        // row i in the batch gets cursor `base_cursor + i + 1`, and the
1791        // batch's final cursor (returned in WriteReceipt and stored as
1792        // the new `next_cursor`) is `base_cursor + batch.len()`. Sharing
1793        // one cursor across the batch previously collapsed every vec0
1794        // INSERT onto the same rowid via `INSERT OR IGNORE` — see
1795        // `dev/notes/0.7.0-engine-batch-vec0-collapse.md`.
1796        let base_cursor = self.next_cursor.load(Ordering::SeqCst);
1797        let increment = u64::try_from(batch.len()).unwrap_or(u64::MAX);
1798        let last_cursor = base_cursor.saturating_add(increment);
1799        let pending_projection = !projection_jobs.is_empty();
1800
1801        if let Err(err) = commit_batch(
1802            connection,
1803            batch,
1804            &plans,
1805            base_cursor,
1806            self.provenance_row_cap.load(Ordering::Relaxed),
1807        ) {
1808            self.emit_sqlite_internal_error(&err);
1809            return Err(EngineError::Storage);
1810        }
1811        self.next_cursor.store(last_cursor, Ordering::SeqCst);
1812        if pending_projection {
1813            self.projection_runtime.notify_new_work();
1814        }
1815
1816        Ok(WriteReceipt { cursor: last_cursor })
1817    }
1818
1819    pub fn search(&self, query: &str) -> Result<SearchResult, EngineError> {
1820        self.emit_event(lifecycle::Phase::Started, lifecycle::EventCategory::Search, None);
1821        let started = Instant::now();
1822        let outcome = self.search_inner(query);
1823        self.detect_slow(started, lifecycle::EventCategory::Search);
1824        match outcome {
1825            Ok(result) => {
1826                self.counters.record_query();
1827                self.emit_event(lifecycle::Phase::Finished, lifecycle::EventCategory::Search, None);
1828                Ok(result)
1829            }
1830            Err(err) => {
1831                let code = err.stable_code();
1832                self.counters.record_error(code);
1833                self.emit_event(
1834                    lifecycle::Phase::Failed,
1835                    lifecycle::EventCategory::Search,
1836                    Some(code),
1837                );
1838                self.emit_event(
1839                    lifecycle::Phase::Failed,
1840                    lifecycle::EventCategory::Error,
1841                    Some(code),
1842                );
1843                Err(err)
1844            }
1845        }
1846    }
1847
1848    fn detect_slow(&self, started: Instant, category: lifecycle::EventCategory) {
1849        let elapsed = started.elapsed();
1850        let threshold = self.slow_threshold_ms.load(Ordering::Relaxed);
1851        let threshold_duration = std::time::Duration::from_millis(threshold);
1852        if elapsed > threshold_duration {
1853            // `dev/design/lifecycle.md` § Slow and heartbeat policy: a slow
1854            // operation produces TWO correlated facts. The
1855            // statement-level slow-statement signal is dispatched by the
1856            // sqlite3_profile callback (`profile_callback_trampoline`).
1857            // This site emits the lifecycle `Phase::Slow` event for the
1858            // outer operation envelope (AC-008).
1859            self.emit_event(lifecycle::Phase::Slow, category, None);
1860        }
1861    }
1862
1863    fn emit_event(
1864        &self,
1865        phase: lifecycle::Phase,
1866        category: lifecycle::EventCategory,
1867        code: Option<&'static str>,
1868    ) {
1869        let event =
1870            lifecycle::Event { phase, source: lifecycle::EventSource::Engine, category, code };
1871        self.subscribers.dispatch(&event);
1872    }
1873
1874    /// Emit a `(SqliteInternal, Error, code: <SQLITE_*>)` lifecycle
1875    /// event for a rusqlite error. Per `dev/design/lifecycle.md`
1876    /// § Diagnostic source and category, SQLite-originated diagnostics
1877    /// route through the same host subscriber as engine-originated
1878    /// events with `source` preserved. AC-021 dispatches on
1879    /// `code == "SQLITE_SCHEMA"`.
1880    fn emit_sqlite_internal_error(&self, err: &rusqlite::Error) {
1881        if let Some(code) = sqlite_extended_code_name(err) {
1882            let event = lifecycle::Event {
1883                phase: lifecycle::Phase::Failed,
1884                source: lifecycle::EventSource::SqliteInternal,
1885                category: lifecycle::EventCategory::Error,
1886                code: Some(code),
1887            };
1888            self.subscribers.dispatch(&event);
1889        }
1890    }
1891
1892    fn search_inner(&self, query: &str) -> Result<SearchResult, EngineError> {
1893        self.ensure_open()?;
1894        if query.trim().is_empty() {
1895            return Err(EngineError::WriteValidation);
1896        }
1897
1898        let compiled = compile_text_query(query);
1899        // REQ-013 / AC-059b / REQ-055: the cursor returned with a search
1900        // MUST be derived from the same WAL snapshot the data was read
1901        // from. Loading `next_cursor` from the writer-side atomic before
1902        // the reader transaction acquires its snapshot races against
1903        // concurrent writers — see `dev/design/engine.md` § Cursor
1904        // contract. Run cursor probe + body query inside one read tx
1905        // (BEGIN DEFERRED on a `query_only=ON` connection in WAL mode is
1906        // a snapshot-stable read).
1907        // EU-5a2 mean-centering apply path (query side). `query_vector`
1908        // is ALWAYS un-centered (used by the f32 vec_distance_l2 rerank
1909        // in phase 2). `query_vector_bin` is the (possibly centered) f32
1910        // fed to `vec_quantize_binary` in phase 1. The centering decision
1911        // mirrors the write path: identity must be MC-required AND a
1912        // mean_vec must be pinned. NoopEmbedder collapses to
1913        // `query_vector_bin == query_vector` until EU-5b.
1914        let raw_query_vector =
1915            self.runtime_embedder.as_ref().and_then(|embedder| embedder.embed(query).ok());
1916        let query_vector_bin = match raw_query_vector.as_ref() {
1917            Some(vector) if identity_requires_mean_centering(&self.runtime_embedder_identity) => {
1918                let pinned = {
1919                    let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
1920                    let connection = connection.as_ref().ok_or(EngineError::Closing)?;
1921                    read_pinned_mean_vec(connection, self.runtime_embedder_identity.dimension)?
1922                };
1923                match pinned {
1924                    Some(mean) => serde_json::to_string(&subtract_mean(vector, &mean)).ok(),
1925                    None => serde_json::to_string(vector).ok(),
1926                }
1927            }
1928            Some(vector) => serde_json::to_string(vector).ok(),
1929            None => None,
1930        };
1931        let query_vector = raw_query_vector.and_then(|vector| serde_json::to_string(&vector).ok());
1932        // 0.7.2 PR-2bc S1 fix-1 — phase-2 rerank LIMIT. Production default is
1933        // `SEARCH_RERANK_LIMIT` (10); the test seam may RAISE it, clamped to
1934        // the production floor so a test can never shrink search semantics.
1935        let search_limit = self
1936            .projection_runtime
1937            .shared
1938            .search_limit_override
1939            .load(Ordering::SeqCst)
1940            .max(SEARCH_RERANK_LIMIT);
1941        let (response_tx, response_rx) = mpsc::sync_channel::<ReaderResponse>(1);
1942        let request = ReaderRequest::Search {
1943            compiled,
1944            query_vector,
1945            query_vector_bin,
1946            search_limit,
1947            respond: response_tx,
1948        };
1949        if self.reader_pool.dispatch(request).is_err() {
1950            return Err(EngineError::Closing);
1951        }
1952        let search_result = response_rx.recv().map_err(|_| EngineError::Storage)?;
1953        let (cursor, soft_fallback, results) = match search_result {
1954            Ok(result) => result,
1955            Err(err) => {
1956                self.emit_sqlite_internal_error(&err);
1957                return Err(EngineError::Storage);
1958            }
1959        };
1960
1961        Ok(SearchResult { projection_cursor: cursor, soft_fallback, results })
1962    }
1963
1964    pub fn close(&self) -> Result<(), EngineError> {
1965        self.closed.store(true, Ordering::SeqCst);
1966        self.projection_runtime.stop();
1967        // Uninstall profile callbacks before dropping the connections so
1968        // SQLite cannot fire one last callback against a profile context
1969        // whose Box is about to free. Per `dev/design/engine.md` § Close
1970        // path step 6, readers drain before the writer connection so
1971        // SQLite's last-handle checkpointer runs on the writer. Each
1972        // reader worker uninstalls its own callback inside
1973        // `reader_worker_loop` before dropping its connection, then
1974        // exits — `shutdown` joins those threads here.
1975        self.reader_pool.shutdown();
1976        if let Ok(mut connection) = self.connection.lock() {
1977            if let Some(conn) = connection.as_ref() {
1978                uninstall_profile_callback(conn);
1979            }
1980            connection.take();
1981        }
1982        if let Ok(mut contexts) = self.profile_contexts.lock() {
1983            contexts.clear();
1984        }
1985        if let Ok(mut lock) = self.lock.lock() {
1986            lock.take();
1987        }
1988        Ok(())
1989    }
1990
1991    /// Block until in-flight writes drain or `timeout_ms` elapses.
1992    ///
1993    /// Surface owned by `dev/interfaces/rust.md` § Engine-attached
1994    /// instrumentation; semantics are owned by `dev/design/lifecycle.md`.
1995    pub fn drain(&self, timeout_ms: u64) -> Result<(), EngineError> {
1996        self.ensure_open()?;
1997        if self.projection_runtime.wait_for_idle(timeout_ms) {
1998            Ok(())
1999        } else {
2000            Err(EngineError::Scheduler)
2001        }
2002    }
2003
2004    /// Snapshot of engine-internal counters.
2005    ///
2006    /// Field set owned by `dev/design/lifecycle.md`.
2007    #[must_use]
2008    pub fn counters(&self) -> CounterSnapshot {
2009        self.counters.snapshot()
2010    }
2011
2012    /// Toggle response-cycle profiling.
2013    ///
2014    /// Per `dev/design/lifecycle.md` § Per-statement profiling, profiling
2015    /// is an opt-in surface that is independently toggleable on a running
2016    /// engine without restart. AC-005a locks runtime toggleability.
2017    pub fn set_profiling(&self, enabled: bool) -> Result<(), EngineError> {
2018        self.profiling_enabled.store(enabled, Ordering::Relaxed);
2019        Ok(())
2020    }
2021
2022    /// Set the threshold above which an operation is reported as slow.
2023    ///
2024    /// Per `dev/design/lifecycle.md` § Slow and heartbeat policy, the
2025    /// threshold is runtime-configurable; mutating it changes detection
2026    /// behavior on subsequent statements without restart (AC-007b).
2027    pub fn set_slow_threshold_ms(&self, value: u64) -> Result<(), EngineError> {
2028        self.slow_threshold_ms.store(value, Ordering::Relaxed);
2029        Ok(())
2030    }
2031
2032    /// Attach a host subscriber to engine events.
2033    ///
2034    /// Dropping the returned [`Subscription`] detaches the subscriber.
2035    /// Payload shape owned by `dev/design/lifecycle.md` and
2036    /// `dev/design/migrations.md`.
2037    #[must_use]
2038    pub fn subscribe(&self, subscriber: Arc<dyn lifecycle::Subscriber>) -> Subscription {
2039        self.subscribers.attach(subscriber)
2040    }
2041
2042    #[cfg(debug_assertions)]
2043    #[doc(hidden)]
2044    pub fn reader_worker_count_for_test(&self) -> usize {
2045        self.reader_pool.worker_count()
2046    }
2047
2048    #[cfg(debug_assertions)]
2049    #[doc(hidden)]
2050    pub fn live_reader_worker_count_for_test(&self) -> usize {
2051        self.reader_pool.live_count()
2052    }
2053
2054    /// Pack 6.G G.1 — return the `sqlite3_db_config(LOOKASIDE)` rc
2055    /// captured for each reader worker at open time, in worker index
2056    /// order. SQLITE_OK (= 0) means the lookaside was configured
2057    /// before any allocation happened on the connection.
2058    #[cfg(debug_assertions)]
2059    #[doc(hidden)]
2060    pub fn reader_lookaside_config_rcs_for_test(&self) -> Vec<i32> {
2061        self.reader_lookaside_rcs.clone()
2062    }
2063
2064    /// Pack 6.G G.1 — query each reader worker's
2065    /// `SQLITE_DBSTATUS_LOOKASIDE_USED` counter. A value > 0 means at
2066    /// least one allocation was satisfied from the per-connection
2067    /// lookaside arena (proof the configuration was honored before the
2068    /// first prepare).
2069    #[cfg(debug_assertions)]
2070    #[doc(hidden)]
2071    pub fn reader_lookaside_used_per_worker_for_test(&self) -> Vec<i32> {
2072        self.reader_pool.lookaside_used_per_worker()
2073    }
2074
2075    /// Pack 6.G G.3.5 — broadcast a debug-only `CacheStatus` request to
2076    /// every reader worker and collect per-worker
2077    /// `SQLITE_DBSTATUS_CACHE_HIT` / `_CACHE_MISS` / `_CACHE_USED`
2078    /// values. Counters are monotonic (reset flag = 0); callers compute
2079    /// pre/post deltas explicitly.
2080    #[cfg(debug_assertions)]
2081    #[doc(hidden)]
2082    pub fn cache_status_per_worker_for_test(&self, label: &str) -> Vec<CacheStatusReply> {
2083        self.reader_pool.cache_status_per_worker(label)
2084    }
2085
2086    #[cfg(debug_assertions)]
2087    #[doc(hidden)]
2088    pub fn force_next_commit_failure_for_test(&self) {
2089        self.force_next_commit_failure.store(true, Ordering::SeqCst);
2090    }
2091
2092    /// Execute an arbitrary SQL statement on the writer connection through
2093    /// the same wall-clock + slow-detect path as `write` / `search`.
2094    ///
2095    /// Test-only helper for the deterministic-slow-cte fixture used by
2096    /// AC-007a / AC-007b. Not part of the public 0.6.0 surface; gated on
2097    /// `debug_assertions` so release builds do not expose it.
2098    #[cfg(debug_assertions)]
2099    #[doc(hidden)]
2100    pub fn execute_for_test(&self, sql: &str) -> Result<(), EngineError> {
2101        self.ensure_open()?;
2102        let started = Instant::now();
2103        {
2104            let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2105            let connection = connection.as_mut().ok_or(EngineError::Closing)?;
2106            connection.execute_batch(sql).map_err(|_| EngineError::Storage)?;
2107        }
2108        self.detect_slow(started, lifecycle::EventCategory::Search);
2109        Ok(())
2110    }
2111
2112    /// One-thread-poison robustness fixture (AC-009).
2113    ///
2114    /// Spawns four reader threads + one writer thread that all make
2115    /// forward progress (single canonical write + repeated searches),
2116    /// plus one designated poison thread that runs an empty-batch write
2117    /// — a deterministic `EngineError::WriteValidation`. The captured
2118    /// poison failure is dispatched as a `StressFailureContext` whose
2119    /// `last_error_chain` is `[EngineError::stable_code(),
2120    /// engine_error.to_string()]` per the lifecycle § Stress-failure
2121    /// context payload contract.
2122    #[doc(hidden)]
2123    #[cfg(debug_assertions)]
2124    pub fn run_one_thread_poison_for_test(&self) -> Result<(), EngineError> {
2125        self.ensure_open()?;
2126
2127        // Forward-progress writer seeds a row so readers + the poison
2128        // thread share a non-trivial canonical state.
2129        self.write(&[PreparedWrite::Node {
2130            kind: "doc".to_string(),
2131            body: "poison-fixture-seed".to_string(),
2132            source_id: None,
2133        }])?;
2134
2135        let poison_outcome: Mutex<Option<EngineError>> = Mutex::new(None);
2136        let poison_thread_id: AtomicU64 = AtomicU64::new(0);
2137
2138        thread::scope(|scope| {
2139            // N=4 reader threads make forward progress.
2140            for _ in 0..4 {
2141                scope.spawn(|| {
2142                    for _ in 0..4 {
2143                        let _ = self.search("poison-fixture-seed");
2144                    }
2145                });
2146            }
2147            // One forward-progress writer thread.
2148            scope.spawn(|| {
2149                let _ = self.write(&[PreparedWrite::Node {
2150                    kind: "doc".to_string(),
2151                    body: "writer-progress".to_string(),
2152                    source_id: None,
2153                }]);
2154            });
2155            // One poison thread — empty batch is a deterministic
2156            // WriteValidation failure.
2157            scope.spawn(|| {
2158                // Use a non-zero, deterministic group id so subscribers
2159                // see a stable identifier across runs of the fixture.
2160                poison_thread_id.store(1, Ordering::SeqCst);
2161                if let Err(err) = self.write(&[]) {
2162                    *poison_outcome.lock().expect("poison_outcome lock") = Some(err);
2163                }
2164            });
2165        });
2166
2167        let err = poison_outcome
2168            .into_inner()
2169            .expect("poison_outcome lock")
2170            .expect("poison thread must produce a deterministic error");
2171
2172        let projection_state = match self.projection_status_for_test("doc") {
2173            Ok(lifecycle::ProjectionStatus::Pending) => "Pending",
2174            Ok(lifecycle::ProjectionStatus::Failed) => "Failed",
2175            Ok(lifecycle::ProjectionStatus::UpToDate) => "UpToDate",
2176            // Default to UpToDate when projection status is unobservable
2177            // (e.g. embedder not configured for the seed kind). The
2178            // value is still one of the documented enum stringifications
2179            // per AC-010.
2180            Err(_) => "UpToDate",
2181        };
2182
2183        let context = lifecycle::StressFailureContext {
2184            thread_group_id: poison_thread_id.load(Ordering::SeqCst),
2185            op_kind: "write".to_string(),
2186            last_error_chain: vec![err.stable_code().to_string(), err.to_string()],
2187            projection_state: projection_state.to_string(),
2188        };
2189        self.subscribers.dispatch_stress_failure(&context);
2190        Ok(())
2191    }
2192
2193    #[doc(hidden)]
2194    pub fn set_projection_scheduler_frozen_for_test(&self, frozen: bool) {
2195        self.projection_runtime.set_frozen(frozen);
2196    }
2197
2198    #[doc(hidden)]
2199    pub fn set_projection_retry_delays_for_test(&self, delays_ms: &[u64]) {
2200        self.projection_runtime.set_retry_delays_for_test(delays_ms);
2201    }
2202
2203    /// PR-9 — lower the ADR-0.6.0 Invariant 5 per-`embed()` watchdog deadline
2204    /// for tests (production default is `DEFAULT_EMBED_TIMEOUT_MS` = 30s).
2205    #[doc(hidden)]
2206    pub fn set_embed_timeout_ms_for_test(&self, timeout_ms: u64) {
2207        self.projection_runtime.set_embed_timeout_ms_for_test(timeout_ms);
2208    }
2209
2210    /// PR-9 — lower the embed circuit-breaker threshold for tests (production
2211    /// default `DEFAULT_EMBED_CIRCUIT_THRESHOLD`); 0 disables the breaker.
2212    #[doc(hidden)]
2213    pub fn set_embed_circuit_threshold_for_test(&self, threshold: u64) {
2214        self.projection_runtime.set_embed_circuit_threshold_for_test(threshold);
2215    }
2216
2217    /// PR-9 — whether the embed circuit breaker has latched open.
2218    #[doc(hidden)]
2219    pub fn embed_circuit_open_for_test(&self) -> bool {
2220        self.projection_runtime.embed_circuit_open_for_test()
2221    }
2222
2223    #[doc(hidden)]
2224    pub fn projection_status_for_test(
2225        &self,
2226        kind: &str,
2227    ) -> Result<lifecycle::ProjectionStatus, EngineError> {
2228        self.ensure_open()?;
2229        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2230        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2231        projection_status(connection, kind)
2232    }
2233
2234    #[doc(hidden)]
2235    pub fn has_vector_for_cursor_for_test(&self, cursor: u64) -> Result<bool, EngineError> {
2236        self.ensure_open()?;
2237        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2238        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2239        terminal_state_for_cursor(connection, cursor)
2240            .map(|state| matches!(state.as_deref(), Some("up_to_date")))
2241            .map_err(|_| EngineError::Storage)
2242    }
2243
2244    #[doc(hidden)]
2245    pub fn projection_failure_count_for_test(&self, cursor: u64) -> Result<u64, EngineError> {
2246        self.ensure_open()?;
2247        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2248        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2249        connection
2250            .query_row(
2251                "SELECT COUNT(*) FROM operational_mutations
2252                 WHERE collection_name = 'projection_failures'
2253                   AND record_key = ?1",
2254                [cursor.to_string()],
2255                |row| row.get::<_, u64>(0),
2256            )
2257            .map_err(|_| EngineError::Storage)
2258    }
2259
2260    #[doc(hidden)]
2261    pub fn set_provenance_row_cap_for_test(&self, cap: Option<u64>) {
2262        self.provenance_row_cap.store(cap.unwrap_or(0), Ordering::Relaxed);
2263    }
2264
2265    #[doc(hidden)]
2266    pub fn provenance_row_count_for_test(&self) -> Result<u64, EngineError> {
2267        self.ensure_open()?;
2268        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2269        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2270        connection
2271            .query_row("SELECT COUNT(*) FROM operational_mutations", [], |row| row.get::<_, u64>(0))
2272            .map_err(|_| EngineError::Storage)
2273    }
2274
2275    #[doc(hidden)]
2276    pub fn oldest_provenance_record_key_for_test(
2277        &self,
2278        collection: &str,
2279    ) -> Result<Option<String>, EngineError> {
2280        self.ensure_open()?;
2281        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2282        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2283        connection
2284            .query_row(
2285                "SELECT record_key FROM operational_mutations
2286                 WHERE collection_name = ?1
2287                 ORDER BY id
2288                 LIMIT 1",
2289                [collection],
2290                |row| row.get::<_, String>(0),
2291            )
2292            .map(Some)
2293            .or_else(|err| match err {
2294                rusqlite::Error::QueryReturnedNoRows => Ok(None),
2295                _ => Err(EngineError::Storage),
2296            })
2297    }
2298
2299    #[doc(hidden)]
2300    pub fn configure_vector_kind_for_test(&self, kind: &str) -> Result<(), EngineError> {
2301        self.ensure_open()?;
2302        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2303        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
2304        connection
2305            .execute(
2306                "INSERT OR REPLACE INTO _fathomdb_vector_kinds(kind, profile, created_at)
2307                 VALUES(?1, ?2, 0)",
2308                params![kind, DEFAULT_VECTOR_PROFILE],
2309            )
2310            .map_err(|_| EngineError::Storage)?;
2311        Ok(())
2312    }
2313
2314    #[doc(hidden)]
2315    pub fn write_vector_for_test(
2316        &self,
2317        kind: &str,
2318        text: &str,
2319    ) -> Result<WriteReceipt, EngineError> {
2320        self.ensure_open()?;
2321        let embedder =
2322            self.runtime_embedder.as_ref().cloned().ok_or(EngineError::EmbedderNotConfigured)?;
2323
2324        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2325        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
2326        if !kind_is_vector_indexed(connection, kind)? {
2327            return Err(EngineError::KindNotVectorIndexed);
2328        }
2329
2330        let expected = default_profile_dimension(connection)?;
2331        ensure_vector_partition(connection, expected).map_err(|_| EngineError::Storage)?;
2332        let vector = embedder.embed(text).map_err(map_runtime_embedder_error)?;
2333        let actual = u32::try_from(vector.len()).unwrap_or(u32::MAX);
2334        if actual != expected {
2335            return Err(EngineError::EmbedderDimensionMismatch { expected, actual });
2336        }
2337
2338        let cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
2339        // EU-5a2 mean-centering apply path (write side). f32 BLOB stored
2340        // is ALWAYS un-centered; the sign-quant input is the centered
2341        // vector iff the identity is MC-required AND a `mean_vec` is
2342        // pinned. NoopEmbedder identity (the only EU-5a2 live one) is
2343        // NOT MC-required, so this is a no-op until EU-5b's flip.
2344        let blob = encode_vector_blob(&vector);
2345        let bin_blob = if identity_requires_mean_centering(&self.runtime_embedder_identity) {
2346            match read_pinned_mean_vec(connection, self.runtime_embedder_identity.dimension)? {
2347                Some(mean) => encode_vector_blob(&subtract_mean(&vector, &mean)),
2348                None => blob.clone(),
2349            }
2350        } else {
2351            blob.clone()
2352        };
2353        let source_type = resolve_source_type(kind)?;
2354        let now_unix =
2355            SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
2356
2357        // EU-5b — feed the streaming mean accumulator (if live) and detect
2358        // a threshold-crossing pin. The mean materialization, pre-pin
2359        // re-quantize, and `MeanVecPinned` event emission all happen in
2360        // the SAME SQLite transaction as the row INSERT.
2361        let pin_event = {
2362            let runtime = &self.projection_runtime.shared;
2363            let mut accumulator =
2364                runtime.mean_accumulator.lock().map_err(|_| EngineError::Storage)?;
2365            if let Some(acc) = accumulator.as_mut() {
2366                acc.add(&vector);
2367                if acc.count() >= MEAN_VEC_PIN_THRESHOLD {
2368                    let mean = acc.materialize();
2369                    *accumulator = None;
2370                    Some(mean)
2371                } else {
2372                    None
2373                }
2374            } else {
2375                None
2376            }
2377        };
2378
2379        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
2380        tx.execute(
2381            "INSERT INTO _fathomdb_vector_rows(rowid, kind, write_cursor) VALUES(?1, ?2, ?3)",
2382            params![cursor, kind, cursor],
2383        )
2384        .map_err(|_| EngineError::Storage)?;
2385        tx.execute(
2386            "INSERT INTO vector_default(
2387                rowid, embedding, embedding_bin, source_type, kind, created_at
2388             ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6)",
2389            params![cursor, blob, bin_blob, source_type, kind, now_unix],
2390        )
2391        .map_err(|_| EngineError::Storage)?;
2392
2393        let mut emitted_event: Option<EmbedderEvent> = None;
2394        if let Some(mean_vec) = pin_event {
2395            let mean_bytes = encode_vector_blob(&mean_vec);
2396            tx.execute(
2397                "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
2398                params![mean_bytes],
2399            )
2400            .map_err(|_| EngineError::Storage)?;
2401            // Read all pre-pin (rowid, embedding) and re-quantize within
2402            // the same tx. The just-inserted row above is also covered.
2403            let rows: Vec<(i64, Vec<u8>)> = {
2404                let mut statement = tx
2405                    .prepare("SELECT rowid, embedding FROM vector_default ORDER BY rowid")
2406                    .map_err(|_| EngineError::Storage)?;
2407                let mapped = statement
2408                    .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)))
2409                    .map_err(|_| EngineError::Storage)?;
2410                let mut out = Vec::new();
2411                for r in mapped {
2412                    out.push(r.map_err(|_| EngineError::Storage)?);
2413                }
2414                out
2415            };
2416            let (doc_count, _) = run_pin_and_requantize_pass(&tx, &rows, &mean_vec)?;
2417            emitted_event = Some(EmbedderEvent::MeanVecPinned {
2418                dim: u32::try_from(mean_vec.len()).unwrap_or(u32::MAX),
2419                doc_count,
2420            });
2421        }
2422
2423        tx.commit().map_err(|_| EngineError::Storage)?;
2424
2425        if let Some(ev) = emitted_event {
2426            if let Ok(mut events) = self.projection_runtime.shared.pending_events.lock() {
2427                events.push(ev);
2428            }
2429        }
2430
2431        self.next_cursor.store(cursor, Ordering::SeqCst);
2432        Ok(WriteReceipt { cursor })
2433    }
2434
2435    /// EU-5b test seam — drain MeanVecPinned events queued by the
2436    /// projection-commit pin transaction since the last drain. Production
2437    /// callers consume these via `OpenReport.embedder_events`; this seam
2438    /// exists so the EU-5b RED test can observe the live emission.
2439    #[doc(hidden)]
2440    pub fn drain_mean_centering_events_for_test(&self) -> Result<Vec<EmbedderEvent>, EngineError> {
2441        self.ensure_open()?;
2442        let mut events = self
2443            .projection_runtime
2444            .shared
2445            .pending_events
2446            .lock()
2447            .map_err(|_| EngineError::Storage)?;
2448        let out = std::mem::take(&mut *events);
2449        Ok(out)
2450    }
2451
2452    /// 0.7.2 PR-2b — NON-test observation seam. Drains and returns every
2453    /// `EmbedderEvent` queued since the last drain (mean pin, manual mean
2454    /// recompute). Production callers use
2455    /// this to observe the synchronous recompute work; events are queued
2456    /// only AFTER the recompute transaction is durable, so a rolled-back
2457    /// recompute never surfaces. Mirrors the at-open
2458    /// `OpenReport.embedder_events` channel for the steady-state path.
2459    pub fn drain_embedder_events(&self) -> Result<Vec<EmbedderEvent>, EngineError> {
2460        self.ensure_open()?;
2461        let mut events = self
2462            .projection_runtime
2463            .shared
2464            .pending_events
2465            .lock()
2466            .map_err(|_| EngineError::Storage)?;
2467        Ok(std::mem::take(&mut *events))
2468    }
2469
2470    /// 0.7.2 PR-2b — explicit `doctor recompute-mean` path. Re-derives the
2471    /// pinned corpus mean from the current `vector_default` rows and
2472    /// re-quantizes every row, SYNCHRONOUSLY in one transaction. ALWAYS
2473    /// allowed at any corpus size — this is the ONLY mean-refresh path as of
2474    /// 0.7.2 (the automatic in-ingest drift detector was carved out / deferred
2475    /// to 0.8.x; see `dev/design/embedder.md` §0.3).
2476    ///
2477    /// Serializes against the projection workers via `commit_gate` so the
2478    /// re-quantize sees a totally-ordered history, exactly like the at-pin
2479    /// commit. Publishes a `MeanVecRecomputed { trigger: Manual }` event
2480    /// only after the transaction is durable. No-op-safe on a non-MC
2481    /// identity (returns `EmbedderNotConfigured` rather than corrupting an
2482    /// un-centered workspace).
2483    pub fn recompute_mean(&self) -> Result<MeanRecomputeReport, EngineError> {
2484        self.ensure_open()?;
2485        let identity = self.runtime_embedder_identity.clone();
2486        if !identity_requires_mean_centering(&identity) {
2487            return Err(EngineError::EmbedderNotConfigured);
2488        }
2489        let report = {
2490            // Hold the commit gate for the whole recompute so no projection
2491            // worker commit interleaves with the re-quantize.
2492            let _gate = self
2493                .projection_runtime
2494                .shared
2495                .commit_gate
2496                .lock()
2497                .unwrap_or_else(|p| p.into_inner());
2498            let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2499            let connection = connection.as_mut().ok_or(EngineError::Closing)?;
2500            let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
2501            #[cfg(debug_assertions)]
2502            let fail = self
2503                .projection_runtime
2504                .shared
2505                .force_recompute_failure
2506                .swap(false, Ordering::SeqCst);
2507            #[cfg(not(debug_assertions))]
2508            let fail = false;
2509            let report = recompute_mean_in_tx_inner(&tx, &identity, fail)?;
2510            tx.commit().map_err(|_| EngineError::Storage)?;
2511            report
2512        };
2513        // Post-durable-commit publish.
2514        if let Ok(mut events) = self.projection_runtime.shared.pending_events.lock() {
2515            events.push(EmbedderEvent::MeanVecRecomputed {
2516                dim: report.dim,
2517                doc_count: report.doc_count_requantized,
2518                trigger: MeanRecomputeTrigger::Manual,
2519            });
2520        }
2521        Ok(report)
2522    }
2523
2524    /// 0.7.2 PR-2bc S1 fix-1 test seam — RAISE the phase-2 rerank `LIMIT`
2525    /// above the production `SEARCH_RERANK_LIMIT` (10) so the recall harness
2526    /// can pull top-(10+slack) and exclude the self-retrieving query-source
2527    /// doc before truncating to 10. The search path clamps the stored value
2528    /// to the production floor, so a test can never shrink search fanout
2529    /// below production semantics. Production reads the same atomic and never
2530    /// consults any env var.
2531    #[doc(hidden)]
2532    pub fn set_search_limit_for_test(&self, limit: usize) {
2533        self.projection_runtime.shared.search_limit_override.store(limit, Ordering::SeqCst);
2534    }
2535
2536    /// 0.7.2 PR-2b test seam — arm a one-shot fault inside the NEXT
2537    /// `recompute_mean` so it errors after the `mean_vec` UPDATE but before
2538    /// the re-quantize completes. Proves the recompute tx rolls back whole.
2539    #[doc(hidden)]
2540    #[cfg(debug_assertions)]
2541    pub fn force_next_recompute_failure_for_test(&self) {
2542        self.projection_runtime.shared.force_recompute_failure.store(true, Ordering::SeqCst);
2543    }
2544
2545    #[doc(hidden)]
2546    pub fn vector_row_count_for_test(&self) -> Result<u64, EngineError> {
2547        self.ensure_open()?;
2548        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2549        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2550        connection
2551            .query_row("SELECT COUNT(*) FROM vector_default", [], |row| row.get::<_, u64>(0))
2552            .map_err(|_| EngineError::Storage)
2553    }
2554
2555    #[doc(hidden)]
2556    pub fn read_vector_blob_for_test(&self, rowid: i64) -> Result<Vec<u8>, EngineError> {
2557        self.ensure_open()?;
2558        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2559        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2560        connection
2561            .query_row("SELECT embedding FROM vector_default WHERE rowid = ?1", [rowid], |row| {
2562                row.get::<_, Vec<u8>>(0)
2563            })
2564            .map_err(|_| EngineError::Storage)
2565    }
2566
2567    #[doc(hidden)]
2568    pub fn default_embedder_profile_for_test(&self) -> Result<EmbedderIdentity, EngineError> {
2569        self.ensure_open()?;
2570        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2571        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2572        load_default_profile(connection).map_err(|_| EngineError::Storage)
2573    }
2574
2575    /// Doctor read-only integrity report. Three-section output per
2576    /// AC-043a/b. `opts.full` adds `PRAGMA integrity_check`. `quick` and
2577    /// `round_trip` are accepted but treated as default for 0.6.0.
2578    pub fn check_integrity(
2579        &self,
2580        opts: CheckIntegrityOpts,
2581    ) -> Result<IntegrityReport, EngineError> {
2582        self.ensure_open()?;
2583        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2584        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2585        Ok(IntegrityReport {
2586            physical: physical_section(connection, opts.full),
2587            logical: logical_section(connection),
2588            semantic: semantic_section(connection),
2589        })
2590    }
2591
2592    /// Doctor bit-preserving export. Runs `VACUUM INTO` to produce a
2593    /// self-contained SQLite file at `out`, computes SHA-256 of the
2594    /// resulting bytes, and writes a JSON manifest at `manifest`. Per
2595    /// AC-039a/b.
2596    pub fn safe_export(
2597        &self,
2598        out: &Path,
2599        manifest: &Path,
2600    ) -> Result<SafeExportArtifact, EngineError> {
2601        self.ensure_open()?;
2602        {
2603            let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2604            let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2605            let target = out.to_string_lossy().to_string();
2606            connection
2607                .execute("VACUUM INTO ?1", params![target])
2608                .map_err(|_| EngineError::Storage)?;
2609        }
2610        let bytes = std::fs::read(out).map_err(|_| EngineError::Storage)?;
2611        let digest = sha2::Sha256::digest(&bytes);
2612        let sha256_hex = hex_encode(digest.as_slice());
2613        let export_abs = out.canonicalize().unwrap_or_else(|_| out.to_path_buf());
2614        let manifest_json = serde_json::json!({
2615            "export_path": export_abs.to_string_lossy(),
2616            "sha256": sha256_hex,
2617            "byte_count": bytes.len() as u64,
2618        });
2619        let manifest_bytes =
2620            serde_json::to_vec_pretty(&manifest_json).map_err(|_| EngineError::Storage)?;
2621        std::fs::write(manifest, &manifest_bytes).map_err(|_| EngineError::Storage)?;
2622        Ok(SafeExportArtifact {
2623            export_path: out.to_path_buf(),
2624            manifest_path: manifest.to_path_buf(),
2625            manifest_sha256: sha256_hex,
2626        })
2627    }
2628
2629    /// Operator regenerate workflow per `dev/design/projections.md`
2630    /// § Regenerate workflow. Drains in-flight projection work, then
2631    /// truncates FTS5 + vec0 shadow rows, resets the projection cursor,
2632    /// and lets the scheduler re-enqueue every canonical row. Durable
2633    /// `projection_failures` audit rows are preserved per design. AC-044
2634    /// + AC-063c.
2635    pub fn rebuild_projections(&self) -> Result<RebuildReport, EngineError> {
2636        self.ensure_open()?;
2637        self.run_rebuild(true, RebuildKind::Projections)
2638    }
2639
2640    /// Vec0-only variant of [`Engine::rebuild_projections`]. Leaves
2641    /// FTS5 shadow content untouched; per recovery design,
2642    /// `recover --rebuild-vec0` is the surface for vec0-only repair.
2643    pub fn rebuild_vec0(&self) -> Result<RebuildReport, EngineError> {
2644        self.ensure_open()?;
2645        self.run_rebuild(false, RebuildKind::Vec0)
2646    }
2647
2648    /// Phase 9 Pack B / AC-042 source trace. Returns the canonical-row
2649    /// id set produced by `source_id`, ordered by `write_cursor`. Empty
2650    /// string is not a valid `source_id`; rows with NULL `source_id`
2651    /// are excluded from every result.
2652    pub fn trace_source_ref(&self, source_id: &str) -> Result<TraceReport, EngineError> {
2653        self.ensure_open()?;
2654        if source_id.is_empty() {
2655            return Err(EngineError::WriteValidation);
2656        }
2657        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2658        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2659
2660        let mut events: Vec<TraceEvent> = Vec::new();
2661        let mut nodes = connection
2662            .prepare(
2663                "SELECT write_cursor, kind FROM canonical_nodes WHERE source_id = ?1
2664                 ORDER BY write_cursor",
2665            )
2666            .map_err(|_| EngineError::Storage)?;
2667        let node_rows = nodes
2668            .query_map([source_id], |row| {
2669                Ok(TraceEvent {
2670                    write_cursor: row.get::<_, i64>(0)? as u64,
2671                    kind: row.get::<_, String>(1)?,
2672                    table: "canonical_nodes",
2673                })
2674            })
2675            .map_err(|_| EngineError::Storage)?;
2676        for row in node_rows {
2677            events.push(row.map_err(|_| EngineError::Storage)?);
2678        }
2679
2680        let mut edges = connection
2681            .prepare(
2682                "SELECT write_cursor, kind FROM canonical_edges WHERE source_id = ?1
2683                 ORDER BY write_cursor",
2684            )
2685            .map_err(|_| EngineError::Storage)?;
2686        let edge_rows = edges
2687            .query_map([source_id], |row| {
2688                Ok(TraceEvent {
2689                    write_cursor: row.get::<_, i64>(0)? as u64,
2690                    kind: row.get::<_, String>(1)?,
2691                    table: "canonical_edges",
2692                })
2693            })
2694            .map_err(|_| EngineError::Storage)?;
2695        for row in edge_rows {
2696            events.push(row.map_err(|_| EngineError::Storage)?);
2697        }
2698
2699        events.sort_by_key(|e| e.write_cursor);
2700        Ok(TraceReport { source_ref: source_id.to_string(), events })
2701    }
2702
2703    /// Phase 9 Pack B / AC-028a/b/c source excise. Drains in-flight
2704    /// projection work, then deletes every canonical row attributable
2705    /// to `source_id` plus the FTS5 + vec0 shadow rows that referenced
2706    /// those cursors, and appends an audit row to the
2707    /// `excise_source_audit` operational collection.
2708    ///
2709    /// Non-perturbation: rows from other sources (and rows with NULL
2710    /// `source_id`) are untouched; the projection cursor is NOT reset
2711    /// and no blanket projection rebuild is issued.
2712    pub fn excise_source(&self, source_id: &str) -> Result<ExciseReport, EngineError> {
2713        self.ensure_open()?;
2714        if source_id.is_empty() {
2715            return Err(EngineError::WriteValidation);
2716        }
2717
2718        // Drain MUST succeed before the excise transaction. SQLite-WAL
2719        // would otherwise allow a worker that already dequeued a job
2720        // for an excised cursor to commit its INSERT into vec0 /
2721        // _fathomdb_vector_rows after our DELETE releases the writer
2722        // lock, leaving residue and breaking AC-028b. Surface the
2723        // timeout instead of swallowing it (Pack A pattern).
2724        self.projection_runtime.set_frozen(true);
2725        let drain_result = self.drain(REBUILD_DRAIN_TIMEOUT_MS);
2726        let outcome = drain_result.and_then(|()| self.excise_source_inner(source_id));
2727        self.projection_runtime.set_frozen(false);
2728        outcome
2729    }
2730
2731    /// Doctor `verify-embedder` seam (AC-040a). Compares the
2732    /// `_fathomdb_embedder_profiles` row to the operator-supplied
2733    /// `name:revision` identity + dimension; never raises on mismatch.
2734    pub fn verify_embedder(
2735        &self,
2736        supplied_identity: &str,
2737        supplied_dimension: u32,
2738    ) -> Result<VerifyEmbedderReport, EngineError> {
2739        self.ensure_open()?;
2740        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2741        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2742        let stored = load_default_profile(connection).map_err(|_| EngineError::Storage)?;
2743        let stored_identity = format!("{}:{}", stored.name, stored.revision);
2744        let identity_match = stored_identity == supplied_identity;
2745        let dimension_match = stored.dimension == supplied_dimension;
2746        let status = match (identity_match, dimension_match) {
2747            (true, true) => VerifyEmbedderStatus::Match,
2748            (false, true) => VerifyEmbedderStatus::IdentityMismatch,
2749            (true, false) => VerifyEmbedderStatus::DimensionMismatch,
2750            (false, false) => VerifyEmbedderStatus::BothMismatch,
2751        };
2752        Ok(VerifyEmbedderReport {
2753            stored_identity,
2754            stored_dimension: stored.dimension,
2755            supplied_identity: supplied_identity.to_string(),
2756            supplied_dimension,
2757            status,
2758        })
2759    }
2760
2761    /// Doctor `dump-schema` seam (AC-040a). Returns the
2762    /// `PRAGMA user_version` sentinel plus the table + index inventory
2763    /// from `sqlite_schema`, excluding `sqlite_*` internal rows.
2764    /// Canonical tables appear first per [`CANONICAL_TABLES`].
2765    pub fn dump_schema(&self) -> Result<DumpSchemaReport, EngineError> {
2766        self.ensure_open()?;
2767        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2768        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2769        let user_version: u32 = connection
2770            .query_row("PRAGMA user_version", [], |row| row.get(0))
2771            .map_err(|_| EngineError::Storage)?;
2772        let tables = read_schema_objects(connection, "table")?;
2773        let indexes = read_schema_objects(connection, "index")?;
2774        Ok(DumpSchemaReport { user_version, tables: order_canonical_first(tables), indexes })
2775    }
2776
2777    /// Doctor `dump-row-counts` seam (AC-040a). Emits canonical-table
2778    /// counts only; projection / FTS / vec0 shadow tables are excluded.
2779    pub fn dump_row_counts(&self) -> Result<DumpRowCountsReport, EngineError> {
2780        self.ensure_open()?;
2781        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2782        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2783        let mut counts = Vec::with_capacity(CANONICAL_TABLES.len());
2784        for name in CANONICAL_TABLES {
2785            let rows: u64 = connection
2786                .query_row(&format!("SELECT COUNT(*) FROM {name}"), [], |row| row.get(0))
2787                .map_err(|_| EngineError::Storage)?;
2788            counts.push(TableRowCount { name: (*name).to_string(), rows });
2789        }
2790        Ok(DumpRowCountsReport { counts })
2791    }
2792
2793    /// Doctor `dump-profile` seam (AC-040a). Returns the stored
2794    /// embedder identity + dimension plus the registered vectorized
2795    /// kinds from `_fathomdb_vector_kinds`.
2796    pub fn dump_profile(&self) -> Result<DumpProfileReport, EngineError> {
2797        self.ensure_open()?;
2798        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2799        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2800        let stored = load_default_profile(connection).map_err(|_| EngineError::Storage)?;
2801        let mut stmt = connection
2802            .prepare("SELECT kind FROM _fathomdb_vector_kinds ORDER BY kind")
2803            .map_err(|_| EngineError::Storage)?;
2804        let rows =
2805            stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|_| EngineError::Storage)?;
2806        let mut vectorized_kinds = Vec::new();
2807        for row in rows {
2808            vectorized_kinds.push(row.map_err(|_| EngineError::Storage)?);
2809        }
2810        Ok(DumpProfileReport {
2811            embedder_identity: format!("{}:{}", stored.name, stored.revision),
2812            embedder_dimension: stored.dimension,
2813            vectorized_kinds,
2814        })
2815    }
2816
2817    /// Recover `--truncate-wal` seam. Runs
2818    /// `PRAGMA wal_checkpoint(TRUNCATE)` and returns the three counters
2819    /// SQLite reports. `status = Busy` when SQLite signalled a blocked
2820    /// checkpoint (`busy != 0`); the WAL may still be partially
2821    /// checkpointed in that case.
2822    pub fn truncate_wal(&self) -> Result<TruncateWalReport, EngineError> {
2823        self.ensure_open()?;
2824        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2825        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2826        let (busy, log_frames, checkpointed_frames): (i64, i64, i64) = connection
2827            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
2828                Ok((row.get(0)?, row.get(1)?, row.get(2)?))
2829            })
2830            .map_err(|_| EngineError::Storage)?;
2831        let status = if busy == 0 { TruncateWalStatus::Done } else { TruncateWalStatus::Busy };
2832        Ok(TruncateWalReport {
2833            status,
2834            busy: busy.max(0) as u32,
2835            log_frames: log_frames.max(0) as u32,
2836            checkpointed_frames: checkpointed_frames.max(0) as u32,
2837        })
2838    }
2839
2840    fn excise_source_inner(&self, source_id: &str) -> Result<ExciseReport, EngineError> {
2841        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2842        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
2843        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
2844
2845        // Collect the cursor sets up-front so we can targeted-delete
2846        // shadow rows AND emit an accurate audit row in one txn.
2847        let node_cursors: Vec<i64> = {
2848            let mut stmt = tx
2849                .prepare("SELECT write_cursor FROM canonical_nodes WHERE source_id = ?1")
2850                .map_err(|_| EngineError::Storage)?;
2851            let rows = stmt
2852                .query_map([source_id], |row| row.get::<_, i64>(0))
2853                .map_err(|_| EngineError::Storage)?;
2854            rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
2855        };
2856        let edge_cursors: Vec<i64> = {
2857            let mut stmt = tx
2858                .prepare("SELECT write_cursor FROM canonical_edges WHERE source_id = ?1")
2859                .map_err(|_| EngineError::Storage)?;
2860            let rows = stmt
2861                .query_map([source_id], |row| row.get::<_, i64>(0))
2862                .map_err(|_| EngineError::Storage)?;
2863            rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
2864        };
2865
2866        let mut shadow_invalidated: u64 = 0;
2867        for cursor in node_cursors.iter().chain(edge_cursors.iter()) {
2868            shadow_invalidated = shadow_invalidated.saturating_add(
2869                tx.execute("DELETE FROM search_index WHERE write_cursor = ?1", [cursor])
2870                    .map_err(|_| EngineError::Storage)? as u64,
2871            );
2872            // vec0 rowid is the canonical row's write_cursor (see
2873            // `_fathomdb_vector_rows.write_cursor UNIQUE`).
2874            shadow_invalidated = shadow_invalidated.saturating_add(
2875                tx.execute("DELETE FROM vector_default WHERE rowid = ?1", [cursor])
2876                    .map_err(|_| EngineError::Storage)? as u64,
2877            );
2878            shadow_invalidated = shadow_invalidated.saturating_add(
2879                tx.execute("DELETE FROM _fathomdb_vector_rows WHERE write_cursor = ?1", [cursor])
2880                    .map_err(|_| EngineError::Storage)? as u64,
2881            );
2882            shadow_invalidated = shadow_invalidated.saturating_add(
2883                tx.execute(
2884                    "DELETE FROM _fathomdb_projection_terminal WHERE write_cursor = ?1",
2885                    [cursor],
2886                )
2887                .map_err(|_| EngineError::Storage)? as u64,
2888            );
2889        }
2890
2891        let nodes_excised = tx
2892            .execute("DELETE FROM canonical_nodes WHERE source_id = ?1", [source_id])
2893            .map_err(|_| EngineError::Storage)? as u64;
2894        let edges_excised = tx
2895            .execute("DELETE FROM canonical_edges WHERE source_id = ?1", [source_id])
2896            .map_err(|_| EngineError::Storage)? as u64;
2897
2898        // AC-028a audit row: a single append on the
2899        // `excise_source_audit` collection naming the excised source.
2900        // `next_cursor` after a prior write holds the LAST committed cursor;
2901        // mirror the vec writer pattern (load + 1, then store post-commit)
2902        // so the audit row's `write_cursor` is strictly greater than every
2903        // canonical row that preceded it.
2904        let excised_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
2905        let payload = serde_json::json!({
2906            "source_id": source_id,
2907            "excised_at": excised_at,
2908            "nodes_excised": nodes_excised,
2909            "edges_excised": edges_excised,
2910            "projections_invalidated": shadow_invalidated,
2911        })
2912        .to_string();
2913        let audit_cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
2914        tx.execute(
2915            "INSERT INTO operational_mutations(
2916                collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
2917             ) VALUES('excise_source_audit', ?1, 'append', ?2, NULL, ?3)",
2918            params![source_id, payload, audit_cursor],
2919        )
2920        .map_err(|_| EngineError::Storage)?;
2921
2922        tx.commit().map_err(|_| EngineError::Storage)?;
2923        self.next_cursor.store(audit_cursor, Ordering::SeqCst);
2924        Ok(ExciseReport {
2925            source_ref: source_id.to_string(),
2926            nodes_excised,
2927            edges_excised,
2928            projections_invalidated: shadow_invalidated,
2929        })
2930    }
2931
2932    fn run_rebuild(
2933        &self,
2934        include_fts: bool,
2935        kind: RebuildKind,
2936    ) -> Result<RebuildReport, EngineError> {
2937        self.projection_runtime.set_frozen(true);
2938        // Drain MUST succeed: rebuild_shadow_state truncates shadow rows,
2939        // and SQLite-WAL allows a worker that already dequeued a job to
2940        // commit its `INSERT OR IGNORE INTO _fathomdb_vector_rows / vec0`
2941        // after our truncate releases the writer lock, leaving stale
2942        // rows. Surfacing the timeout (instead of swallowing it) lets the
2943        // operator retry rather than silently corrupt the rebuild.
2944        let drain_result = self.drain(REBUILD_DRAIN_TIMEOUT_MS);
2945        let result = drain_result.and_then(|()| self.rebuild_shadow_state(include_fts, kind));
2946        self.projection_runtime.set_frozen(false);
2947        result
2948    }
2949
2950    fn rebuild_shadow_state(
2951        &self,
2952        include_fts: bool,
2953        kind: RebuildKind,
2954    ) -> Result<RebuildReport, EngineError> {
2955        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2956        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
2957        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
2958        let mut rows_invalidated: u64 = 0;
2959        if include_fts {
2960            let n = tx.execute("DELETE FROM search_index", []).map_err(|_| EngineError::Storage)?;
2961            rows_invalidated = rows_invalidated.saturating_add(n as u64);
2962        }
2963        let n = tx.execute("DELETE FROM vector_default", []).map_err(|_| EngineError::Storage)?;
2964        rows_invalidated = rows_invalidated.saturating_add(n as u64);
2965        let n = tx
2966            .execute("DELETE FROM _fathomdb_vector_rows", [])
2967            .map_err(|_| EngineError::Storage)?;
2968        rows_invalidated = rows_invalidated.saturating_add(n as u64);
2969        let n = tx
2970            .execute("DELETE FROM _fathomdb_projection_terminal", [])
2971            .map_err(|_| EngineError::Storage)?;
2972        rows_invalidated = rows_invalidated.saturating_add(n as u64);
2973        store_projection_cursor(&tx, 0).map_err(|_| EngineError::Storage)?;
2974        let mut rows_rebuilt: u64 = 0;
2975        if include_fts {
2976            for row in canonical_node_rows(&tx).map_err(|_| EngineError::Storage)? {
2977                tx.execute(
2978                    "INSERT INTO search_index(body, kind, write_cursor) VALUES(?1, ?2, ?3)",
2979                    params![row.body, row.kind, row.cursor],
2980                )
2981                .map_err(|_| EngineError::Storage)?;
2982                rows_rebuilt = rows_rebuilt.saturating_add(1);
2983            }
2984        }
2985        let projection_cursor_after =
2986            load_projection_cursor(&tx).map_err(|_| EngineError::Storage)?;
2987        tx.commit().map_err(|_| EngineError::Storage)?;
2988        Ok(RebuildReport { kind, rows_invalidated, rows_rebuilt, projection_cursor_after })
2989    }
2990
2991    fn ensure_open(&self) -> Result<(), EngineError> {
2992        if self.closed.load(Ordering::SeqCst) {
2993            return Err(EngineError::Closing);
2994        }
2995
2996        Ok(())
2997    }
2998}
2999
3000fn batch_is_admin(batch: &[PreparedWrite]) -> bool {
3001    !batch.is_empty() && batch.iter().all(|w| matches!(w, PreparedWrite::AdminSchema { .. }))
3002}
3003
3004// 0.7.0 Pack 2 (ADR-0.7.0-vector-binary-quant § 2; handoff § 2.2):
3005// bit-KNN candidate-set size for the two-phase read path. Tuned with
3006// the recall@10 floor in tests/perf_gates.rs::ac_013b_recall_at_10_floor.
3007//
3008// Bumped from 64 → 192 in EU-5a2 per the HITL 2026-05-29 fine-grained
3009// K-sweep result (dev/notes/0.7.1-default-embedder-research.md §5.4):
3010// K=192 sits above the recall-plateau knee for the default embedder.
3011// Public-visible so the EU-5a2 machinery test can assert the value.
3012pub const TOP_K_BIT_CANDIDATES: usize = 192;
3013
3014/// EU-5a2 — number of documents required before the workspace's
3015/// `_fathomdb_embedder_profiles.mean_vec` is pinned for the default
3016/// profile. Per `dev/design/embedder.md` §0.3 (compute-once-on-first-
3017/// ingest lifecycle). Public-visible so the EU-5a2 machinery test can
3018/// assert the value.
3019pub const MEAN_VEC_PIN_THRESHOLD: u64 = 256;
3020
3021/// 0.7.2 PR-2bc S1 fix-1 — production phase-2 rerank `LIMIT` for engine
3022/// search. This is the original hardcoded `LIMIT 10`; it is the default and
3023/// the floor for `search_limit_override` (a test seam may RAISE it but never
3024/// shrink it below this). There is NO env-var override on the hot path.
3025pub const SEARCH_RERANK_LIMIT: usize = 10;
3026
3027/// EU-5a2 — streaming f64 accumulator for the mean-centering pipeline,
3028/// per `dev/design/embedder.md` §0.3 (f64 chosen to bound numerical
3029/// drift across `MEAN_VEC_PIN_THRESHOLD` adds). Owned by the projection
3030/// worker; materialized into the schema column at the threshold cross.
3031#[derive(Clone, Debug)]
3032struct MeanAccumulator {
3033    sum: Vec<f64>,
3034    count: u64,
3035}
3036
3037impl MeanAccumulator {
3038    fn new(dim: usize) -> Self {
3039        Self { sum: vec![0.0; dim], count: 0 }
3040    }
3041
3042    fn add(&mut self, v: &[f32]) {
3043        debug_assert_eq!(v.len(), self.sum.len(), "accumulator dim mismatch");
3044        for (slot, value) in self.sum.iter_mut().zip(v.iter()) {
3045            *slot += f64::from(*value);
3046        }
3047        self.count = self.count.saturating_add(1);
3048    }
3049
3050    fn materialize(&self) -> Vec<f32> {
3051        if self.count == 0 {
3052            return vec![0.0; self.sum.len()];
3053        }
3054        let denom = self.count as f64;
3055        self.sum.iter().map(|s| (s / denom) as f32).collect()
3056    }
3057
3058    fn count(&self) -> u64 {
3059        self.count
3060    }
3061}
3062
3063/// 0.7.2 PR-2b — cosine similarity between two equal-length vectors.
3064/// Returns 1.0 for a pair with a zero-norm operand (treated as "no drift
3065/// signal"), so the detector never fires on a degenerate all-zero mean.
3066fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
3067    if a.len() != b.len() {
3068        return 1.0;
3069    }
3070    let mut dot = 0.0f64;
3071    let mut na = 0.0f64;
3072    let mut nb = 0.0f64;
3073    for (x, y) in a.iter().zip(b.iter()) {
3074        dot += f64::from(*x) * f64::from(*y);
3075        na += f64::from(*x) * f64::from(*x);
3076        nb += f64::from(*y) * f64::from(*y);
3077    }
3078    if na == 0.0 || nb == 0.0 {
3079        return 1.0;
3080    }
3081    (dot / (na.sqrt() * nb.sqrt())) as f32
3082}
3083
3084/// EU-5b — at-pin pin-and-requantize pass per `dev/design/embedder.md`
3085/// §0.5. Runs INSIDE the caller's SQLite transaction so the mean_vec
3086/// INSERT/UPDATE + the per-row sign-bit UPDATEs commit atomically.
3087///
3088/// For each pre-pin row, recomputes `bits' = sign_quantize(f32 - mean)`
3089/// via the SQL extension's `vec_quantize_binary`, then UPDATEs the
3090/// row's `embedding_bin` column.
3091fn run_pin_and_requantize_pass(
3092    tx: &rusqlite::Transaction<'_>,
3093    rows: &[(i64, Vec<u8>)],
3094    mean: &[f32],
3095) -> Result<(u64, Vec<EmbedderEvent>), EngineError> {
3096    let mut updated: u64 = 0;
3097    let dim = mean.len();
3098    // sqlite-vec's vec0 xUpdate path discards SQL-function result subtypes
3099    // (see sqlite-vec.c §vec0Update_UpdateVectorColumn — "subtypes don't
3100    // appear to survive xColumn -> xUpdate, it's always 0"), so a direct
3101    // `UPDATE ... SET embedding_bin = vec_quantize_binary(?)` reads the
3102    // bound value as a float32-tagged vector and trips the column-type
3103    // check. We work around by DELETE+INSERT inside the same transaction:
3104    // INSERT preserves the BIT subtype on `vec_quantize_binary`. The
3105    // surrounding pin-commit tx keeps the rewrite atomic.
3106    for (rowid, blob) in rows {
3107        if blob.len() != dim * 4 {
3108            return Err(EngineError::Storage);
3109        }
3110        let un_centered = decode_vector_blob(blob);
3111        let centered = subtract_mean(&un_centered, mean);
3112        let centered_blob = encode_vector_blob(&centered);
3113
3114        let (source_type, kind, created_at): (String, String, i64) = tx
3115            .query_row(
3116                "SELECT source_type, kind, created_at FROM vector_default WHERE rowid = ?1",
3117                params![rowid],
3118                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
3119            )
3120            .map_err(|_| EngineError::Storage)?;
3121
3122        tx.execute("DELETE FROM vector_default WHERE rowid = ?1", params![rowid])
3123            .map_err(|_| EngineError::Storage)?;
3124
3125        tx.execute(
3126            "INSERT INTO vector_default(
3127                rowid, embedding, embedding_bin, source_type, kind, created_at
3128             ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6)",
3129            params![rowid, blob, centered_blob, source_type, kind, created_at],
3130        )
3131        .map_err(|_| EngineError::Storage)?;
3132
3133        updated = updated.saturating_add(1);
3134    }
3135    let events = vec![EmbedderEvent::MeanVecPinned {
3136        dim: u32::try_from(dim).unwrap_or(u32::MAX),
3137        doc_count: updated,
3138    }];
3139    Ok((updated, events))
3140}
3141
3142/// EU-5a2 — back-compat test-only count+emit helper. Preserved so the
3143/// EU-5a2 machinery test stays green; the EU-5b production path uses
3144/// `run_pin_and_requantize_pass`.
3145fn run_requantize_pass(rows: &[(i64, Vec<u8>)], mean: &[f32]) -> (u64, Vec<EmbedderEvent>) {
3146    let mut updated: u64 = 0;
3147    let dim = mean.len();
3148    for (_rowid, blob) in rows {
3149        if blob.len() != dim * 4 {
3150            continue;
3151        }
3152        updated = updated.saturating_add(1);
3153    }
3154    let events = vec![EmbedderEvent::MeanVecPinned {
3155        dim: u32::try_from(dim).unwrap_or(u32::MAX),
3156        doc_count: updated,
3157    }];
3158    (updated, events)
3159}
3160
3161/// EU-5a2 — test-visible re-exports of the mean-centering internals.
3162/// Per the handoff RED tests; the production accumulator and re-quantize
3163/// pass are otherwise crate-private.
3164#[doc(hidden)]
3165pub mod mean_centering_internals_for_test {
3166    use super::{EmbedderEvent, MeanAccumulator};
3167
3168    pub struct AccumulatorHandle(MeanAccumulator);
3169
3170    #[must_use]
3171    pub fn new_mean_accumulator(dim: usize) -> AccumulatorHandle {
3172        AccumulatorHandle(MeanAccumulator::new(dim))
3173    }
3174
3175    pub fn accumulator_add(handle: &mut AccumulatorHandle, v: &[f32]) {
3176        handle.0.add(v);
3177    }
3178
3179    #[must_use]
3180    pub fn accumulator_materialize(handle: &AccumulatorHandle) -> Vec<f32> {
3181        handle.0.materialize()
3182    }
3183
3184    #[must_use]
3185    pub fn accumulator_count(handle: &AccumulatorHandle) -> u64 {
3186        handle.0.count()
3187    }
3188
3189    #[must_use]
3190    pub fn run_requantize_pass(rows: &[(i64, Vec<u8>)], mean: &[f32]) -> (u64, Vec<EmbedderEvent>) {
3191        super::run_requantize_pass(rows, mean)
3192    }
3193}
3194
3195/// Read projection cursor and matching body rows inside one read tx.
3196fn read_search_in_tx(
3197    reader: &mut Connection,
3198    compiled: &fathomdb_query::CompiledQuery,
3199    query_vector: Option<&str>,
3200    query_vector_bin: Option<&str>,
3201    final_limit: usize,
3202) -> rusqlite::Result<(u64, Option<SoftFallback>, Vec<String>)> {
3203    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
3204    let cursor = load_projection_cursor(&tx)?;
3205    let vector_results = if let Some(query_vector) = query_vector {
3206        let mut rowids = Vec::new();
3207        let bin_vector = query_vector_bin.unwrap_or(query_vector);
3208        {
3209            // Phase 1: bit-KNN over `embedding_bin` to a top-K candidate
3210            // set; Phase 2: f32 rerank on the candidate set via
3211            // vec_distance_l2 against the retained `embedding` column.
3212            // EU-5a2: ?1 is the (possibly centered) sign-quant input,
3213            // ?2 is the un-centered f32 for vec_distance_l2 — both sides
3214            // of the f32 cosine use un-centered vectors.
3215            // PR-2bc S1 fix-1: the phase-2 rerank LIMIT is `SEARCH_RERANK_LIMIT`
3216            // (10) in production. `final_limit` is supplied by the caller from
3217            // `ProjectionRuntimeShared::search_limit_override` (default 10,
3218            // clamped >=10) — there is NO env-var read on this hot path. A test
3219            // seam (`set_search_limit_for_test`) may RAISE it so the recall
3220            // harness can pull top-(10+slack) and exclude the self-retrieving
3221            // query-source doc BEFORE truncating to 10 (standard ANN-recall
3222            // practice); it can never shrink below production semantics.
3223            let sql = format!(
3224                "WITH candidates AS (
3225                     SELECT rowid
3226                     FROM vector_default
3227                     WHERE embedding_bin MATCH vec_quantize_binary(vec_f32(?1))
3228                     ORDER BY distance
3229                     LIMIT {top_k}
3230                 )
3231                 SELECT c.rowid
3232                 FROM candidates c
3233                 JOIN vector_default v ON v.rowid = c.rowid
3234                 ORDER BY vec_distance_l2(v.embedding, vec_f32(?2))
3235                 LIMIT {final_limit}",
3236                top_k = TOP_K_BIT_CANDIDATES,
3237            );
3238            let mut statement = tx.prepare(&sql)?;
3239            let rows =
3240                statement.query_map([bin_vector, query_vector], |row| row.get::<_, i64>(0))?;
3241            for row in rows.flatten() {
3242                rowids.push(row);
3243            }
3244        }
3245        let mut results = Vec::new();
3246        let mut statement =
3247            tx.prepare("SELECT body FROM canonical_nodes WHERE write_cursor = ?1 LIMIT 1")?;
3248        for rowid in rowids {
3249            if let Ok(body) = statement.query_row([rowid], |row| row.get::<_, String>(0)) {
3250                results.push(body);
3251            }
3252        }
3253        results
3254    } else {
3255        Vec::new()
3256    };
3257    let vector_rows_visible = !vector_results.is_empty();
3258    let soft_fallback = if query_vector.is_some() && !vector_rows_visible {
3259        tx.query_row(
3260            "SELECT 1
3261             FROM search_index
3262             JOIN _fathomdb_vector_kinds ON _fathomdb_vector_kinds.kind = search_index.kind
3263             LEFT JOIN _fathomdb_projection_terminal
3264               ON _fathomdb_projection_terminal.write_cursor = search_index.write_cursor
3265             WHERE search_index MATCH ?1
3266              AND _fathomdb_projection_terminal.write_cursor IS NULL
3267             LIMIT 1",
3268            [compiled.match_expression.as_str()],
3269            |_row| Ok(SoftFallback { branch: SoftFallbackBranch::Vector }),
3270        )
3271        .ok()
3272    } else {
3273        None
3274    };
3275    let mut seen = BTreeSet::new();
3276    let mut results = Vec::new();
3277    for row in vector_results {
3278        if seen.insert(row.clone()) {
3279            results.push(row);
3280        }
3281    }
3282    {
3283        // 0.7.0 perf-experiments: optional FTS5 LIMIT cap. Gated on
3284        // FATHOMDB_PERF_EXPERIMENTS=1; opt-in via
3285        // FATHOMDB_PERF_SEARCH_LIMIT=<k>. No-op by default — preserves
3286        // 0.6.x unbounded result-set semantics. Removed (or made the
3287        // hardcoded default) at Wave 5 landing per
3288        // dev/plans/0.7.0-perf-experiments.md.
3289        let perf_limit: Option<usize> = if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_some() {
3290            std::env::var("FATHOMDB_PERF_SEARCH_LIMIT").ok().and_then(|s| s.parse().ok())
3291        } else {
3292            None
3293        };
3294        let sql = match perf_limit {
3295            Some(k) => format!(
3296                "SELECT body FROM search_index WHERE search_index MATCH ?1 ORDER BY write_cursor LIMIT {k}"
3297            ),
3298            None => "SELECT body FROM search_index WHERE search_index MATCH ?1 ORDER BY write_cursor"
3299                .to_string(),
3300        };
3301        let mut statement = tx.prepare(&sql)?;
3302        let rows = statement
3303            .query_map([compiled.match_expression.as_str()], |row| row.get::<_, String>(0))?;
3304        for row in rows.flatten() {
3305            if seen.insert(row.clone()) {
3306                results.push(row);
3307            }
3308        }
3309    }
3310    tx.commit()?;
3311    Ok((cursor, soft_fallback, results))
3312}
3313
3314fn projection_dispatcher_loop(shared: Arc<ProjectionRuntimeShared>) {
3315    let connection = match open_runtime_connection(&shared.path) {
3316        Ok(connection) => connection,
3317        Err(_) => return,
3318    };
3319    loop {
3320        let in_flight = {
3321            let mut state = match shared.state.lock() {
3322                Ok(state) => state,
3323                Err(_) => return,
3324            };
3325            while !state.stopping
3326                && (!state.pending_scan
3327                    || state.frozen
3328                    || state.active_jobs + state.queued_jobs >= PROJECTION_INFLIGHT_LIMIT)
3329            {
3330                state = match shared.state_cvar.wait(state) {
3331                    Ok(state) => state,
3332                    Err(_) => return,
3333                };
3334            }
3335            if state.stopping {
3336                return;
3337            }
3338            state.pending_scan = false;
3339            state.in_flight.clone()
3340        };
3341
3342        // Fetch up to the in-flight budget in one SQL roundtrip and
3343        // enqueue them as a batch — previously this loop fetched ONE job
3344        // per cycle, which capped projection throughput at one row per
3345        // scanner/worker handshake regardless of how much work was queued
3346        // in canonical_nodes.
3347        let budget = {
3348            let state = match shared.state.lock() {
3349                Ok(state) => state,
3350                Err(_) => return,
3351            };
3352            PROJECTION_INFLIGHT_LIMIT.saturating_sub(state.active_jobs + state.queued_jobs)
3353        };
3354        let fetch_cap = budget.clamp(1, PROJECTION_SCAN_FETCH);
3355        match next_pending_projection_jobs(&connection, &in_flight, fetch_cap) {
3356            Ok(jobs) if !jobs.is_empty() => {
3357                if let Ok(mut state) = shared.state.lock() {
3358                    state.queued_jobs = state.queued_jobs.saturating_add(jobs.len());
3359                    for job in &jobs {
3360                        state.in_flight.insert(job.cursor);
3361                    }
3362                    state.pending_scan = true;
3363                    shared.state_cvar.notify_all();
3364                }
3365                if let Ok(mut queue) = shared.queue.lock() {
3366                    for job in jobs {
3367                        queue.push_back(job);
3368                    }
3369                    shared.queue_cvar.notify_all();
3370                }
3371            }
3372            Ok(_) => {}
3373            Err(_) => {
3374                if let Ok(mut state) = shared.state.lock() {
3375                    state.pending_scan = false;
3376                    shared.state_cvar.notify_all();
3377                }
3378            }
3379        }
3380    }
3381}
3382
3383fn projection_worker_loop(shared: Arc<ProjectionRuntimeShared>) {
3384    let mut connection = match open_runtime_connection(&shared.path) {
3385        Ok(connection) => connection,
3386        Err(_) => return,
3387    };
3388    if ensure_vector_partition(&mut connection, shared.embedder_identity.dimension).is_err() {
3389        return;
3390    }
3391    loop {
3392        let jobs = {
3393            let mut queue = match shared.queue.lock() {
3394                Ok(queue) => queue,
3395                Err(_) => return,
3396            };
3397            loop {
3398                let stopping = shared.state.lock().map(|state| state.stopping).unwrap_or(true);
3399                if stopping && queue.is_empty() {
3400                    return;
3401                }
3402                if let Some(job) = queue.pop_front() {
3403                    let mut jobs = vec![job];
3404                    while jobs.len() < PROJECTION_COMMIT_BATCH {
3405                        let Some(job) = queue.pop_front() else {
3406                            break;
3407                        };
3408                        jobs.push(job);
3409                    }
3410                    if let Ok(mut state) = shared.state.lock() {
3411                        state.queued_jobs = state.queued_jobs.saturating_sub(jobs.len());
3412                        state.active_jobs = state.active_jobs.saturating_add(jobs.len());
3413                        shared.state_cvar.notify_all();
3414                    }
3415                    break jobs;
3416                }
3417                queue = match shared.queue_cvar.wait(queue) {
3418                    Ok(queue) => queue,
3419                    Err(_) => return,
3420                };
3421            }
3422        };
3423
3424        // EU-5f — isolate worker faults. A panic inside `embed()` (or the
3425        // commit) must not skip the state cleanup below, or `active_jobs`
3426        // would stay elevated forever and `wait_for_idle` / `drain` would
3427        // wedge into `EngineError::Scheduler` (Finding A). Mirrors the
3428        // reader pool's `LiveGuard` panic-safety. The local commit tx rolls
3429        // back on unwind, leaving the connection clean for reuse.
3430        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3431            run_projection_jobs(&shared, &mut connection, &jobs);
3432        }))
3433        .is_err();
3434        if panicked {
3435            commit_projection_panic_failures(&shared, &mut connection, &jobs);
3436        }
3437
3438        if let Ok(mut state) = shared.state.lock() {
3439            state.active_jobs = state.active_jobs.saturating_sub(jobs.len());
3440            for job in &jobs {
3441                state.in_flight.remove(&job.cursor);
3442            }
3443            if !state.stopping {
3444                state.pending_scan = true;
3445            }
3446            shared.state_cvar.notify_all();
3447        }
3448    }
3449}
3450
3451enum ProjectionOutcome {
3452    /// `blob` is the un-centered f32 BLOB persisted to
3453    /// `vector_default.embedding`. `bin_blob` is the (possibly centered)
3454    /// f32 BLOB fed to `vec_quantize_binary` for the sign-bit column.
3455    /// EU-5a2: `bin_blob == blob` unless the identity is MC-required
3456    /// AND a mean_vec is pinned.
3457    Success {
3458        cursor: u64,
3459        kind: String,
3460        blob: Vec<u8>,
3461        bin_blob: Vec<u8>,
3462    },
3463    Failure {
3464        cursor: u64,
3465        failure_code: &'static str,
3466    },
3467}
3468
3469fn run_projection_jobs(
3470    shared: &ProjectionRuntimeShared,
3471    connection: &mut Connection,
3472    jobs: &[ProjectionJob],
3473) {
3474    let mut outcomes = Vec::with_capacity(jobs.len());
3475    for job in jobs {
3476        outcomes.push(run_projection_job(shared, job));
3477    }
3478    let _ = commit_projection_outcomes(connection, &outcomes, shared);
3479}
3480
3481/// EU-5f — record every job in a panicked batch as a terminal projection
3482/// failure so the scheduler does not re-enqueue and re-panic on the same
3483/// cursors. Best-effort; runs after the worker caught a panic.
3484fn commit_projection_panic_failures(
3485    shared: &ProjectionRuntimeShared,
3486    connection: &mut Connection,
3487    jobs: &[ProjectionJob],
3488) {
3489    let outcomes: Vec<ProjectionOutcome> = jobs
3490        .iter()
3491        .map(|job| ProjectionOutcome::Failure {
3492            cursor: job.cursor,
3493            failure_code: "ProjectionPanic",
3494        })
3495        .collect();
3496    let _ = commit_projection_outcomes(connection, &outcomes, shared);
3497}
3498
3499/// PR-9 — ADR-0.6.0-embedder-protocol **Invariant 5**: run one `embed()`
3500/// under a per-call deadline. A hung (non-panicking) embed would otherwise
3501/// park a projection worker forever — the EU-5f `catch_unwind` only catches
3502/// *panics*. On timeout we return `RuntimeEmbedderError::Timeout`, which the
3503/// caller's existing retry/failure path already handles.
3504///
3505/// Cancellation follows Invariant 5 exactly: the embed runs on a detached
3506/// thread that is allowed to *finish + discard* its result — never aborted
3507/// mid-call (there is no safe thread-cancel API). The caller (the projection
3508/// worker) holds `embed_serialize` across this call, but DROPS it the moment
3509/// this returns — including on timeout — so the abandoned detached thread
3510/// runs lock-free and a hung embed can neither hold the serialization guard
3511/// forever nor deadlock the pool. (The commit happens later, outside this
3512/// call, under the separate `commit_gate`.)
3513///
3514/// Panic-transparent: if `embed()` panics, the panic payload is captured on
3515/// the watchdog thread and resumed on the worker thread, so the existing
3516/// batch-level `catch_unwind` records `ProjectionPanic` exactly as before.
3517///
3518/// `live` counts embed threads currently alive: incremented before the spawn
3519/// and decremented by the thread when it finishes (even if its result was
3520/// abandoned on timeout). The caller reads it to bound the abandoned-thread
3521/// leak via the circuit breaker.
3522fn embed_with_watchdog(
3523    embedder: &Arc<dyn Embedder>,
3524    body: &str,
3525    timeout: Duration,
3526    live: &Arc<AtomicU64>,
3527) -> Result<Vec<f32>, RuntimeEmbedderError> {
3528    let (tx, rx) = mpsc::channel();
3529    let embedder = Arc::clone(embedder);
3530    let body = body.to_string();
3531    // Count this embed thread as live before spawning; the thread decrements
3532    // when it finishes, whether or not its result is still wanted.
3533    live.fetch_add(1, Ordering::Relaxed);
3534    let live_thread = Arc::clone(live);
3535    thread::spawn(move || {
3536        let outcome =
3537            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| embedder.embed(&body)));
3538        // The receiver may already be gone (this call timed out): an async
3539        // channel send never blocks, and a send to a dropped receiver is a
3540        // no-op error we deliberately ignore — the result is discarded.
3541        let _ = tx.send(outcome);
3542        live_thread.fetch_sub(1, Ordering::Relaxed);
3543    });
3544    match rx.recv_timeout(timeout) {
3545        Ok(Ok(result)) => result,
3546        Ok(Err(panic_payload)) => std::panic::resume_unwind(panic_payload),
3547        Err(mpsc::RecvTimeoutError::Timeout) => Err(RuntimeEmbedderError::Timeout),
3548        // The watchdog thread dropped its sender without sending — should not
3549        // happen (panics are captured above), but treat as a failed embed so
3550        // the retry/failure path engages rather than silently succeeding.
3551        Err(mpsc::RecvTimeoutError::Disconnected) => Err(RuntimeEmbedderError::Failed {
3552            message: "embed watchdog thread dropped its result channel".to_string(),
3553        }),
3554    }
3555}
3556
3557fn run_projection_job(shared: &ProjectionRuntimeShared, job: &ProjectionJob) -> ProjectionOutcome {
3558    // PR-9 — embed circuit breaker (see `embed_circuit_open`). Once abandoned
3559    // (timed-out) embed threads have piled up to the threshold the embedder is
3560    // treated as broken; fail subsequent jobs fast WITHOUT attempting an embed,
3561    // so a wedged embedder cannot keep leaking abandoned watchdog threads. This
3562    // entry check is the fast path; the latch decision itself is made under the
3563    // embed guard below (race-free against other workers).
3564    if shared.embed_circuit_open.load(Ordering::Relaxed) {
3565        return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: "EmbedderError" };
3566    }
3567    let delays = shared.retry_delays_ms.lock().map(|delays| delays.clone()).unwrap_or_default();
3568    let mut last_code = "EmbedderError";
3569    for (attempt, delay_ms) in std::iter::once(0_u64).chain(delays.iter().copied()).enumerate() {
3570        if attempt > 0 {
3571            if shared.state.lock().map(|state| state.stopping).unwrap_or(true) {
3572                return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code };
3573            }
3574            thread::sleep(Duration::from_millis(delay_ms));
3575        }
3576        // PR-9 — re-check the breaker on every attempt, not just at entry:
3577        // another worker (or an earlier attempt of this job) may have latched
3578        // it while we were sleeping between retries. Bail before spawning yet
3579        // another timeout-bound watchdog thread, so the abandoned-thread leak
3580        // stays bounded even on the multi-retry path.
3581        if shared.embed_circuit_open.load(Ordering::Relaxed) {
3582            return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code };
3583        }
3584        // PR-9 / ADR-0.6.0 Invariant 5 — every embed runs under the per-call
3585        // watchdog deadline so a hung embed surfaces Timeout instead of
3586        // parking this worker forever.
3587        let embed_timeout = Duration::from_millis(shared.embed_timeout_ms.load(Ordering::Relaxed));
3588        let vector = match shared.embedder.as_ref() {
3589            Some(embedder) => {
3590                // PR-9 — serialize the embed call engine-side (see
3591                // `embed_serialize`): the shared embedder is invoked one call
3592                // at a time, for SAFETY with arbitrary caller-supplied
3593                // embedders (throughput is ~neutral on the candle default).
3594                // The guard is held across the watchdog call and released
3595                // here, so commit/IO below stays parallel and a timed-out
3596                // embed frees it. The guard owns no data; a panic-resumed
3597                // embed poisons it, so we recover the inner guard rather than
3598                // wedge the whole pool.
3599                let _embed_permit =
3600                    shared.embed_serialize.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
3601                // PR-9 — breaker decision, made WITH the guard held so it is
3602                // race-free against other workers: if abandoned embed threads
3603                // from earlier timeouts have piled up to the threshold, latch
3604                // the breaker and fail fast WITHOUT spawning another one. The
3605                // live count is checked here (also covers a breaker latched by
3606                // another worker while we were queued on the lock), bounding
3607                // the abandoned-thread leak to ~threshold regardless of whether
3608                // the embedder hangs always or only intermittently.
3609                let threshold = shared.embed_circuit_threshold.load(Ordering::Relaxed);
3610                if shared.embed_circuit_open.load(Ordering::Relaxed)
3611                    || (threshold != 0
3612                        && shared.live_embed_threads.load(Ordering::Relaxed) >= threshold)
3613                {
3614                    shared.embed_circuit_open.store(true, Ordering::Relaxed);
3615                    return ProjectionOutcome::Failure {
3616                        cursor: job.cursor,
3617                        failure_code: last_code,
3618                    };
3619                }
3620                match embed_with_watchdog(
3621                    embedder,
3622                    &job.body,
3623                    embed_timeout,
3624                    &shared.live_embed_threads,
3625                ) {
3626                    Ok(vector) => vector,
3627                    Err(RuntimeEmbedderError::Timeout) => {
3628                        // The embed thread is now abandoned (still counted in
3629                        // live_embed_threads until it returns); the breaker
3630                        // check above caps how many can accumulate.
3631                        last_code = "EmbedderError";
3632                        continue;
3633                    }
3634                    Err(RuntimeEmbedderError::Failed { .. }) => {
3635                        last_code = "EmbedderError";
3636                        continue;
3637                    }
3638                }
3639            }
3640            None => {
3641                last_code = "EmbedderNotConfiguredError";
3642                continue;
3643            }
3644        };
3645
3646        if u32::try_from(vector.len()).unwrap_or(u32::MAX) != shared.embedder_identity.dimension {
3647            last_code = "EmbedderDimensionMismatchError";
3648            continue;
3649        }
3650
3651        let blob = encode_vector_blob(&vector);
3652        // EU-5a2 mean-centering apply path (projection write side). The
3653        // f32 BLOB persisted is ALWAYS un-centered; `bin_blob` carries
3654        // the (possibly centered) f32 fed to `vec_quantize_binary`. The
3655        // centering decision is finalized in `commit_projection_outcomes`
3656        // where the writer connection is in-hand and the read of
3657        // `_fathomdb_embedder_profiles.mean_vec` is in the same tx as
3658        // the INSERT. NoopEmbedder (EU-5a2's only live identity) is not
3659        // MC-required, so `bin_blob == blob` throughout EU-5a2.
3660        let bin_blob = blob.clone();
3661        return ProjectionOutcome::Success {
3662            cursor: job.cursor,
3663            kind: job.kind.clone(),
3664            blob,
3665            bin_blob,
3666        };
3667    }
3668
3669    ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code }
3670}
3671
3672fn next_pending_projection_jobs(
3673    connection: &Connection,
3674    in_flight: &BTreeSet<u64>,
3675    max_jobs: usize,
3676) -> rusqlite::Result<Vec<ProjectionJob>> {
3677    if max_jobs == 0 {
3678        return Ok(Vec::new());
3679    }
3680    let cursor = load_projection_cursor(connection)?;
3681    // Over-fetch by `in_flight.len()` so the post-filter still returns
3682    // up to `max_jobs` after skipping cursors already in-flight.
3683    let sql_limit = max_jobs.saturating_add(in_flight.len()).min(256);
3684    let sql = format!(
3685        "SELECT canonical_nodes.write_cursor, canonical_nodes.kind, canonical_nodes.body
3686         FROM canonical_nodes
3687         JOIN _fathomdb_vector_kinds ON _fathomdb_vector_kinds.kind = canonical_nodes.kind
3688         LEFT JOIN _fathomdb_projection_terminal
3689           ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
3690         WHERE canonical_nodes.write_cursor > ?1
3691           AND _fathomdb_projection_terminal.write_cursor IS NULL
3692         ORDER BY canonical_nodes.write_cursor
3693         LIMIT {sql_limit}"
3694    );
3695    let mut statement = connection.prepare_cached(&sql)?;
3696    let rows = statement.query_map([cursor], |row| {
3697        Ok(ProjectionJob { cursor: row.get(0)?, kind: row.get(1)?, body: row.get(2)? })
3698    })?;
3699    let mut jobs = Vec::with_capacity(max_jobs);
3700    for row in rows {
3701        let job = row?;
3702        if in_flight.contains(&job.cursor) {
3703            continue;
3704        }
3705        jobs.push(job);
3706        if jobs.len() >= max_jobs {
3707            break;
3708        }
3709    }
3710    Ok(jobs)
3711}
3712
3713fn database_has_pending_projection_work(path: &Path) -> rusqlite::Result<bool> {
3714    let connection = open_runtime_connection(path)?;
3715    let cursor = load_projection_cursor(&connection)?;
3716    connection
3717        .query_row(
3718            "SELECT 1
3719             FROM canonical_nodes
3720             JOIN _fathomdb_vector_kinds ON _fathomdb_vector_kinds.kind = canonical_nodes.kind
3721             LEFT JOIN _fathomdb_projection_terminal
3722               ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
3723             WHERE canonical_nodes.write_cursor > ?1
3724               AND _fathomdb_projection_terminal.write_cursor IS NULL
3725             LIMIT 1",
3726            [cursor],
3727            |_row| Ok(true),
3728        )
3729        .or_else(|err| match err {
3730            rusqlite::Error::QueryReturnedNoRows => Ok(false),
3731            _ => Err(err),
3732        })
3733}
3734
3735struct CanonicalNodeRow {
3736    cursor: u64,
3737    kind: String,
3738    body: String,
3739}
3740
3741fn canonical_node_rows(connection: &Connection) -> rusqlite::Result<Vec<CanonicalNodeRow>> {
3742    let mut statement = connection
3743        .prepare("SELECT write_cursor, kind, body FROM canonical_nodes ORDER BY write_cursor")?;
3744    let rows = statement.query_map([], |row| {
3745        Ok(CanonicalNodeRow {
3746            cursor: row.get::<_, u64>(0)?,
3747            kind: row.get::<_, String>(1)?,
3748            body: row.get::<_, String>(2)?,
3749        })
3750    })?;
3751    rows.collect()
3752}
3753
3754fn hex_encode(bytes: &[u8]) -> String {
3755    let mut out = String::with_capacity(bytes.len() * 2);
3756    for byte in bytes {
3757        out.push(hex_nibble(byte >> 4));
3758        out.push(hex_nibble(byte & 0x0f));
3759    }
3760    out
3761}
3762
3763fn hex_nibble(value: u8) -> char {
3764    match value {
3765        0..=9 => (b'0' + value) as char,
3766        10..=15 => (b'a' + value - 10) as char,
3767        _ => unreachable!(),
3768    }
3769}
3770
3771fn physical_section(connection: &Connection, full: bool) -> Section {
3772    let mut findings = Vec::new();
3773    if let Err(err) = connection.query_row("PRAGMA page_count", [], |row| row.get::<_, i64>(0)) {
3774        findings.push(Finding {
3775            code: "E_CORRUPT_HEADER",
3776            stage: "PhysicalProbe",
3777            locator: locator_from_rusqlite_error(&err),
3778            doc_anchor: "design/recovery.md#header-malformed",
3779            detail: format!("page_count probe failed: {err}"),
3780        });
3781    }
3782    if full {
3783        match collect_integrity_check_findings(connection) {
3784            Ok(rows) => findings.extend(rows),
3785            Err(err) => findings.push(Finding {
3786                code: "E_CORRUPT_INTEGRITY_CHECK",
3787                stage: "IntegrityCheck",
3788                locator: locator_from_rusqlite_error(&err),
3789                doc_anchor: "design/recovery.md#integrity-check-full-findings",
3790                detail: format!("PRAGMA integrity_check failed: {err}"),
3791            }),
3792        }
3793    }
3794    if findings.is_empty() {
3795        Section::Clean
3796    } else {
3797        Section::Findings(findings)
3798    }
3799}
3800
3801fn logical_section(connection: &Connection) -> Section {
3802    let mut findings = Vec::new();
3803    if let Err(err) = connection.query_row("PRAGMA schema_version", [], |row| row.get::<_, i64>(0))
3804    {
3805        findings.push(Finding {
3806            code: "E_CORRUPT_SCHEMA",
3807            stage: "SchemaProbe",
3808            locator: locator_from_rusqlite_error(&err),
3809            doc_anchor: "design/recovery.md#schema-inconsistent",
3810            detail: format!("schema_version probe failed: {err}"),
3811        });
3812    }
3813    match connection.query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)) {
3814        Ok(0) => findings.push(Finding {
3815            code: "E_CORRUPT_SCHEMA",
3816            stage: "SchemaProbe",
3817            locator: CorruptionLocator::MigrationStep { from: 0, to: 0 },
3818            doc_anchor: "design/recovery.md#schema-inconsistent",
3819            detail: "user_version is zero".to_string(),
3820        }),
3821        Ok(_) => {}
3822        Err(err) => findings.push(Finding {
3823            code: "E_CORRUPT_SCHEMA",
3824            stage: "SchemaProbe",
3825            locator: locator_from_rusqlite_error(&err),
3826            doc_anchor: "design/recovery.md#schema-inconsistent",
3827            detail: format!("user_version probe failed: {err}"),
3828        }),
3829    }
3830    if findings.is_empty() {
3831        Section::Clean
3832    } else {
3833        Section::Findings(findings)
3834    }
3835}
3836
3837fn semantic_section(connection: &Connection) -> Section {
3838    match load_default_profile(connection) {
3839        Ok(_) => Section::Clean,
3840        Err(rusqlite::Error::QueryReturnedNoRows) => Section::Findings(vec![Finding {
3841            code: "E_CORRUPT_EMBEDDER_IDENTITY",
3842            stage: "EmbedderIdentity",
3843            locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
3844            doc_anchor: "design/recovery.md#embedder-identity-drift",
3845            detail: "default embedder profile row is missing".to_string(),
3846        }]),
3847        Err(err) => Section::Findings(vec![Finding {
3848            code: "E_CORRUPT_EMBEDDER_IDENTITY",
3849            stage: "EmbedderIdentity",
3850            locator: locator_from_rusqlite_error(&err),
3851            doc_anchor: "design/recovery.md#embedder-identity-drift",
3852            detail: format!("default embedder profile probe failed: {err}"),
3853        }]),
3854    }
3855}
3856
3857fn collect_integrity_check_findings(connection: &Connection) -> rusqlite::Result<Vec<Finding>> {
3858    let mut statement = connection.prepare("PRAGMA integrity_check")?;
3859    let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
3860    let mut findings = Vec::new();
3861    for row in rows {
3862        let message = row?;
3863        if message == "ok" {
3864            continue;
3865        }
3866        findings.push(Finding {
3867            code: "E_CORRUPT_INTEGRITY_CHECK",
3868            stage: "IntegrityCheck",
3869            locator: CorruptionLocator::OpaqueSqliteError {
3870                sqlite_extended_code: rusqlite::ffi::SQLITE_CORRUPT,
3871            },
3872            doc_anchor: "design/recovery.md#integrity-check-full-findings",
3873            detail: message,
3874        });
3875    }
3876    Ok(findings)
3877}
3878
3879fn locator_from_rusqlite_error(err: &rusqlite::Error) -> CorruptionLocator {
3880    let extended = err.sqlite_error().map(|inner| inner.extended_code).unwrap_or(0);
3881    CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: extended }
3882}
3883
3884fn open_runtime_connection(path: &Path) -> rusqlite::Result<Connection> {
3885    let connection = Connection::open(path)?;
3886    connection.pragma_update(None, "journal_mode", "WAL")?;
3887    Ok(connection)
3888}
3889
3890fn load_projection_cursor(connection: &Connection) -> rusqlite::Result<u64> {
3891    connection
3892        .query_row(
3893            "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
3894            [PROJECTION_CURSOR_KEY],
3895            |row| row.get::<_, String>(0),
3896        )
3897        .map(|value| value.parse::<u64>().unwrap_or(0))
3898        .or_else(|err| match err {
3899            rusqlite::Error::QueryReturnedNoRows => Ok(0),
3900            _ => Err(err),
3901        })
3902}
3903
3904fn store_projection_cursor(connection: &Connection, cursor: u64) -> rusqlite::Result<()> {
3905    connection.execute(
3906        "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
3907         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
3908        params![PROJECTION_CURSOR_KEY, cursor.to_string()],
3909    )?;
3910    Ok(())
3911}
3912
3913fn record_projection_terminal(
3914    connection: &Connection,
3915    cursor: u64,
3916    state: &str,
3917) -> rusqlite::Result<()> {
3918    connection.execute(
3919        "INSERT OR IGNORE INTO _fathomdb_projection_terminal(write_cursor, state) VALUES(?1, ?2)",
3920        params![cursor, state],
3921    )?;
3922    Ok(())
3923}
3924
3925fn terminal_state_for_cursor(
3926    connection: &Connection,
3927    cursor: u64,
3928) -> rusqlite::Result<Option<String>> {
3929    connection
3930        .query_row(
3931            "SELECT state FROM _fathomdb_projection_terminal WHERE write_cursor = ?1",
3932            [cursor],
3933            |row| row.get::<_, String>(0),
3934        )
3935        .map(Some)
3936        .or_else(|err| match err {
3937            rusqlite::Error::QueryReturnedNoRows => Ok(None),
3938            _ => Err(err),
3939        })
3940}
3941
3942fn advance_projection_cursor(connection: &Connection) -> rusqlite::Result<u64> {
3943    let mut cursor = load_projection_cursor(connection)?;
3944    loop {
3945        let next = cursor.saturating_add(1);
3946        if terminal_state_for_cursor(connection, next)?.is_some() {
3947            cursor = next;
3948        } else {
3949            break;
3950        }
3951    }
3952    store_projection_cursor(connection, cursor)?;
3953    Ok(cursor)
3954}
3955
3956fn commit_projection_outcomes(
3957    connection: &mut Connection,
3958    outcomes: &[ProjectionOutcome],
3959    shared: &ProjectionRuntimeShared,
3960) -> rusqlite::Result<()> {
3961    let embedder_identity = &shared.embedder_identity;
3962    let mc = identity_requires_mean_centering(embedder_identity);
3963    // EU-5f — serialize the whole commit across workers so the at-pin
3964    // re-quantize sees a totally-ordered history (see `commit_gate`).
3965    let _gate = shared.commit_gate.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
3966    let tx = connection.transaction()?;
3967    // EU-5a2/EU-5f — the live pinned mean. Read once at the top; may pin
3968    // mid-batch (set to `Some` after a threshold-crossing row below).
3969    let mut current_mean: Option<Vec<f32>> = if mc {
3970        tx.query_row(
3971            "SELECT mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
3972            [],
3973            |row| row.get::<_, Option<Vec<u8>>>(0),
3974        )
3975        .ok()
3976        .flatten()
3977        .map(|bytes| decode_vector_blob(&bytes))
3978    } else {
3979        None
3980    };
3981    let mut staged_events: Vec<EmbedderEvent> = Vec::new();
3982    for outcome in outcomes {
3983        match outcome {
3984            ProjectionOutcome::Success { cursor, kind, blob, bin_blob } => {
3985                if terminal_state_for_cursor(&tx, *cursor)?.is_some() {
3986                    continue;
3987                }
3988                // EU-5f — feed the streaming accumulator and decide the pin
3989                // atomically under the accumulator lock (add -> count ->
3990                // take), so exactly one row/worker can cross the threshold.
3991                // Only while MC-required and not yet pinned.
3992                let pin_mean: Option<Vec<f32>> = if mc && current_mean.is_none() {
3993                    let mut acc = shared.mean_accumulator.lock().unwrap_or_else(|p| p.into_inner());
3994                    match acc.as_mut() {
3995                        Some(a) => {
3996                            a.add(&decode_vector_blob(bin_blob));
3997                            if a.count() >= MEAN_VEC_PIN_THRESHOLD {
3998                                let mean = a.materialize();
3999                                *acc = None;
4000                                Some(mean)
4001                            } else {
4002                                None
4003                            }
4004                        }
4005                        None => None,
4006                    }
4007                } else {
4008                    None
4009                };
4010
4011                let source_type = resolve_source_type(kind).map_err(|_| {
4012                    rusqlite::Error::SqliteFailure(
4013                        rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
4014                        Some(format!("unknown kind for source_type mapping: {kind}")),
4015                    )
4016                })?;
4017                let now_unix =
4018                    SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
4019                        as i64;
4020                tx.execute(
4021                    "INSERT OR IGNORE INTO _fathomdb_vector_rows(rowid, kind, write_cursor) VALUES(?1, ?2, ?3)",
4022                    params![cursor, kind, cursor],
4023                )?;
4024                // EU-5a2/EU-5f — sign-quant input is the mean-subtracted
4025                // vector iff a mean is live (`current_mean`); otherwise the
4026                // un-centered `bin_blob`. A row inserted just before the
4027                // crossing is centered retroactively by the re-quantize
4028                // pass below.
4029                let centered_blob: Vec<u8> = match &current_mean {
4030                    Some(mean) if mean.len() * 4 == bin_blob.len() => {
4031                        encode_vector_blob(&subtract_mean(&decode_vector_blob(bin_blob), mean))
4032                    }
4033                    _ => bin_blob.clone(),
4034                };
4035                tx.execute(
4036                    "INSERT OR IGNORE INTO vector_default(
4037                        rowid, embedding, embedding_bin, source_type, kind, created_at
4038                     ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6)",
4039                    params![cursor, blob, centered_blob, source_type, kind, now_unix],
4040                )?;
4041                record_projection_terminal(&tx, *cursor, "up_to_date")?;
4042
4043                // EU-5f — this row crossed the threshold: pin the mean and
4044                // re-quantize every row written so far (incl. earlier rows
4045                // in this same tx, which are visible to the SELECT) within
4046                // the same transaction so the pin is atomic.
4047                if let Some(mean) = pin_mean {
4048                    tx.execute(
4049                        "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
4050                        params![encode_vector_blob(&mean)],
4051                    )?;
4052                    let rows: Vec<(i64, Vec<u8>)> = {
4053                        let mut statement = tx.prepare(
4054                            "SELECT rowid, embedding FROM vector_default ORDER BY rowid",
4055                        )?;
4056                        let mapped = statement.query_map([], |row| {
4057                            Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?))
4058                        })?;
4059                        let mut out = Vec::new();
4060                        for r in mapped {
4061                            out.push(r?);
4062                        }
4063                        out
4064                    };
4065                    let (doc_count, _) =
4066                        run_pin_and_requantize_pass(&tx, &rows, &mean).map_err(|_| {
4067                            rusqlite::Error::SqliteFailure(
4068                                rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
4069                                Some("mean-centering re-quantize pass failed".to_string()),
4070                            )
4071                        })?;
4072                    staged_events.push(EmbedderEvent::MeanVecPinned {
4073                        dim: u32::try_from(mean.len()).unwrap_or(u32::MAX),
4074                        doc_count,
4075                    });
4076                    current_mean = Some(mean);
4077                }
4078            }
4079            ProjectionOutcome::Failure { cursor, failure_code } => {
4080                if terminal_state_for_cursor(&tx, *cursor)?.is_some() {
4081                    continue;
4082                }
4083                let existing: u64 = tx.query_row(
4084                    "SELECT COUNT(*) FROM operational_mutations
4085                     WHERE collection_name = 'projection_failures'
4086                       AND json_extract(payload_json, '$.write_cursor') = ?1",
4087                    [cursor],
4088                    |row| row.get(0),
4089                )?;
4090                if existing == 0 {
4091                    let payload = format!(
4092                        r#"{{"write_cursor":{cursor},"failure_code":"{failure_code}","recorded_at":0}}"#
4093                    );
4094                    tx.execute(
4095                        "INSERT INTO operational_mutations(
4096                            collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
4097                         ) VALUES('projection_failures', ?1, 'append', ?2, NULL, ?3)",
4098                        params![cursor.to_string(), payload, cursor],
4099                    )?;
4100                }
4101                record_projection_terminal(&tx, *cursor, "failed")?;
4102            }
4103        }
4104    }
4105    // 0.7.2 PR-2bc S2 — the AUTOMATIC in-ingest drift detector (EWMA recent
4106    // mean + cos-threshold + debounce + 200k cap + `MeanRecomputeDeferred`)
4107    // was CARVED OUT and DEFERRED to 0.8.x; its recall premise was refuted
4108    // (the mean is a non-lever) and the benefit is unmeasured. The mean is
4109    // refreshed only on demand via `Engine::recompute_mean` (the
4110    // `doctor recompute-mean` verb). See `dev/design/embedder.md` §0.3 and
4111    // `dev/plans/prompts/0.8.x-auto-mean-drift-DEFERRED.md`. Nothing here
4112    // mutates `mean_vec` after the initial pin.
4113
4114    advance_projection_cursor(&tx)?;
4115    tx.commit()?;
4116    // EU-5f — publish MeanVecPinned only after the pin tx is durable, so a
4117    // rolled-back pin never emits a spurious event.
4118    if !staged_events.is_empty() {
4119        if let Ok(mut events) = shared.pending_events.lock() {
4120            events.extend(staged_events);
4121        }
4122    }
4123    Ok(())
4124}
4125
4126/// EU-5f — open-time recovery pin (`dev/design/embedder.md` §0.3, Hazard 4).
4127/// Derives the corpus mean from the existing un-centered `vector_default`
4128/// rows, pins it, and re-quantizes every row, all in one transaction on the
4129/// single-threaded open connection (no workers running yet, so no gate is
4130/// needed). Called only when MC is required, no mean is pinned, and the row
4131/// count already meets the threshold.
4132fn recover_mean_vec_pin(
4133    connection: &mut Connection,
4134    identity: &EmbedderIdentity,
4135) -> Result<(), EngineError> {
4136    let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
4137    recompute_mean_in_tx(&tx, identity)?;
4138    tx.commit().map_err(|_| EngineError::Storage)?;
4139    Ok(())
4140}
4141
4142/// 0.7.2 PR-2b — shared mean (re)compute core, run INSIDE the caller's
4143/// transaction. Derives the FULL-corpus mean from the un-centered
4144/// `vector_default.embedding` BLOBs, writes `mean_vec`, and re-quantizes
4145/// EVERY row via the existing [`run_pin_and_requantize_pass`] so no row is
4146/// left under a stale centering.
4147///
4148/// This generalizes the EU-5f open-time recovery pin: it has NO "no mean
4149/// pinned yet" guard, so it equally serves the FIRST pin (recovery) and a
4150/// REFRESH of an already-pinned mean (PR-2b drift / `doctor recompute-mean`).
4151/// The caller owns the transaction boundary, which is what makes a fault
4152/// between the `mean_vec` UPDATE and re-quantize completion roll back
4153/// wholesale (`dev/design/embedder.md` §0.5 atomicity). It does NOT publish
4154/// any event — that is the caller's job, strictly post-durable-commit.
4155fn recompute_mean_in_tx(
4156    tx: &rusqlite::Transaction<'_>,
4157    identity: &EmbedderIdentity,
4158) -> Result<MeanRecomputeReport, EngineError> {
4159    recompute_mean_in_tx_inner(tx, identity, false)
4160}
4161
4162/// 0.7.2 PR-2b — recompute core with an optional fault-injection point. The
4163/// `fail_after_mean_update` flag (debug builds only, set via a test seam)
4164/// errors AFTER the `mean_vec` UPDATE but BEFORE the re-quantize completes,
4165/// so the caller's tx rolls back the partial recentering.
4166fn recompute_mean_in_tx_inner(
4167    tx: &rusqlite::Transaction<'_>,
4168    identity: &EmbedderIdentity,
4169    fail_after_mean_update: bool,
4170) -> Result<MeanRecomputeReport, EngineError> {
4171    let started = Instant::now();
4172    let dim = identity.dimension as usize;
4173    // The previously-pinned mean (if any) is read first so we can report
4174    // the pre-recompute drift cosine.
4175    let old_mean = read_pinned_mean_vec(tx, identity.dimension)?;
4176    let rows: Vec<(i64, Vec<u8>)> = {
4177        let mut statement = tx
4178            .prepare("SELECT rowid, embedding FROM vector_default ORDER BY rowid")
4179            .map_err(|_| EngineError::Storage)?;
4180        let mapped = statement
4181            .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)))
4182            .map_err(|_| EngineError::Storage)?;
4183        let mut out = Vec::new();
4184        for r in mapped {
4185            out.push(r.map_err(|_| EngineError::Storage)?);
4186        }
4187        out
4188    };
4189    let mut accumulator = MeanAccumulator::new(dim);
4190    for (_rowid, blob) in &rows {
4191        if blob.len() != dim * 4 {
4192            return Err(EngineError::Storage);
4193        }
4194        accumulator.add(&decode_vector_blob(blob));
4195    }
4196    let old_doc_count = accumulator.count();
4197    let mean = accumulator.materialize();
4198    let drift_cos_before = match &old_mean {
4199        Some(old) => cosine_similarity(&mean, old),
4200        None => 1.0,
4201    };
4202    tx.execute(
4203        "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
4204        params![encode_vector_blob(&mean)],
4205    )
4206    .map_err(|_| EngineError::Storage)?;
4207    if fail_after_mean_update {
4208        // Injected fault: bail before re-quantizing so the caller's tx
4209        // rolls back the `mean_vec` UPDATE too (crash-atomicity proof).
4210        return Err(EngineError::Storage);
4211    }
4212    let (doc_count, _) = run_pin_and_requantize_pass(tx, &rows, &mean)?;
4213    Ok(MeanRecomputeReport {
4214        dim: u32::try_from(dim).unwrap_or(u32::MAX),
4215        old_doc_count,
4216        doc_count_requantized: doc_count,
4217        drift_cos_before,
4218        mean_was_pinned: old_mean.is_some(),
4219        elapsed_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
4220    })
4221}
4222
4223fn enforce_provenance_retention(connection: &Connection, cap: u64) -> rusqlite::Result<()> {
4224    if cap == 0 {
4225        return Ok(());
4226    }
4227    let slack = cap.max(20) / 20;
4228    let upper = cap.saturating_add(slack.max(1));
4229    let count: u64 =
4230        connection.query_row("SELECT COUNT(*) FROM operational_mutations", [], |row| row.get(0))?;
4231    if count <= upper {
4232        return Ok(());
4233    }
4234    let to_delete = count.saturating_sub(cap);
4235    connection.execute(
4236        "DELETE FROM operational_mutations
4237         WHERE id IN (
4238             SELECT id FROM operational_mutations
4239             ORDER BY id
4240             LIMIT ?1
4241         )",
4242        [to_delete],
4243    )?;
4244    Ok(())
4245}
4246
4247fn projection_status(
4248    connection: &Connection,
4249    kind: &str,
4250) -> Result<lifecycle::ProjectionStatus, EngineError> {
4251    let latest = connection
4252        .query_row(
4253            "SELECT COALESCE(MAX(write_cursor), 0) FROM canonical_nodes WHERE kind = ?1",
4254            [kind],
4255            |row| row.get::<_, u64>(0),
4256        )
4257        .map_err(|_| EngineError::Storage)?;
4258    if latest == 0 {
4259        return Ok(lifecycle::ProjectionStatus::UpToDate);
4260    }
4261    let pending: u64 = connection
4262        .query_row(
4263            "SELECT COUNT(*)
4264             FROM canonical_nodes
4265             LEFT JOIN _fathomdb_projection_terminal
4266               ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
4267             WHERE canonical_nodes.kind = ?1
4268               AND _fathomdb_projection_terminal.write_cursor IS NULL",
4269            [kind],
4270            |row| row.get(0),
4271        )
4272        .map_err(|_| EngineError::Storage)?;
4273    if pending > 0 {
4274        return Ok(lifecycle::ProjectionStatus::Pending);
4275    }
4276    match terminal_state_for_cursor(connection, latest).map_err(|_| EngineError::Storage)? {
4277        Some(state) if state == "failed" => Ok(lifecycle::ProjectionStatus::Failed),
4278        _ => Ok(lifecycle::ProjectionStatus::UpToDate),
4279    }
4280}
4281
4282fn canonical_database_path(path: &Path) -> Result<PathBuf, EngineOpenError> {
4283    let parent = path
4284        .parent()
4285        .filter(|parent| !parent.as_os_str().is_empty())
4286        .unwrap_or_else(|| Path::new("."));
4287    let canonical_parent = parent.canonicalize().map_err(|_| EngineOpenError::Io {
4288        message: "database parent directory is not accessible".to_string(),
4289    })?;
4290    let file_name = path.file_name().ok_or_else(|| EngineOpenError::Io {
4291        message: "database path has no file name".to_string(),
4292    })?;
4293
4294    Ok(canonical_parent.join(file_name))
4295}
4296
4297fn acquire_lock(path: &Path) -> Result<File, EngineOpenError> {
4298    let lock_path = lock_path(path);
4299    let mut options = OpenOptions::new();
4300    options.read(true).write(true).create(true);
4301    #[cfg(unix)]
4302    options.mode(0o600);
4303
4304    let mut file = options.open(&lock_path).map_err(|_| EngineOpenError::Io {
4305        message: "could not open database lock file".to_string(),
4306    })?;
4307
4308    match file.try_lock() {
4309        Ok(()) => {
4310            let pid = std::process::id().to_string();
4311            let _ = file.set_len(0);
4312            let _ = file.seek(SeekFrom::Start(0));
4313            let _ = file.write_all(pid.as_bytes());
4314            Ok(file)
4315        }
4316        Err(std::fs::TryLockError::WouldBlock) => {
4317            Err(EngineOpenError::DatabaseLocked { holder_pid: read_holder_pid(&lock_path) })
4318        }
4319        Err(_) => {
4320            Err(EngineOpenError::Io { message: "could not acquire database lock".to_string() })
4321        }
4322    }
4323}
4324
4325fn lock_path(path: &Path) -> PathBuf {
4326    let mut lock_path = path.as_os_str().to_os_string();
4327    lock_path.push(LOCK_SUFFIX);
4328    PathBuf::from(lock_path)
4329}
4330
4331fn read_holder_pid(path: &Path) -> Option<u32> {
4332    std::fs::read_to_string(path).ok()?.trim().parse().ok()
4333}
4334
4335fn map_migration_error(err: SchemaMigrationError) -> EngineOpenError {
4336    match err {
4337        SchemaMigrationError::IncompatibleSchemaVersion { seen, supported } => {
4338            EngineOpenError::IncompatibleSchemaVersion { seen, supported }
4339        }
4340        SchemaMigrationError::MigrationError(report) => EngineOpenError::MigrationError {
4341            schema_version_before: report.schema_version_before,
4342            schema_version_current: report.schema_version_current,
4343            step_id: report.migration_steps.last().map_or(0, |step| step.step_id),
4344        },
4345        SchemaMigrationError::Storage { message } => {
4346            EngineOpenError::Io { message: message.to_string() }
4347        }
4348    }
4349}
4350
4351/// 0.7.0 perf-experiments hook: process-start `sqlite3_config` calls.
4352/// Runs exactly once per process; must precede any `Connection::open`.
4353/// Gated on `FATHOMDB_PERF_EXPERIMENTS=1`. Each individual config
4354/// option is opt-in via its own env var so unrelated experiments do
4355/// not implicitly co-fire.
4356///
4357/// Currently supports:
4358/// - `FATHOMDB_PERF_SQLITE_MEMSTATUS_OFF=1`:
4359///   `sqlite3_config(SQLITE_CONFIG_MEMSTATUS, 0)` — drops the
4360///   allocator stats locking surface (whitepaper § 7.4). Composes
4361///   with other levers; small payoff alone.
4362///
4363/// Pattern: shutdown → config → initialize, mirroring B.1 attempt #2
4364/// (`d448263`, reverted). The captured rc for each config call is
4365/// logged to stderr so experiments can verify the call took effect.
4366fn init_perf_experiments_runtime() {
4367    static INIT: Once = Once::new();
4368    INIT.call_once(|| {
4369        if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
4370            return;
4371        }
4372        let memstatus_off =
4373            std::env::var_os("FATHOMDB_PERF_SQLITE_MEMSTATUS_OFF").is_some_and(|v| v == "1");
4374        // FATHOMDB_PERF_SQLITE_PAGECACHE=<page_size_bytes>:<page_count>
4375        // E.g. "4096:5000" => pre-allocate 4096 B × 5000 pages = 20 MB
4376        // global page-cache backing. SQLite distributes this across
4377        // connections; reduces global allocator pressure for page
4378        // cache fills.
4379        let pagecache = std::env::var("FATHOMDB_PERF_SQLITE_PAGECACHE").ok();
4380        // FATHOMDB_PERF_SQLITE_PCACHE2=1 installs the per-instance
4381        // custom page-cache allocator (pcache2.rs). Targets AC-020
4382        // residual contention on the default pcache1 mutex.
4383        let pcache2_on =
4384            std::env::var_os("FATHOMDB_PERF_SQLITE_PCACHE2").is_some_and(|v| v == "1");
4385        if !memstatus_off && pagecache.is_none() && !pcache2_on {
4386            return;
4387        }
4388        // SAFETY: sqlite3_shutdown / sqlite3_initialize are documented
4389        // as safe to call before any other SQLite API; sqlite3_config
4390        // must be called between shutdown and initialize. We pre-empt
4391        // rusqlite's lazy first-call sqlite3_initialize via this
4392        // explicit shutdown-then-config-then-initialize sequence,
4393        // identical to B.1 attempt #2's plumbing.
4394        unsafe {
4395            let rc_shutdown = rusqlite::ffi::sqlite3_shutdown();
4396            let rc_memstatus = if memstatus_off {
4397                rusqlite::ffi::sqlite3_config(rusqlite::ffi::SQLITE_CONFIG_MEMSTATUS, 0_i32)
4398            } else {
4399                -1
4400            };
4401            // SQLITE_CONFIG_PAGECACHE = 7 per sqlite3.h. With buffer=NULL,
4402            // SQLite allocates the backing memory itself but still
4403            // partitions it for use as the page-cache pool.
4404            let rc_pagecache = if let Some(spec) = pagecache.as_ref() {
4405                let mut parts = spec.split(':');
4406                let sz = parts.next().and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
4407                let n = parts.next().and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
4408                if sz > 0 && n > 0 {
4409                    rusqlite::ffi::sqlite3_config(
4410                        7, // SQLITE_CONFIG_PAGECACHE
4411                        std::ptr::null_mut::<std::ffi::c_void>(),
4412                        sz,
4413                        n,
4414                    )
4415                } else {
4416                    eprintln!(
4417                        "perf-experiment: bad FATHOMDB_PERF_SQLITE_PAGECACHE spec '{spec}' (expect '<bytes>:<count>')"
4418                    );
4419                    -1
4420                }
4421            } else {
4422                -1
4423            };
4424            let rc_pcache2 = if pcache2_on {
4425                // SQLITE_CONFIG_PCACHE2 = 18 per sqlite3.h. The methods
4426                // table must outlive the SQLite engine; we pass a
4427                // pointer to our static.
4428                rusqlite::ffi::sqlite3_config(
4429                    rusqlite::ffi::SQLITE_CONFIG_PCACHE2,
4430                    &raw const pcache2::PCACHE2_METHODS.0,
4431                )
4432            } else {
4433                -1
4434            };
4435            let rc_init = rusqlite::ffi::sqlite3_initialize();
4436            eprintln!(
4437                "perf-experiment: runtime-config rcs shutdown={rc_shutdown} \
4438                 memstatus={rc_memstatus} pagecache={rc_pagecache} pcache2={rc_pcache2} \
4439                 initialize={rc_init} (0=SQLITE_OK; 21=SQLITE_MISUSE; -1=not configured)"
4440            );
4441        }
4442    });
4443}
4444
4445fn register_sqlite_vec_extension() {
4446    static REGISTER: Once = Once::new();
4447    REGISTER.call_once(|| unsafe {
4448        let entrypoint: unsafe extern "C" fn(
4449            *mut rusqlite::ffi::sqlite3,
4450            *mut *const std::os::raw::c_char,
4451            *const rusqlite::ffi::sqlite3_api_routines,
4452        ) -> std::os::raw::c_int = std::mem::transmute(sqlite3_vec_init as *const ());
4453        rusqlite::ffi::sqlite3_auto_extension(Some(entrypoint));
4454    });
4455}
4456
4457fn probe_open_integrity(connection: &Connection) -> Result<(), EngineOpenError> {
4458    // `SELECT COUNT(*) FROM sqlite_schema` forces a full traversal of the
4459    // sqlite_schema b-tree; this surfaces page-1 b-tree corruption that a
4460    // bare `PRAGMA schema_version` (which only reads the schema cookie
4461    // out of the file header) would miss.
4462    connection
4463        .query_row("SELECT COUNT(*) FROM sqlite_schema", [], |row| row.get::<_, i64>(0))
4464        .map(|_| ())
4465        .map_err(|err| map_open_sqlite_error(err, OpenStage::SchemaProbe))
4466}
4467
4468fn probe_database_header(connection: &Connection) -> Result<(), EngineOpenError> {
4469    connection
4470        .query_row("PRAGMA application_id", [], |row| row.get::<_, i64>(0))
4471        .map(|_| ())
4472        .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))
4473}
4474
4475/// Pre-`pragma WAL` sidecar validation. SQLite silently discards a WAL
4476/// file whose header magic is wrong or whose advertised page size is
4477/// outside `[512, SQLITE_MAX_PAGE_SIZE]`, which would cause us to lose
4478/// committed frames at open time. AC-035a requires that we instead
4479/// refuse to open with `Corruption(WalReplayFailure)` rather than
4480/// silently rebuild from a truncated WAL.
4481fn probe_wal_sidecar(db_path: &Path) -> Result<(), EngineOpenError> {
4482    let mut wal_path = db_path.as_os_str().to_owned();
4483    wal_path.push("-wal");
4484    let wal_path = PathBuf::from(wal_path);
4485    // Bounded read: the WAL header is fixed-layout in the first 32
4486    // bytes (magic + format + page-size + checkpoint-seq + salts +
4487    // checksums); frame data starts at offset 32 and is irrelevant to
4488    // the magic + page-size pre-check. A `std::fs::read` of the whole
4489    // sidecar would force an unclean-shutdown open path to allocate
4490    // and copy the entire WAL into memory before SQLite touches
4491    // recovery — a real latency + RSS regression on AC-035.
4492    use std::io::Read;
4493    let mut file = match std::fs::File::open(&wal_path) {
4494        Ok(file) => file,
4495        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
4496        Err(_) => return Ok(()),
4497    };
4498    let mut bytes = [0u8; 32];
4499    if file.read_exact(&mut bytes).is_err() {
4500        // A short (< 32-byte) sidecar carries no committed frames;
4501        // SQLite treats it as empty and re-initializes WAL state.
4502        return Ok(());
4503    }
4504    let magic = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
4505    let page_size = u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
4506    // WAL_MAGIC mask per SQLite `walIndexRecover`: low bit distinguishes
4507    // big-endian vs little-endian checksum encoding; the rest of the
4508    // magic is fixed.
4509    const WAL_MAGIC_MASK: u32 = 0xFFFF_FFFE;
4510    const WAL_MAGIC: u32 = 0x377F_0682;
4511    const SQLITE_MAX_PAGE_SIZE: u32 = 65536;
4512    let magic_ok = (magic & WAL_MAGIC_MASK) == WAL_MAGIC;
4513    let page_size_ok =
4514        page_size.is_power_of_two() && (512..=SQLITE_MAX_PAGE_SIZE).contains(&page_size);
4515    if magic_ok && page_size_ok {
4516        return Ok(());
4517    }
4518    Err(EngineOpenError::Corruption(CorruptionDetail {
4519        kind: CorruptionKind::WalReplayFailure,
4520        stage: OpenStage::WalReplay,
4521        locator: CorruptionLocator::FileOffset { offset: if !magic_ok { 0 } else { 8 } },
4522        recovery_hint: RecoveryHint {
4523            code: "E_CORRUPT_WAL_REPLAY",
4524            doc_anchor: "design/recovery.md#wal-replay-failures",
4525        },
4526    }))
4527}
4528
4529fn reject_legacy_shape(connection: &Connection) -> Result<(), EngineOpenError> {
4530    let has_legacy_table = table_exists(connection, "fathom_nodes")
4531        || table_exists(connection, "fathom_edges")
4532        || table_exists(connection, "fathom_chunks");
4533    if !has_legacy_table {
4534        return Ok(());
4535    }
4536
4537    let seen =
4538        connection.query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)).unwrap_or(0);
4539    Err(EngineOpenError::IncompatibleSchemaVersion { seen, supported: SCHEMA_VERSION })
4540}
4541
4542fn table_exists(connection: &Connection, table: &str) -> bool {
4543    connection
4544        .query_row(
4545            "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?1",
4546            [table],
4547            |_row| Ok(()),
4548        )
4549        .is_ok()
4550}
4551
4552fn read_schema_objects(
4553    connection: &Connection,
4554    obj_type: &str,
4555) -> Result<Vec<SchemaObject>, EngineError> {
4556    let mut stmt = connection
4557        .prepare(
4558            "SELECT name, sql FROM sqlite_schema
4559             WHERE type = ?1 AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL
4560             ORDER BY name",
4561        )
4562        .map_err(|_| EngineError::Storage)?;
4563    let rows = stmt
4564        .query_map([obj_type], |row| {
4565            Ok(SchemaObject { name: row.get::<_, String>(0)?, sql: row.get::<_, String>(1)? })
4566        })
4567        .map_err(|_| EngineError::Storage)?;
4568    let mut out = Vec::new();
4569    for row in rows {
4570        out.push(row.map_err(|_| EngineError::Storage)?);
4571    }
4572    Ok(out)
4573}
4574
4575fn order_canonical_first(mut objects: Vec<SchemaObject>) -> Vec<SchemaObject> {
4576    let mut canonical: Vec<SchemaObject> = Vec::new();
4577    for name in CANONICAL_TABLES {
4578        if let Some(pos) = objects.iter().position(|o| o.name == *name) {
4579            canonical.push(objects.remove(pos));
4580        }
4581    }
4582    canonical.extend(objects);
4583    canonical
4584}
4585
4586fn load_default_profile(connection: &Connection) -> rusqlite::Result<EmbedderIdentity> {
4587    connection.query_row(
4588        "SELECT name, revision, dimension FROM _fathomdb_embedder_profiles WHERE profile = ?1",
4589        [DEFAULT_VECTOR_PROFILE],
4590        |row| {
4591            Ok(EmbedderIdentity::new(
4592                row.get::<_, String>(0)?,
4593                row.get::<_, String>(1)?,
4594                row.get::<_, u32>(2)?,
4595            ))
4596        },
4597    )
4598}
4599
4600fn default_profile_dimension(connection: &Connection) -> Result<u32, EngineError> {
4601    load_default_profile(connection)
4602        .map(|identity| identity.dimension)
4603        .map_err(|_| EngineError::Storage)
4604}
4605
4606fn kind_is_vector_indexed(connection: &Connection, kind: &str) -> Result<bool, EngineError> {
4607    connection
4608        .query_row("SELECT 1 FROM _fathomdb_vector_kinds WHERE kind = ?1", [kind], |_row| Ok(()))
4609        .map(|_| true)
4610        .or_else(|err| match err {
4611            rusqlite::Error::QueryReturnedNoRows => Ok(false),
4612            _ => Err(EngineError::Storage),
4613        })
4614}
4615
4616fn ensure_vector_partition(connection: &mut Connection, dimension: u32) -> rusqlite::Result<()> {
4617    // 0.7.0 Pack 1 schema per dev/design/0.7.0-vector-quant-pack1.md D1/D2:
4618    // f32 `embedding` + binary-quant sibling `embedding_bin` + `source_type`
4619    // partition key + `kind` + `created_at`. The vec0 column type is
4620    // dim-parameterized, so the reshape lives here rather than in the
4621    // SQL-only migration framework — see fathomdb-schema migration step 9
4622    // and dev/plans/runs/0.7.0-PVQ-P1-IMPL-output.json for the deviation
4623    // from the design memo's "Choose (a)" guidance.
4624    //
4625    // Three paths:
4626    //   (1) no vector_default       -> CREATE at new shape.
4627    //   (2) old single-column shape -> stage + drop + recreate at new shape
4628    //                                  + repopulate with vec_quantize_binary.
4629    //   (3) already new shape       -> no-op.
4630    let existing_sql: Option<String> = connection
4631        .query_row(
4632            "SELECT sql FROM sqlite_master WHERE type='table' AND name=?1",
4633            [DEFAULT_VECTOR_PARTITION],
4634            |row| row.get::<_, String>(0),
4635        )
4636        .optional()?;
4637
4638    match existing_sql {
4639        None => create_vector_partition(connection, dimension),
4640        Some(sql) if sql.contains("embedding_bin") => Ok(()),
4641        Some(_) => migrate_vector_partition_to_pack1(connection, dimension),
4642    }
4643}
4644
4645fn create_vector_partition(connection: &Connection, dimension: u32) -> rusqlite::Result<()> {
4646    let sql = format!(
4647        "CREATE VIRTUAL TABLE IF NOT EXISTS {DEFAULT_VECTOR_PARTITION} USING vec0(\
4648            embedding float[{dimension}],\
4649            embedding_bin bit[{dimension}],\
4650            source_type TEXT partition key,\
4651            kind TEXT,\
4652            created_at INTEGER\
4653         )"
4654    );
4655    connection.execute_batch(&sql)
4656}
4657
4658/// SQL fragment implementing the D3 `kind -> source_type` map.
4659/// Used both by the Pack 1 reshape migration and by the drift-detection
4660/// unit test that pins it to [`resolve_source_type`].
4661const KIND_TO_SOURCE_TYPE_CASE_SQL: &str = "CASE s.kind
4662    WHEN 'email'   THEN 'email'
4663    WHEN 'article' THEN 'article'
4664    WHEN 'paper'   THEN 'paper'
4665    WHEN 'meeting' THEN 'meeting'
4666    WHEN 'note'    THEN 'note'
4667    WHEN 'todo'    THEN 'todo'
4668    WHEN 'doc'     THEN 'article'
4669    ELSE 'article'
4670END";
4671
4672/// Pack 1 in-place reshape of `vector_default`. Stages the existing
4673/// f32 corpus + each row's `kind`, drops the old single-column vec0
4674/// table, recreates at the runtime `dimension` with the Pack 1
4675/// columns, then repopulates with SQL-side `vec_quantize_binary` +
4676/// the D3 `kind -> source_type` mapping. The preflight CHECK on
4677/// unknown kinds has already run as migration step 9 by the time we
4678/// get here.
4679///
4680/// Atomicity: the DROP+CREATE+repopulate sequence runs inside a
4681/// rusqlite `Connection::transaction()` (DEFERRED begin per rusqlite
4682/// `transaction.rs:417`). Cross-process serialization is provided by
4683/// the engine's sidecar `acquire_lock` at `open_with_migrations`
4684/// (`lib.rs:1127` area); reader handles are not opened until
4685/// `ensure_vector_partition` returns (`lib.rs:1241` area), so readers
4686/// never observe a partial reshape.
4687fn migrate_vector_partition_to_pack1(
4688    connection: &mut Connection,
4689    dimension: u32,
4690) -> rusqlite::Result<()> {
4691    let tx = connection.transaction()?;
4692    tx.execute_batch(
4693        "CREATE TABLE _fathomdb_vector_migration_v0_7_0 (
4694             rowid     INTEGER PRIMARY KEY,
4695             embedding BLOB NOT NULL,
4696             kind      TEXT NOT NULL
4697         );
4698         INSERT INTO _fathomdb_vector_migration_v0_7_0(rowid, embedding, kind)
4699             SELECT v.rowid, v.embedding, r.kind
4700             FROM vector_default v
4701             JOIN _fathomdb_vector_rows r ON r.rowid = v.rowid;
4702         DROP TABLE vector_default;",
4703    )?;
4704    let create_sql = format!(
4705        "CREATE VIRTUAL TABLE {DEFAULT_VECTOR_PARTITION} USING vec0(\
4706            embedding float[{dimension}],\
4707            embedding_bin bit[{dimension}],\
4708            source_type TEXT partition key,\
4709            kind TEXT,\
4710            created_at INTEGER\
4711         )"
4712    );
4713    tx.execute_batch(&create_sql)?;
4714    let repopulate_sql = format!(
4715        "INSERT INTO vector_default(
4716             rowid, embedding, embedding_bin, source_type, kind, created_at
4717         )
4718         SELECT
4719             s.rowid,
4720             s.embedding,
4721             vec_quantize_binary(s.embedding),
4722             {KIND_TO_SOURCE_TYPE_CASE_SQL},
4723             s.kind,
4724             strftime('%s', 'now')
4725         FROM _fathomdb_vector_migration_v0_7_0 s;
4726         DROP TABLE _fathomdb_vector_migration_v0_7_0;"
4727    );
4728    tx.execute_batch(&repopulate_sql)?;
4729    tx.commit()
4730}
4731
4732fn encode_vector_blob(vector: &[f32]) -> Vec<u8> {
4733    vector.iter().flat_map(|value| value.to_le_bytes()).collect()
4734}
4735
4736fn decode_vector_blob(bytes: &[u8]) -> Vec<f32> {
4737    debug_assert_eq!(bytes.len() % 4, 0, "f32 BLOB length must be multiple of 4");
4738    bytes.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()
4739}
4740
4741/// EU-5a2 — does the live embedder identity request mean-centering?
4742/// Identity-name compare per EU-5a1's BGE_SMALL_EMBEDDER_NAME constant
4743/// (`dev/design/embedder.md` §0.6). NoopEmbedder returns `false`.
4744fn identity_requires_mean_centering(identity: &EmbedderIdentity) -> bool {
4745    identity.name == BGE_SMALL_EMBEDDER_NAME
4746}
4747
4748/// EU-5a2 — read the pinned mean vector from
4749/// `_fathomdb_embedder_profiles.mean_vec` for the default profile.
4750/// Returns `Ok(None)` when the column is NULL or the row is missing;
4751/// returns `Err(EngineError::Storage)` on dimension drift (the open-time
4752/// `check_embedder_profile` already fails closed for this, so a runtime
4753/// drift here would be an internal-inconsistency signal).
4754fn read_pinned_mean_vec(
4755    connection: &Connection,
4756    dimension: u32,
4757) -> Result<Option<Vec<f32>>, EngineError> {
4758    let bytes: Option<Vec<u8>> = connection
4759        .query_row(
4760            "SELECT mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
4761            [],
4762            |row| row.get::<_, Option<Vec<u8>>>(0),
4763        )
4764        .or_else(|err| match err {
4765            rusqlite::Error::QueryReturnedNoRows => Ok(None),
4766            other => Err(other),
4767        })
4768        .map_err(|_| EngineError::Storage)?;
4769    let Some(bytes) = bytes else { return Ok(None) };
4770    let expected_len = (dimension as usize).saturating_mul(4);
4771    if bytes.len() != expected_len {
4772        return Err(EngineError::Storage);
4773    }
4774    let mut out = Vec::with_capacity(dimension as usize);
4775    for chunk in bytes.chunks_exact(4) {
4776        let arr = [chunk[0], chunk[1], chunk[2], chunk[3]];
4777        out.push(f32::from_le_bytes(arr));
4778    }
4779    Ok(Some(out))
4780}
4781
4782/// EU-5a2 — pointwise `v - mean`. Length-checked debug-assert; caller
4783/// guarantees equal length via `read_pinned_mean_vec` + dimension check.
4784fn subtract_mean(v: &[f32], mean: &[f32]) -> Vec<f32> {
4785    debug_assert_eq!(v.len(), mean.len(), "subtract_mean dim mismatch");
4786    v.iter().zip(mean.iter()).map(|(a, b)| *a - *b).collect()
4787}
4788
4789/// Maps the writer-facing `kind` value to the locked Pack 1
4790/// `source_type` partition-key vocabulary. Must stay in lockstep with
4791/// the CASE WHEN inlined in migration step 9
4792/// (`fathomdb-schema/src/lib.rs`); the drift-detection unit test in
4793/// this module's `tests` mod enforces that. Per
4794/// `dev/design/0.7.0-vector-quant-pack1.md` D3.
4795fn resolve_source_type(kind: &str) -> Result<&'static str, EngineError> {
4796    Ok(match kind {
4797        "email" => "email",
4798        "article" => "article",
4799        "paper" => "paper",
4800        "meeting" => "meeting",
4801        "note" => "note",
4802        "todo" => "todo",
4803        // Synthetic AC-013 test fixture; coerced so the 6-value HITL lock holds.
4804        "doc" => "article",
4805        _ => return Err(EngineError::Storage),
4806    })
4807}
4808
4809fn map_runtime_embedder_error(err: RuntimeEmbedderError) -> EngineError {
4810    match err {
4811        RuntimeEmbedderError::Failed { .. } | RuntimeEmbedderError::Timeout => {
4812            EngineError::Embedder
4813        }
4814    }
4815}
4816
4817fn default_embedder_identity() -> EmbedderIdentity {
4818    EmbedderIdentity::new(
4819        DEFAULT_EMBEDDER_NAME,
4820        DEFAULT_EMBEDDER_REVISION,
4821        DEFAULT_EMBEDDER_DIMENSION,
4822    )
4823}
4824
4825fn check_embedder_profile(
4826    connection: &Connection,
4827    supplied: &EmbedderIdentity,
4828) -> Result<bool, EngineOpenError> {
4829    // Returns `true` iff `_fathomdb_embedder_profiles.mean_vec IS NOT NULL`
4830    // for the default profile (and its byte length matches `4 * dimension`
4831    // per `dev/design/embedder.md` §0.2). EU-5a2: column lands in step 10.
4832    let mut statement = match connection.prepare(
4833        "SELECT name, revision, dimension, mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
4834    ) {
4835        Ok(statement) => statement,
4836        Err(_) => return Ok(false),
4837    };
4838    let mut rows = statement.query([]).map_err(|_| {
4839        EngineOpenError::Corruption(CorruptionDetail {
4840            kind: CorruptionKind::EmbedderIdentityDrift,
4841            stage: OpenStage::EmbedderIdentity,
4842            locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
4843            recovery_hint: RecoveryHint {
4844                code: "E_CORRUPT_EMBEDDER_IDENTITY",
4845                doc_anchor: "design/recovery.md#embedder-identity-drift",
4846            },
4847        })
4848    })?;
4849
4850    let Some(row) = rows.next().map_err(|_| {
4851        EngineOpenError::Corruption(CorruptionDetail {
4852            kind: CorruptionKind::EmbedderIdentityDrift,
4853            stage: OpenStage::EmbedderIdentity,
4854            locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
4855            recovery_hint: RecoveryHint {
4856                code: "E_CORRUPT_EMBEDDER_IDENTITY",
4857                doc_anchor: "design/recovery.md#embedder-identity-drift",
4858            },
4859        })
4860    })?
4861    else {
4862        connection
4863            .execute(
4864                "INSERT INTO _fathomdb_embedder_profiles(profile, name, revision, dimension)
4865                 VALUES(?1, ?2, ?3, ?4)",
4866                params![
4867                    DEFAULT_VECTOR_PROFILE,
4868                    supplied.name,
4869                    supplied.revision,
4870                    supplied.dimension
4871                ],
4872            )
4873            .map_err(|_| EngineOpenError::Io {
4874                message: "could not persist embedder profile".to_string(),
4875            })?;
4876        return Ok(false);
4877    };
4878
4879    let stored_name = row.get::<_, String>(0).map_err(|_| {
4880        EngineOpenError::Corruption(CorruptionDetail {
4881            kind: CorruptionKind::EmbedderIdentityDrift,
4882            stage: OpenStage::EmbedderIdentity,
4883            locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
4884            recovery_hint: RecoveryHint {
4885                code: "E_CORRUPT_EMBEDDER_IDENTITY",
4886                doc_anchor: "design/recovery.md#embedder-identity-drift",
4887            },
4888        })
4889    })?;
4890    let stored_revision = row.get::<_, String>(1).map_err(|_| {
4891        EngineOpenError::Corruption(CorruptionDetail {
4892            kind: CorruptionKind::EmbedderIdentityDrift,
4893            stage: OpenStage::EmbedderIdentity,
4894            locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
4895            recovery_hint: RecoveryHint {
4896                code: "E_CORRUPT_EMBEDDER_IDENTITY",
4897                doc_anchor: "design/recovery.md#embedder-identity-drift",
4898            },
4899        })
4900    })?;
4901    let dimension = row.get::<_, u32>(2).map_err(|_| {
4902        EngineOpenError::Corruption(CorruptionDetail {
4903            kind: CorruptionKind::EmbedderIdentityDrift,
4904            stage: OpenStage::EmbedderIdentity,
4905            locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
4906            recovery_hint: RecoveryHint {
4907                code: "E_CORRUPT_EMBEDDER_IDENTITY",
4908                doc_anchor: "design/recovery.md#embedder-identity-drift",
4909            },
4910        })
4911    })?;
4912
4913    let stored = EmbedderIdentity::new(stored_name, stored_revision, dimension);
4914
4915    if stored.name != supplied.name || stored.revision != supplied.revision {
4916        return Err(EngineOpenError::EmbedderIdentityMismatch {
4917            stored,
4918            supplied: supplied.clone(),
4919        });
4920    }
4921    if dimension != supplied.dimension {
4922        return Err(EngineOpenError::EmbedderDimensionMismatch {
4923            stored: dimension,
4924            supplied: supplied.dimension,
4925        });
4926    }
4927
4928    // EU-5a2 / `dev/design/embedder.md` §0.2 invariant: if `mean_vec` is
4929    // populated, byte length MUST equal `4 * dimension`. Debug builds
4930    // assert; release builds fail closed via EmbedderIdentityMismatch
4931    // (the same fail-closed channel the rest of profile drift takes).
4932    let mean_vec: Option<Vec<u8>> = row.get::<_, Option<Vec<u8>>>(3).map_err(|_| {
4933        EngineOpenError::Corruption(CorruptionDetail {
4934            kind: CorruptionKind::EmbedderIdentityDrift,
4935            stage: OpenStage::EmbedderIdentity,
4936            locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
4937            recovery_hint: RecoveryHint {
4938                code: "E_CORRUPT_EMBEDDER_IDENTITY",
4939                doc_anchor: "design/recovery.md#embedder-identity-drift",
4940            },
4941        })
4942    })?;
4943    let pinned = match mean_vec {
4944        Some(bytes) => {
4945            let expected_len = (dimension as usize).saturating_mul(4);
4946            // `dev/design/embedder.md` §0.2 invariant: when populated,
4947            // `mean_vec` byte length MUST equal `4 * dimension`. Fail
4948            // closed via the existing identity-drift channel in both
4949            // debug and release builds — tests deliberately poke
4950            // malformed values to exercise this branch.
4951            if bytes.len() != expected_len {
4952                return Err(EngineOpenError::EmbedderIdentityMismatch {
4953                    stored,
4954                    supplied: supplied.clone(),
4955                });
4956            }
4957            true
4958        }
4959        None => false,
4960    };
4961
4962    Ok(pinned)
4963}
4964
4965#[derive(Clone, Debug, Eq, PartialEq)]
4966enum WritePlan {
4967    Node,
4968    Edge,
4969    AppendOnlyLog,
4970    LatestState,
4971    AdminSchema,
4972}
4973
4974fn validate_batch(
4975    connection: &Connection,
4976    batch: &[PreparedWrite],
4977) -> Result<Vec<WritePlan>, EngineError> {
4978    batch.iter().map(|write| validate_write(connection, write)).collect()
4979}
4980
4981fn collect_projection_jobs(
4982    connection: &Connection,
4983    batch: &[PreparedWrite],
4984) -> Result<Vec<ProjectionJob>, EngineError> {
4985    let mut jobs = Vec::new();
4986    for write in batch {
4987        if let PreparedWrite::Node { kind, body, .. } = write {
4988            if kind_is_vector_indexed(connection, kind)? {
4989                jobs.push(ProjectionJob { cursor: 0, kind: kind.clone(), body: body.clone() });
4990            }
4991        }
4992    }
4993    Ok(jobs)
4994}
4995
4996fn validate_write(
4997    connection: &Connection,
4998    write: &PreparedWrite,
4999) -> Result<WritePlan, EngineError> {
5000    match write {
5001        PreparedWrite::Node { kind, body, source_id } => {
5002            if kind.trim().is_empty() || body.trim().is_empty() {
5003                return Err(EngineError::WriteValidation);
5004            }
5005            if let Some(source_id) = source_id {
5006                if source_id.is_empty() {
5007                    return Err(EngineError::WriteValidation);
5008                }
5009            }
5010            Ok(WritePlan::Node)
5011        }
5012        PreparedWrite::Edge { kind, from, to, source_id } => {
5013            if kind.trim().is_empty() || from.trim().is_empty() || to.trim().is_empty() {
5014                return Err(EngineError::WriteValidation);
5015            }
5016            if let Some(source_id) = source_id {
5017                if source_id.is_empty() {
5018                    return Err(EngineError::WriteValidation);
5019                }
5020            }
5021            Ok(WritePlan::Edge)
5022        }
5023        PreparedWrite::AdminSchema { name, kind, schema_json, retention_json } => {
5024            if name.trim().is_empty()
5025                || !matches!(kind.as_str(), "append_only_log" | "latest_state")
5026                || serde_json::from_str::<Value>(schema_json).is_err()
5027                || serde_json::from_str::<Value>(retention_json).is_err()
5028                || contains_external_ref(schema_json)
5029            {
5030                return Err(EngineError::SchemaValidation);
5031            }
5032            Ok(WritePlan::AdminSchema)
5033        }
5034        PreparedWrite::OpStore { collection, record_key, schema_id, body } => {
5035            if collection.trim().is_empty() || record_key.trim().is_empty() {
5036                return Err(EngineError::WriteValidation);
5037            }
5038            let (kind, schema_json) = collection_metadata(connection, collection)?;
5039            if let Some(schema_id) = schema_id {
5040                if schema_id != collection {
5041                    return Err(EngineError::SchemaValidation);
5042                }
5043                validate_payload(&schema_json, body)?;
5044            } else if serde_json::from_str::<Value>(body).is_err() {
5045                return Err(EngineError::SchemaValidation);
5046            }
5047
5048            match kind.as_str() {
5049                "append_only_log" => Ok(WritePlan::AppendOnlyLog),
5050                "latest_state" => Ok(WritePlan::LatestState),
5051                _ => Err(EngineError::OpStore),
5052            }
5053        }
5054    }
5055}
5056
5057fn collection_metadata(
5058    connection: &Connection,
5059    collection: &str,
5060) -> Result<(String, String), EngineError> {
5061    connection
5062        .query_row(
5063            "SELECT kind, schema_json FROM operational_collections WHERE name = ?1",
5064            [collection],
5065            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
5066        )
5067        .map_err(|_| EngineError::OpStore)
5068}
5069
5070fn validate_payload(schema_json: &str, body: &str) -> Result<(), EngineError> {
5071    let schema =
5072        serde_json::from_str::<Value>(schema_json).map_err(|_| EngineError::SchemaValidation)?;
5073    let payload = serde_json::from_str::<Value>(body).map_err(|_| EngineError::SchemaValidation)?;
5074
5075    let compiled = JSONSchema::compile(&schema).map_err(|_| EngineError::SchemaValidation)?;
5076    compiled.validate(&payload).map_err(|_| EngineError::SchemaValidation)?;
5077
5078    Ok(())
5079}
5080
5081fn contains_external_ref(schema_json: &str) -> bool {
5082    let Ok(value) = serde_json::from_str::<Value>(schema_json) else {
5083        return false;
5084    };
5085    value_contains_external_ref(&value)
5086}
5087
5088fn value_contains_external_ref(value: &Value) -> bool {
5089    match value {
5090        Value::Object(object) => object.iter().any(|(key, value)| {
5091            if key == "$ref" {
5092                return value.as_str().is_some_and(|uri| !uri.starts_with('#'));
5093            }
5094            value_contains_external_ref(value)
5095        }),
5096        Value::Array(values) => values.iter().any(value_contains_external_ref),
5097        _ => false,
5098    }
5099}
5100
5101fn commit_batch(
5102    connection: &mut Connection,
5103    batch: &[PreparedWrite],
5104    plans: &[WritePlan],
5105    base_cursor: u64,
5106    provenance_row_cap: u64,
5107) -> rusqlite::Result<()> {
5108    let tx = connection.transaction()?;
5109
5110    for (i, (write, plan)) in batch.iter().zip(plans).enumerate() {
5111        // Per-row cursor: row i gets `base_cursor + i + 1`. See the
5112        // comment in `Engine::write_inner`.
5113        let cursor = base_cursor.saturating_add((i as u64).saturating_add(1));
5114        match (write, plan) {
5115            (PreparedWrite::Node { kind, body, source_id }, WritePlan::Node) => {
5116                tx.execute(
5117                    "INSERT INTO canonical_nodes(write_cursor, kind, body, source_id)
5118                     VALUES(?1, ?2, ?3, ?4)",
5119                    params![cursor, kind, body, source_id],
5120                )?;
5121                tx.execute(
5122                    "INSERT INTO search_index(body, kind, write_cursor) VALUES(?1, ?2, ?3)",
5123                    params![body, kind, cursor],
5124                )?;
5125                if kind_is_vector_indexed(&tx, kind).unwrap_or(false) {
5126                    tx.execute(
5127                        "INSERT INTO _fathomdb_projection_state(kind, last_enqueued_cursor, updated_at)
5128                         VALUES(?1, ?2, 0)
5129                         ON CONFLICT(kind) DO UPDATE SET last_enqueued_cursor = excluded.last_enqueued_cursor",
5130                        params![kind, cursor],
5131                    )?;
5132                } else {
5133                    // Non-vector-indexed nodes will never be projected,
5134                    // so terminate the cursor up-front to let
5135                    // `advance_projection_cursor` walk past it.
5136                    record_projection_terminal(&tx, cursor, "up_to_date")?;
5137                }
5138            }
5139            (PreparedWrite::Edge { kind, from, to, source_id }, WritePlan::Edge) => {
5140                tx.execute(
5141                    "INSERT INTO canonical_edges(write_cursor, kind, from_id, to_id, source_id)
5142                     VALUES(?1, ?2, ?3, ?4, ?5)",
5143                    params![cursor, kind, from, to, source_id],
5144                )?;
5145                record_projection_terminal(&tx, cursor, "up_to_date")?;
5146            }
5147            (
5148                PreparedWrite::AdminSchema { name, kind, schema_json, retention_json },
5149                WritePlan::AdminSchema,
5150            ) => {
5151                tx.execute(
5152                    "INSERT INTO operational_collections(
5153                        name, kind, schema_json, retention_json, format_version, created_at
5154                     ) VALUES(?1, ?2, ?3, ?4, 1, 0)
5155                     ON CONFLICT(name) DO UPDATE SET
5156                        schema_json = excluded.schema_json,
5157                        retention_json = excluded.retention_json",
5158                    params![name, kind, schema_json, retention_json],
5159                )?;
5160                record_projection_terminal(&tx, cursor, "up_to_date")?;
5161            }
5162            (
5163                PreparedWrite::OpStore { collection, record_key, schema_id, body },
5164                WritePlan::AppendOnlyLog,
5165            ) => {
5166                tx.execute(
5167                    "INSERT INTO operational_mutations(
5168                        collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
5169                     ) VALUES(?1, ?2, 'append', ?3, ?4, ?5)",
5170                    params![collection, record_key, body, schema_id, cursor],
5171                )?;
5172                record_projection_terminal(&tx, cursor, "up_to_date")?;
5173            }
5174            (
5175                PreparedWrite::OpStore { collection, record_key, schema_id, body },
5176                WritePlan::LatestState,
5177            ) => {
5178                tx.execute(
5179                    "INSERT INTO operational_state(
5180                        collection_name, record_key, payload_json, schema_id, write_cursor
5181                     ) VALUES(?1, ?2, ?3, ?4, ?5)
5182                     ON CONFLICT(collection_name, record_key) DO UPDATE SET
5183                        payload_json = excluded.payload_json,
5184                        schema_id = excluded.schema_id,
5185                        write_cursor = excluded.write_cursor",
5186                    params![collection, record_key, body, schema_id, cursor],
5187                )?;
5188                record_projection_terminal(&tx, cursor, "up_to_date")?;
5189            }
5190            _ => return Err(rusqlite::Error::InvalidQuery),
5191        }
5192    }
5193
5194    enforce_provenance_retention(&tx, provenance_row_cap)?;
5195    advance_projection_cursor(&tx)?;
5196
5197    tx.commit()
5198}
5199
5200fn load_next_cursor(connection: &Connection) -> u64 {
5201    let nodes = max_cursor(connection, "canonical_nodes").unwrap_or(0);
5202    let edges = max_cursor(connection, "canonical_edges").unwrap_or(0);
5203    let mutations = max_cursor(connection, "operational_mutations").unwrap_or(0);
5204    let state = max_cursor(connection, "operational_state").unwrap_or(0);
5205    nodes.max(edges).max(mutations).max(state)
5206}
5207
5208fn max_cursor(connection: &Connection, table: &str) -> rusqlite::Result<u64> {
5209    let sql = format!("SELECT COALESCE(MAX(write_cursor), 0) FROM {table}");
5210    connection.query_row(&sql, [], |row| row.get::<_, u64>(0))
5211}
5212
5213/// Map a rusqlite error to its stable SQLite extended-code name.
5214///
5215/// Returns `None` for non-`SqliteFailure` variants (e.g. JSON conversion
5216/// failures, type mismatches at the rusqlite layer) — those are not
5217/// SQLite-internal events and should not be surfaced under
5218/// `EventSource::SqliteInternal`. The names returned here are the
5219/// canonical `SQLITE_*` symbol names from `sqlite3.h` and are stable
5220/// dispatch keys for AC-021 / AC-006 binding adapters.
5221///
5222/// Only the subset of codes the engine can reach in 0.6.0 is enumerated
5223/// — bare-extended-code matching covers the rest with a stable
5224/// `"SQLITE_UNKNOWN"` fallback so subscribers always see a typed code.
5225///
5226/// Diagnostic completeness for unmapped codes: when this helper returns
5227/// `"SQLITE_UNKNOWN"`, the numeric extended code is not lost — it
5228/// remains on the underlying `rusqlite::Error::SqliteFailure` carried
5229/// in the engine error chain that subscribers can inspect via
5230/// `EngineError`'s `source()`. Expanding the enumerated subset (or
5231/// surfacing the numeric code as a typed payload field) is a 0.7+
5232/// improvement.
5233fn sqlite_extended_code_name(err: &rusqlite::Error) -> Option<&'static str> {
5234    let sqlite_error = err.sqlite_error()?;
5235    let extended = sqlite_error.extended_code;
5236    Some(match extended {
5237        rusqlite::ffi::SQLITE_SCHEMA => "SQLITE_SCHEMA",
5238        rusqlite::ffi::SQLITE_BUSY => "SQLITE_BUSY",
5239        rusqlite::ffi::SQLITE_LOCKED => "SQLITE_LOCKED",
5240        rusqlite::ffi::SQLITE_CORRUPT => "SQLITE_CORRUPT",
5241        rusqlite::ffi::SQLITE_NOTADB => "SQLITE_NOTADB",
5242        rusqlite::ffi::SQLITE_IOERR => "SQLITE_IOERR",
5243        rusqlite::ffi::SQLITE_FULL => "SQLITE_FULL",
5244        rusqlite::ffi::SQLITE_READONLY => "SQLITE_READONLY",
5245        rusqlite::ffi::SQLITE_CONSTRAINT => "SQLITE_CONSTRAINT",
5246        rusqlite::ffi::SQLITE_MISUSE => "SQLITE_MISUSE",
5247        rusqlite::ffi::SQLITE_INTERRUPT => "SQLITE_INTERRUPT",
5248        rusqlite::ffi::SQLITE_NOMEM => "SQLITE_NOMEM",
5249        rusqlite::ffi::SQLITE_PERM => "SQLITE_PERM",
5250        rusqlite::ffi::SQLITE_ABORT => "SQLITE_ABORT",
5251        rusqlite::ffi::SQLITE_PROTOCOL => "SQLITE_PROTOCOL",
5252        rusqlite::ffi::SQLITE_RANGE => "SQLITE_RANGE",
5253        rusqlite::ffi::SQLITE_TOOBIG => "SQLITE_TOOBIG",
5254        rusqlite::ffi::SQLITE_MISMATCH => "SQLITE_MISMATCH",
5255        rusqlite::ffi::SQLITE_AUTH => "SQLITE_AUTH",
5256        rusqlite::ffi::SQLITE_NOTFOUND => "SQLITE_NOTFOUND",
5257        rusqlite::ffi::SQLITE_CANTOPEN => "SQLITE_CANTOPEN",
5258        _ => "SQLITE_UNKNOWN",
5259    })
5260}
5261
5262fn sqlite_extended_code_name_from_int(extended: i32) -> &'static str {
5263    match extended {
5264        rusqlite::ffi::SQLITE_SCHEMA => "SQLITE_SCHEMA",
5265        rusqlite::ffi::SQLITE_BUSY => "SQLITE_BUSY",
5266        rusqlite::ffi::SQLITE_LOCKED => "SQLITE_LOCKED",
5267        rusqlite::ffi::SQLITE_CORRUPT => "SQLITE_CORRUPT",
5268        rusqlite::ffi::SQLITE_NOTADB => "SQLITE_NOTADB",
5269        rusqlite::ffi::SQLITE_IOERR => "SQLITE_IOERR",
5270        rusqlite::ffi::SQLITE_FULL => "SQLITE_FULL",
5271        rusqlite::ffi::SQLITE_READONLY => "SQLITE_READONLY",
5272        rusqlite::ffi::SQLITE_CONSTRAINT => "SQLITE_CONSTRAINT",
5273        rusqlite::ffi::SQLITE_MISUSE => "SQLITE_MISUSE",
5274        rusqlite::ffi::SQLITE_INTERRUPT => "SQLITE_INTERRUPT",
5275        rusqlite::ffi::SQLITE_NOMEM => "SQLITE_NOMEM",
5276        rusqlite::ffi::SQLITE_PERM => "SQLITE_PERM",
5277        rusqlite::ffi::SQLITE_ABORT => "SQLITE_ABORT",
5278        rusqlite::ffi::SQLITE_PROTOCOL => "SQLITE_PROTOCOL",
5279        rusqlite::ffi::SQLITE_RANGE => "SQLITE_RANGE",
5280        rusqlite::ffi::SQLITE_TOOBIG => "SQLITE_TOOBIG",
5281        rusqlite::ffi::SQLITE_MISMATCH => "SQLITE_MISMATCH",
5282        rusqlite::ffi::SQLITE_AUTH => "SQLITE_AUTH",
5283        rusqlite::ffi::SQLITE_NOTFOUND => "SQLITE_NOTFOUND",
5284        rusqlite::ffi::SQLITE_CANTOPEN => "SQLITE_CANTOPEN",
5285        _ => "SQLITE_UNKNOWN",
5286    }
5287}
5288
5289fn map_open_sqlite_error(err: rusqlite::Error, stage: OpenStage) -> EngineOpenError {
5290    let Some(sqlite_error) = err.sqlite_error() else {
5291        return EngineOpenError::Io { message: "could not open database".to_string() };
5292    };
5293    match sqlite_error.extended_code {
5294        rusqlite::ffi::SQLITE_CORRUPT | rusqlite::ffi::SQLITE_NOTADB => {
5295            EngineOpenError::Corruption(CorruptionDetail {
5296                kind: match stage {
5297                    OpenStage::WalReplay => CorruptionKind::WalReplayFailure,
5298                    OpenStage::HeaderProbe => CorruptionKind::HeaderMalformed,
5299                    OpenStage::SchemaProbe => CorruptionKind::SchemaInconsistent,
5300                    OpenStage::EmbedderIdentity => CorruptionKind::EmbedderIdentityDrift,
5301                },
5302                stage,
5303                locator: CorruptionLocator::OpaqueSqliteError {
5304                    sqlite_extended_code: sqlite_error.extended_code,
5305                },
5306                recovery_hint: RecoveryHint {
5307                    code: match stage {
5308                        OpenStage::WalReplay => "E_CORRUPT_WAL_REPLAY",
5309                        OpenStage::HeaderProbe => "E_CORRUPT_HEADER",
5310                        OpenStage::SchemaProbe => "E_CORRUPT_SCHEMA",
5311                        OpenStage::EmbedderIdentity => "E_CORRUPT_EMBEDDER_IDENTITY",
5312                    },
5313                    doc_anchor: match stage {
5314                        OpenStage::WalReplay => "design/recovery.md#wal-replay-failures",
5315                        OpenStage::HeaderProbe => "design/recovery.md#header-malformed",
5316                        OpenStage::SchemaProbe => "design/recovery.md#schema-inconsistent",
5317                        OpenStage::EmbedderIdentity => "design/recovery.md#embedder-identity-drift",
5318                    },
5319                },
5320            })
5321        }
5322        _ => EngineOpenError::Io { message: "could not open database".to_string() },
5323    }
5324}
5325
5326fn emit_open_error_event(subscriber: &Arc<dyn lifecycle::Subscriber>, err: &EngineOpenError) {
5327    if let EngineOpenError::Corruption(detail) = err {
5328        let code = match detail.locator {
5329            CorruptionLocator::OpaqueSqliteError { sqlite_extended_code } => {
5330                Some(sqlite_extended_code_name_from_int(sqlite_extended_code))
5331            }
5332            _ => None,
5333        };
5334        let event = lifecycle::Event {
5335            phase: lifecycle::Phase::Failed,
5336            source: lifecycle::EventSource::SqliteInternal,
5337            category: lifecycle::EventCategory::Corruption,
5338            code,
5339        };
5340        subscriber.on_event(&event);
5341    }
5342}
5343
5344/// Install a `sqlite3_profile` callback on `connection` that dispatches
5345/// per-statement profile records and slow-statement signals to the
5346/// engine's subscriber registry.
5347///
5348/// Why FFI rather than `rusqlite::Connection::profile`: the safe API
5349/// (rusqlite 0.31) accepts only a `fn(&str, Duration)` with no
5350/// environment, so it cannot carry a per-engine subscriber-registry
5351/// pointer. We use `sqlite3_profile` directly with a leaked-into-`Box`
5352/// context whose pointer is tied to the engine's lifetime via
5353/// `Engine::profile_contexts`.
5354///
5355/// `sqlite3_profile` is documented as deprecated in favor of
5356/// `sqlite3_trace_v2`, but it remains supported and is sufficient for
5357/// the wall-clock + SQL-text payload required by AC-005a/b.
5358#[allow(clippy::vec_box)]
5359fn install_profile_callback(
5360    connection: &Connection,
5361    subscribers: &Arc<lifecycle::SubscriberRegistry>,
5362    profiling_enabled: &Arc<AtomicBool>,
5363    slow_threshold_ms: &Arc<AtomicU64>,
5364    contexts: &mut Vec<Box<ProfileContext>>,
5365) {
5366    let mut ctx = Box::new(ProfileContext {
5367        subscribers: Arc::clone(subscribers),
5368        profiling_enabled: Arc::clone(profiling_enabled),
5369        slow_threshold_ms: Arc::clone(slow_threshold_ms),
5370    });
5371    let ctx_ptr: *mut ProfileContext = &mut *ctx;
5372
5373    // SAFETY: the Box outlives the connection. Rust drops struct fields
5374    // in declaration order. `connection` and `reader_pool` are declared
5375    // before `profile_contexts`. `ReaderWorkerPool::Drop` joins every
5376    // reader worker, and each worker uninstalls and drops its owned
5377    // connection inside `reader_worker_loop` before the worker thread
5378    // returns. Therefore all connections — and SQLite's internal
5379    // profile-callback state with them — are torn down before the
5380    // `Box<ProfileContext>` allocations are freed. `Engine::close`
5381    // additionally clears the callback via
5382    // `sqlite3_profile(handle, None, NULL)` before connection close to
5383    // drain any in-flight callback dispatch.
5384    unsafe {
5385        rusqlite::ffi::sqlite3_profile(
5386            connection.handle(),
5387            Some(profile_callback_trampoline),
5388            ctx_ptr.cast::<std::ffi::c_void>(),
5389        );
5390    }
5391    contexts.push(ctx);
5392}
5393
5394/// Uninstall the profile callback so SQLite stops calling into our
5395/// freed `Box<ProfileContext>` pointer once a connection is being torn
5396/// down. Call before dropping `profile_contexts`.
5397fn uninstall_profile_callback(connection: &Connection) {
5398    // SAFETY: passing `None` as the callback unregisters the previous
5399    // callback; SQLite documents this as legal and idempotent.
5400    unsafe {
5401        rusqlite::ffi::sqlite3_profile(connection.handle(), None, std::ptr::null_mut());
5402    }
5403}
5404
5405/// Pack 6.G G.1 — configure SQLite per-connection lookaside on a reader
5406/// worker connection. Must be called BEFORE any statement is prepared
5407/// or any PRAGMA is run on `connection`; per the SQLite docs
5408/// (https://www.sqlite.org/malloc.html §3) lookaside is silently
5409/// ignored if reconfigured after the first allocation on the
5410/// connection. Passing `NULL` for the buffer pointer lets SQLite
5411/// allocate the lookaside backing memory itself.
5412///
5413/// rusqlite 0.31's `set_db_config` only handles the boolean
5414/// `DbConfig::*` variants; `SQLITE_DBCONFIG_LOOKASIDE` is not surfaced
5415/// (it is commented out in `rusqlite/src/config.rs`), so we call the
5416/// raw FFI directly.
5417///
5418/// Returns the rc of `sqlite3_db_config` so callers can debug-assert
5419/// `SQLITE_OK` and surface configuration failure under
5420/// `debug_assertions` test builds without expanding the public surface.
5421/// 0.7.0 perf-experiments hook: apply caller-supplied reader PRAGMAs
5422/// from the `FATHOMDB_PERF_READER_PRAGMAS` env var. Format:
5423/// comma-separated `name=value` pairs (e.g.
5424/// `cache_size=-262144,mmap_size=268435456,temp_store=MEMORY`).
5425///
5426/// **Gated on `FATHOMDB_PERF_EXPERIMENTS=1`.** No-op if the gate env
5427/// var is unset, so production paths are never affected. Failures to
5428/// apply individual PRAGMAs are logged to stderr (via `eprintln!`) but
5429/// do not error the connection open — experiments are best-effort,
5430/// not contract.
5431///
5432/// Scope: 0.7.0 perf-experiment campaign per
5433/// `dev/plans/0.7.0-perf-experiments.md`. Once Wave 5 picks the
5434/// landing combination, the chosen PRAGMAs are hardcoded as the new
5435/// reader-open default and this hook is removed.
5436/// 0.7.0 perf-experiments hook: apply writer-side PRAGMAs from
5437/// `FATHOMDB_PERF_WRITER_PRAGMAS` (same format as reader hook).
5438/// **Runs BEFORE migrations** so PRAGMAs like `page_size` that must
5439/// precede any table creation take effect on a fresh DB.
5440///
5441/// Gated on `FATHOMDB_PERF_EXPERIMENTS=1`. No-op otherwise.
5442fn apply_perf_experiment_writer_pragmas(connection: &Connection) {
5443    if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
5444        return;
5445    }
5446    let raw = match std::env::var("FATHOMDB_PERF_WRITER_PRAGMAS") {
5447        Ok(s) if !s.is_empty() => s,
5448        _ => return,
5449    };
5450    for entry in raw.split(',') {
5451        let entry = entry.trim();
5452        if entry.is_empty() {
5453            continue;
5454        }
5455        let (name, value) = match entry.split_once('=') {
5456            Some((n, v)) => (n.trim(), v.trim()),
5457            None => {
5458                eprintln!("perf-experiment: bad writer pragma entry (expect name=value): {entry}");
5459                continue;
5460            }
5461        };
5462        if name.is_empty() {
5463            eprintln!("perf-experiment: empty pragma name in writer entry: {entry}");
5464            continue;
5465        }
5466        match connection.pragma_update(None, name, value) {
5467            Ok(()) => {
5468                eprintln!(
5469                    "perf-experiment: applied PRAGMA {name}={value} on writer (pre-migration)"
5470                );
5471            }
5472            Err(err) => {
5473                eprintln!("perf-experiment: writer PRAGMA {name}={value} failed: {err}");
5474            }
5475        }
5476    }
5477}
5478
5479fn apply_perf_experiment_reader_pragmas(connection: &Connection) {
5480    if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
5481        return;
5482    }
5483    let raw = match std::env::var("FATHOMDB_PERF_READER_PRAGMAS") {
5484        Ok(s) if !s.is_empty() => s,
5485        _ => return,
5486    };
5487    for entry in raw.split(',') {
5488        let entry = entry.trim();
5489        if entry.is_empty() {
5490            continue;
5491        }
5492        let (name, value) = match entry.split_once('=') {
5493            Some((n, v)) => (n.trim(), v.trim()),
5494            None => {
5495                eprintln!("perf-experiment: bad pragma entry (expect name=value): {entry}");
5496                continue;
5497            }
5498        };
5499        if name.is_empty() {
5500            eprintln!("perf-experiment: empty pragma name in entry: {entry}");
5501            continue;
5502        }
5503        match connection.pragma_update(None, name, value) {
5504            Ok(()) => {
5505                eprintln!("perf-experiment: applied PRAGMA {name}={value} on reader");
5506            }
5507            Err(err) => {
5508                eprintln!("perf-experiment: PRAGMA {name}={value} failed: {err}");
5509            }
5510        }
5511    }
5512}
5513
5514fn configure_reader_lookaside(connection: &Connection) -> std::os::raw::c_int {
5515    // SAFETY: `connection.handle()` returns a valid `*mut sqlite3` for
5516    // the lifetime of `connection`. The variadic
5517    // `sqlite3_db_config(LOOKASIDE)` call expects three trailing
5518    // arguments of types `void*`, `int`, `int` — the prototype shape
5519    // documented in `sqlite3.h`. We pass a null buffer so SQLite owns
5520    // the lookaside backing allocation, and the slot size / count from
5521    // the G.1 constants. No allocations happen on the connection
5522    // before this call (reader open path is `Connection::open` ->
5523    // `configure_reader_lookaside` -> first PRAGMA).
5524    unsafe {
5525        rusqlite::ffi::sqlite3_db_config(
5526            connection.handle(),
5527            rusqlite::ffi::SQLITE_DBCONFIG_LOOKASIDE,
5528            std::ptr::null_mut::<std::ffi::c_void>(),
5529            READER_LOOKASIDE_SLOT_SIZE,
5530            READER_LOOKASIDE_SLOT_COUNT,
5531        )
5532    }
5533}
5534
5535/// Read the high-water-mark for `SQLITE_DBSTATUS_LOOKASIDE_USED` on
5536/// `connection`. The `current` out-param is the live checked-out slot
5537/// count and decays as transactions finalize, so it is unreliable as
5538/// post-warmup evidence. The `hiwtr` out-param latches the largest
5539/// observed `current` value since the last reset and is the right
5540/// signal that lookaside was honored at any point on this connection.
5541/// Reset flag is `0` so reading does not clear the high-water mark.
5542#[cfg(debug_assertions)]
5543fn read_lookaside_used_hiwtr(connection: &Connection) -> std::os::raw::c_int {
5544    let mut current: std::os::raw::c_int = 0;
5545    let mut hiwtr: std::os::raw::c_int = 0;
5546    // SAFETY: handle is valid; both out pointers are to local stack
5547    // ints; reset flag 0 is documented as legal.
5548    unsafe {
5549        rusqlite::ffi::sqlite3_db_status(
5550            connection.handle(),
5551            rusqlite::ffi::SQLITE_DBSTATUS_LOOKASIDE_USED,
5552            &mut current,
5553            &mut hiwtr,
5554            0,
5555        );
5556    }
5557    hiwtr
5558}
5559
5560/// Pack 6.G G.3.5 — read the three page-cache pressure counters on
5561/// `connection`: `SQLITE_DBSTATUS_CACHE_HIT`, `_CACHE_MISS`, and
5562/// `_CACHE_USED`. Returns `(hit, miss, used_bytes)`. Hit/miss are
5563/// monotonic counters (reset flag = 0 here); used_bytes is the live
5564/// page-cache memory footprint at call time. The caller is expected to
5565/// take pre/post snapshots and do delta arithmetic explicitly.
5566#[cfg(debug_assertions)]
5567fn read_cache_status(
5568    connection: &Connection,
5569) -> (std::os::raw::c_int, std::os::raw::c_int, std::os::raw::c_int) {
5570    let mut hit_current: std::os::raw::c_int = 0;
5571    let mut hit_hiwtr: std::os::raw::c_int = 0;
5572    let mut miss_current: std::os::raw::c_int = 0;
5573    let mut miss_hiwtr: std::os::raw::c_int = 0;
5574    let mut used_current: std::os::raw::c_int = 0;
5575    let mut used_hiwtr: std::os::raw::c_int = 0;
5576    // SAFETY: `connection.handle()` returns a valid `*mut sqlite3` for
5577    // the lifetime of `connection`. All out-pointers are to local stack
5578    // ints. Reset flag 0 is documented as legal (no counter is reset).
5579    unsafe {
5580        rusqlite::ffi::sqlite3_db_status(
5581            connection.handle(),
5582            rusqlite::ffi::SQLITE_DBSTATUS_CACHE_HIT,
5583            &mut hit_current,
5584            &mut hit_hiwtr,
5585            0,
5586        );
5587        rusqlite::ffi::sqlite3_db_status(
5588            connection.handle(),
5589            rusqlite::ffi::SQLITE_DBSTATUS_CACHE_MISS,
5590            &mut miss_current,
5591            &mut miss_hiwtr,
5592            0,
5593        );
5594        rusqlite::ffi::sqlite3_db_status(
5595            connection.handle(),
5596            rusqlite::ffi::SQLITE_DBSTATUS_CACHE_USED,
5597            &mut used_current,
5598            &mut used_hiwtr,
5599            0,
5600        );
5601    }
5602    // CACHE_HIT / CACHE_MISS are monotonic counters reported in the
5603    // `current` out-param; CACHE_USED is the live byte count, also in
5604    // `current`. The hiwtr values are unused for this telemetry.
5605    (hit_current, miss_current, used_current)
5606}
5607
5608/// FFI trampoline for `sqlite3_profile`.
5609///
5610/// Invoked by SQLite at statement-finish with the SQL text and the
5611/// statement's wall-clock cost in nanoseconds. We dispatch a
5612/// `ProfileRecord` (when profiling is enabled) and a `SlowStatement`
5613/// signal (when `wall_clock_ms` exceeds the configured slow threshold).
5614///
5615/// Per `dev/design/lifecycle.md` § Public record shape, the public
5616/// payload exposes `wall_clock_ms`, `step_count`, and `cache_delta`.
5617/// `sqlite3_profile` does not surface per-statement step counts or
5618/// cache-hit deltas in its callback; we emit `0` for those fields and
5619/// document the hazard. AC-005b requires the fields be typed numeric,
5620/// not that they carry non-zero values for every backend.
5621unsafe extern "C" fn profile_callback_trampoline(
5622    user_data: *mut std::ffi::c_void,
5623    sql: *const std::os::raw::c_char,
5624    nanoseconds: u64,
5625) {
5626    if user_data.is_null() || sql.is_null() {
5627        return;
5628    }
5629    let ctx = unsafe { &*(user_data.cast::<ProfileContext>()) };
5630    let sql_text = match unsafe { std::ffi::CStr::from_ptr(sql) }.to_str() {
5631        Ok(s) => s,
5632        Err(_) => return,
5633    };
5634
5635    let wall_clock_ms = nanoseconds / 1_000_000;
5636
5637    if ctx.profiling_enabled.load(Ordering::Relaxed) {
5638        let record = lifecycle::ProfileRecord {
5639            wall_clock_ms,
5640            // step_count / cache_delta are not surfaced by
5641            // sqlite3_profile; placeholder 0 satisfies AC-005b's
5642            // "typed numeric" contract. A future profiling refactor
5643            // around sqlite3_stmt_status + sqlite3_db_status would
5644            // populate them with non-zero deltas.
5645            step_count: 0,
5646            cache_delta: 0,
5647        };
5648        ctx.subscribers.dispatch_profile(&record);
5649    }
5650
5651    let threshold = ctx.slow_threshold_ms.load(Ordering::Relaxed);
5652    if wall_clock_ms > threshold {
5653        let signal = lifecycle::SlowStatement { statement: sql_text.to_string(), wall_clock_ms };
5654        ctx.subscribers.dispatch_slow_statement(&signal);
5655    }
5656}
5657
5658#[cfg(test)]
5659mod tests {
5660    use super::{resolve_source_type, Engine, PreparedWrite, KIND_TO_SOURCE_TYPE_CASE_SQL};
5661    use rusqlite::Connection;
5662    use tempfile::TempDir;
5663
5664    // Pack 1 drift-detection: the Rust helper used by the two writer
5665    // sites must agree with the CASE WHEN used by the Pack 1 reshape
5666    // migration in `migrate_vector_partition_to_pack1`. The CASE SQL
5667    // is exported as `KIND_TO_SOURCE_TYPE_CASE_SQL`; this test
5668    // exercises it against an in-memory SQLite (no sqlite-vec extension
5669    // required — only the CASE) and asserts byte-equal output with the
5670    // Rust helper for every kind in the locked Pack 1 vocabulary
5671    // (incl. the synthetic `doc` -> `article` coercion). See
5672    // `dev/design/0.7.0-vector-quant-pack1.md` D3 / D4.
5673    #[test]
5674    fn resolve_source_type_drift_check() {
5675        let kinds = ["email", "article", "paper", "meeting", "note", "todo", "doc"];
5676
5677        // 1. Rust helper return values (table is the contract: changes
5678        //    here must be reflected in the SQL CASE or this test fails).
5679        let want: &[(&str, &str)] = &[
5680            ("email", "email"),
5681            ("article", "article"),
5682            ("paper", "paper"),
5683            ("meeting", "meeting"),
5684            ("note", "note"),
5685            ("todo", "todo"),
5686            ("doc", "article"),
5687        ];
5688        for (kind, expected) in want {
5689            let got = resolve_source_type(kind).unwrap_or_else(|_| {
5690                panic!("resolve_source_type({kind}) returned Err; want Ok({expected})")
5691            });
5692            assert_eq!(got, *expected, "Rust helper drift for kind={kind}");
5693        }
5694        assert!(
5695            resolve_source_type("banana").is_err(),
5696            "unknown kind must surface as writer error"
5697        );
5698
5699        // 2. SQL CASE evaluated against the same kinds. Build a
5700        //    one-row staging row per kind and SELECT through
5701        //    KIND_TO_SOURCE_TYPE_CASE_SQL; assert each row equals the
5702        //    Rust helper's output. Drift in either direction fails.
5703        let conn = Connection::open_in_memory().expect("in-memory sqlite");
5704        conn.execute_batch("CREATE TABLE s(kind TEXT NOT NULL)").expect("create s");
5705        for kind in &kinds {
5706            conn.execute("INSERT INTO s(kind) VALUES (?1)", [kind]).expect("insert kind");
5707        }
5708        let sql = format!("SELECT s.kind, {KIND_TO_SOURCE_TYPE_CASE_SQL} FROM s");
5709        let mut stmt = conn.prepare(&sql).expect("prepare CASE");
5710        let rows: Vec<(String, String)> = stmt
5711            .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
5712            .expect("query")
5713            .map(|r| r.expect("row"))
5714            .collect();
5715        assert_eq!(rows.len(), kinds.len(), "row count drift");
5716        for (kind, sql_result) in &rows {
5717            let rust_result = resolve_source_type(kind).expect("known kind");
5718            assert_eq!(
5719                sql_result, rust_result,
5720                "SQL CASE vs Rust helper drift for kind={kind}: SQL={sql_result}, Rust={rust_result}"
5721            );
5722        }
5723    }
5724
5725    #[test]
5726    fn write_advances_cursor() {
5727        let dir = TempDir::new().unwrap();
5728        let opened = Engine::open(dir.path().join("rewrite.sqlite")).expect("engine should open");
5729        let receipt = opened
5730            .engine
5731            .write(&[PreparedWrite::Node {
5732                kind: "doc".to_string(),
5733                body: "hello".to_string(),
5734                source_id: None,
5735            }])
5736            .expect("write should succeed");
5737
5738        assert_eq!(receipt.cursor, 1);
5739    }
5740}