Skip to main content

fathomdb_engine/
lib.rs

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