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::{BufRead, BufReader, Seek, SeekFrom, Write};
9use std::path::{Path, PathBuf};
10use std::process::{Command, Stdio};
11use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
12use std::sync::mpsc::{self, Receiver, SyncSender};
13use std::sync::Once;
14use std::sync::{Arc, Condvar, Mutex};
15use std::thread::{self, JoinHandle};
16use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
17
18use fathomdb_embedder::EmbedderEvent;
19// `MeanRecomputeTrigger` is used only by the operator-gated `recompute_mean`.
20#[cfg(feature = "operator")]
21use fathomdb_embedder::MeanRecomputeTrigger;
22use fathomdb_embedder_api::{Embedder, EmbedderError as RuntimeEmbedderError, EmbedderIdentity};
23use fathomdb_query::compile_text_query;
24use fathomdb_schema::{
25    migrate_with_event_sink, MigrationError as SchemaMigrationError, MigrationStepReport,
26    LOCK_SUFFIX, MIGRATIONS, SCHEMA_VERSION,
27};
28// `CANONICAL_TABLES` is used only by the operator-gated `dump_row_counts`.
29#[cfg(feature = "operator")]
30use fathomdb_schema::CANONICAL_TABLES;
31use jsonschema::JSONSchema;
32use rusqlite::{params, Connection, OptionalExtension};
33use serde_json::Value;
34// `sha2::Digest` + `sha2::Sha256` — used by `safe_export` (operator-gated)
35// and unconditionally by `ingest_with_extractor` (G11 logical_id derivation).
36#[cfg(feature = "operator")]
37use sha2::Digest;
38#[cfg(not(feature = "operator"))]
39use sha2::Digest as _;
40use sha2::Sha256;
41use sqlite_vec::sqlite3_vec_init;
42
43#[cfg(unix)]
44use std::os::unix::fs::OpenOptionsExt;
45
46// EU-5b lock-flip: the engine's default embedder identity is now the
47// pinned bge-small variant. Pre-existing 0.7.0 workspaces opened with
48// `EmbedderChoice::Default` will fail-closed on identity mismatch per
49// ADR-0.6.0-vector-identity-embedder-owned; callers can still hold an
50// older noop profile by supplying `EmbedderChoice::Caller(NoopEmbedder)`.
51const DEFAULT_EMBEDDER_NAME: &str = "fathomdb-bge-small-en-v1.5";
52const DEFAULT_EMBEDDER_REVISION: &str = "5c38ec7c405ec4b44b94cc5a9bb96e735b38267a";
53const DEFAULT_EMBEDDER_DIMENSION: u32 = 384;
54
55/// Identity name of the bge-small embedder. `OpenReport.embedder_mean_centering_required`
56/// is `true` iff the live embedder identity reports this name. NoopEmbedder
57/// is `false`. Lifted out as a constant so the EU-5b lock-flip (when the
58/// engine's default identity becomes bge-small) is a single-line change.
59///
60/// TODO(EU-5b): when `DEFAULT_EMBEDDER_NAME` flips to this constant, the
61/// Default path will populate `embedder_mean_centering_required = true`
62/// without further engine work. Caller-supplied bge-small (rare today)
63/// already does the right thing.
64const BGE_SMALL_EMBEDDER_NAME: &str = "fathomdb-bge-small-en-v1.5";
65
66/// REQ-006a / AC-007a default slow-statement threshold. Mutated at runtime
67/// via [`Engine::set_slow_threshold_ms`].
68const DEFAULT_SLOW_THRESHOLD_MS: u64 = 100;
69const DEFAULT_VECTOR_PROFILE: &str = "default";
70const DEFAULT_VECTOR_PARTITION: &str = "vector_default";
71/// Default drain budget for `rebuild_projections` / `rebuild_vec0`. The
72/// rebuild path freezes the scheduler before truncating shadow rows, so
73/// the only outstanding work is whatever workers were mid-flight when
74/// the call landed; 30 s is generous for normal job sizes and bounded
75/// for tests.
76#[cfg(feature = "operator")]
77const REBUILD_DRAIN_TIMEOUT_MS: u64 = 30_000;
78/// 0.8.0 Slice 5 (G1) — schema version that introduces the global FTS5
79/// tokenizer-default upgrade (`SCHEMA_VERSION` 11, migration step 11). A DB
80/// migrated to (or past) this version re-tokenizes `search_index` from
81/// canonical source rows on open (the drop+recreate leaves the FTS index
82/// empty). Repair is keyed off the completion marker below — NOT off crossing
83/// the step boundary — so it is crash-retryable (see
84/// `SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY`).
85const SEARCH_INDEX_TOKENIZER_SCHEMA_VERSION: u32 = 11;
86/// 0.8.0 Slice 5 (G1) fix-1 — `_fathomdb_open_state` key set, in the SAME
87/// transaction as the reproject DELETE+INSERT, once the post-tokenizer-upgrade
88/// re-tokenization commits durably. Step 11 commits `user_version = 11` with an
89/// EMPTY `search_index` in its own transaction; the reproject runs in a later
90/// transaction on open. A crash in that window leaves a durable `user_version =
91/// 11` + empty index. Gating repair on a boundary crossing (`before < 11`)
92/// would skip it on the next open (it sees `before == 11`), stranding the index
93/// empty forever. Gating on this marker's ABSENCE instead makes repair
94/// idempotent and crash-retryable: written atomically with the reindex, so a
95/// crash before commit leaves no marker and the next open re-runs.
96const SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY: &str =
97    "search_index_tokenizer_reproject_complete";
98const DEFAULT_PROVENANCE_ROW_CAP: u64 = 1_000_000;
99const PROJECTION_CURSOR_KEY: &str = "projection_cursor";
100const PROJECTION_WORKERS: usize = 2;
101/// PR-9 — ADR-0.6.0-embedder-protocol **Invariant 5** default per-`embed()`
102/// watchdog deadline. Every projection-path embed runs under this timeout;
103/// a hung embed surfaces `RuntimeEmbedderError::Timeout` (engaging the
104/// existing retry/failure path) rather than parking a worker forever. The
105/// EU-5f `catch_unwind` only catches *panics*; this catches *hangs*.
106const DEFAULT_EMBED_TIMEOUT_MS: u64 = 30_000;
107/// PR-9 — embed circuit-breaker threshold: the maximum number of watchdog
108/// embed threads allowed alive at once before the breaker latches and
109/// projection jobs fail fast (see `embed_circuit_open` / `live_embed_threads`).
110/// Healthy serialized operation keeps the live count at 0–1, so reaching this
111/// many concurrently-alive embed threads means timed-out embeds are piling up
112/// (a hung/wedged embedder); the breaker then caps the abandoned-thread leak
113/// at roughly this count.
114const DEFAULT_EMBED_CIRCUIT_THRESHOLD: u64 = 8;
115const PROJECTION_COMMIT_BATCH: usize = 16;
116// Each worker should be able to grab a full commit batch while another
117// worker has the same waiting in the queue. Below this, the dispatcher
118// throttles below the workers' commit-batch capacity.
119const PROJECTION_INFLIGHT_LIMIT: usize = PROJECTION_WORKERS * PROJECTION_COMMIT_BATCH;
120// SQL fetch cap inside the dispatcher: enough to fill the in-flight
121// budget in a single scan so we don't pay one SQL roundtrip per job.
122const PROJECTION_SCAN_FETCH: usize = PROJECTION_INFLIGHT_LIMIT;
123const DEFAULT_PROJECTION_RETRY_DELAYS_MS: [u64; 3] = [1_000, 4_000, 16_000];
124
125/// Reader pool size. Per `dev/design/engine.md` § Writer / reader split,
126/// reader connections are pooled and never serialize behind one
127/// connection. AC-021 exercises 8 concurrent readers.
128const READER_POOL_SIZE: usize = 8;
129
130/// Per-reader-connection lookaside slot size, in bytes. Pack 6.G G.1.
131/// Picked from G.0 telemetry (`allocator_lookaside` 26.67% conc cycles
132/// with 3.89× ratio) + the SQLite docs' typical-workload sizing
133/// guidance (https://www.sqlite.org/malloc.html §3): 1200-byte slots
134/// cover the small allocations from `sqlite3DbMallocRaw`,
135/// `sqlite3Fts5ExprNew`, and `vec0Filter_knn` visible at the top of the
136/// concurrent profile.
137const READER_LOOKASIDE_SLOT_SIZE: std::os::raw::c_int = 1200;
138
139/// Per-reader-connection lookaside slot count. SQLite default is 128;
140/// we use 500 to absorb the per-statement allocation footprint of the
141/// hybrid search workload across a sticky worker connection without
142/// falling back to the glibc malloc-arena mutex.
143const READER_LOOKASIDE_SLOT_COUNT: std::os::raw::c_int = 500;
144
145pub struct Engine {
146    path: PathBuf,
147    next_cursor: AtomicU64,
148    closed: AtomicBool,
149    lock: Mutex<Option<File>>,
150    connection: Mutex<Option<Connection>>,
151    reader_pool: ReaderWorkerPool,
152    counters: lifecycle::Counters,
153    subscribers: Arc<lifecycle::SubscriberRegistry>,
154    profiling_enabled: Arc<AtomicBool>,
155    slow_threshold_ms: Arc<AtomicU64>,
156    runtime_embedder: Option<Arc<dyn Embedder>>,
157    runtime_embedder_identity: EmbedderIdentity,
158    projection_runtime: ProjectionRuntime,
159    provenance_row_cap: AtomicU64,
160    /// Per-connection profile-callback contexts. Each box's pointer is
161    /// installed into the connection's `sqlite3_profile` userdata; the
162    /// box must outlive the connection so the callback never reads
163    /// freed memory. Connections are dropped before this vec on
164    /// `close`/`Drop`, so the lifetime ordering holds.
165    ///
166    /// Why `Box<ProfileContext>` and not `ProfileContext` directly: the
167    /// FFI pointer captured during `install_profile_callback` MUST
168    /// remain stable for the connection's lifetime; pushing onto a
169    /// `Vec<ProfileContext>` could reallocate and invalidate that
170    /// pointer.
171    #[allow(clippy::vec_box)]
172    profile_contexts: Mutex<Vec<Box<ProfileContext>>>,
173    /// Pack 6.G G.1 — `sqlite3_db_config(LOOKASIDE)` rc per reader
174    /// worker, captured at open time before any PRAGMA / prepare ran
175    /// on the connection. Read only by the debug-only test accessor
176    /// `reader_lookaside_config_rcs_for_test`; held in release builds
177    /// too because the field is set unconditionally at open and a cfg
178    /// gate would force two open-locked return shapes.
179    #[allow(dead_code)]
180    reader_lookaside_rcs: Vec<i32>,
181    /// 0.8.8 Slice 15 (OPP-9) — opt-in telemetry sink. `None` (default) = OFF.
182    /// Local JSONL append; no network/egress. The OFF path never takes this lock —
183    /// it is gated by `telemetry_enabled` (below).
184    telemetry: Mutex<Option<TelemetrySink>>,
185    /// 0.8.8 Slice 15 — fast OFF-path guard. `false` (default) → search does ZERO
186    /// telemetry work: a single `Relaxed` atomic load, NO mutex acquisition (the
187    /// §B.1 footprint / zero-cost gate, codex §9 P2). Set `true` by
188    /// `enable_telemetry` after the sink is installed; the `telemetry` mutex is only
189    /// ever taken when this flag is set.
190    telemetry_enabled: AtomicBool,
191    #[cfg(debug_assertions)]
192    force_next_commit_failure: AtomicBool,
193}
194
195/// 0.8.8 Slice 15 (OPP-9) — opt-in telemetry capture state (per `enable_telemetry`).
196/// Records query→result→feedback events to a local JSONL sink. Ids are
197/// `SearchHit.id` — the interim identity carrier per
198/// `ADR-0.8.0-canonical-identity-substrate` (write_cursor today; swaps to
199/// `logical_id` at the G0 keystone with no carrier reshape), consistent with
200/// `PerHitExplain.id`. Query text and `source_id` are NEVER captured (privacy, ADR
201/// §C). `query_id = "q{nonce}-{seq}"` is fully deterministic; `ts_monotonic_ms` is
202/// monotonic since enable (NOT wall-clock).
203struct TelemetrySink {
204    path: PathBuf,
205    base: Instant,
206    nonce: u64,
207    seq: u64,
208    last_query_id: Option<String>,
209}
210
211#[derive(Clone, Debug)]
212struct ProjectionJob {
213    cursor: u64,
214    kind: String,
215    body: String,
216}
217
218#[derive(Debug, Default)]
219struct ProjectionRuntimeState {
220    active_jobs: usize,
221    queued_jobs: usize,
222    frozen: bool,
223    pending_scan: bool,
224    stopping: bool,
225    in_flight: BTreeSet<u64>,
226}
227
228struct ProjectionRuntimeShared {
229    path: PathBuf,
230    embedder: Option<Arc<dyn Embedder>>,
231    embedder_identity: EmbedderIdentity,
232    state: Mutex<ProjectionRuntimeState>,
233    state_cvar: Condvar,
234    queue: Mutex<VecDeque<ProjectionJob>>,
235    queue_cvar: Condvar,
236    retry_delays_ms: Mutex<Vec<u64>>,
237    /// PR-9 — ADR-0.6.0-embedder-protocol Invariant 5 per-`embed()` watchdog
238    /// deadline (ms). Read lock-free on the projection hot path. Default
239    /// `DEFAULT_EMBED_TIMEOUT_MS` (30s); the test seam
240    /// `set_embed_timeout_ms_for_test` lowers it so the hanging-embedder
241    /// test need not wait 30s. A hung embed surfaces
242    /// `RuntimeEmbedderError::Timeout`, engaging the existing retry/failure
243    /// path in `run_projection_job`.
244    embed_timeout_ms: AtomicU64,
245    /// PR-9 — engine-side embed serialization guard. The pool runs
246    /// `PROJECTION_WORKERS` workers; this guard ensures the shared
247    /// `Arc<dyn Embedder>` is invoked by at most one worker at a time.
248    ///
249    /// Rationale is SAFETY, not throughput. The engine accepts arbitrary
250    /// caller-supplied embedders (the pyo3 / napi bridges, per ADR-0.6.0)
251    /// whose `embed` is `Sync` only by trait contract; many real impls (a
252    /// GIL-bound Python model, a non-reentrant native lib, an internal cache)
253    /// are not actually safe under concurrent calls. Serializing engine-side
254    /// makes the projection robust to embedders that are not truly
255    /// concurrency-safe, without the engine having to trust each impl. The
256    /// default `CandleBgeEmbedder` was shown safe under concurrent forwards
257    /// in the PR-9 pre-flight, so for it the guard is belt-and-suspenders.
258    ///
259    /// Throughput is ~neutral: `candle` fans every `BertModel::forward` onto a
260    /// single process-wide rayon pool, so two concurrent forwards merely
261    /// share that pool (trading per-embed latency, not aggregate work) rather
262    /// than getting 2x — serializing avoids some scheduler/cache thrash but is
263    /// not a large win. (An earlier "~13x" figure compared a debug-build
264    /// unserialized run against a release-build number and was withdrawn; a
265    /// PR-9 micro-benchmark put release embeds at ~14 ms short / ~960 ms for a
266    /// 512-token doc, watchdog overhead ~0.)
267    ///
268    /// Commit/IO stays parallel across workers (see `commit_gate`); this guard
269    /// wraps only the embed call. It is held by the worker across the watchdog
270    /// call and released here, so a timed-out (abandoned) embed frees it and
271    /// cannot stall the pool — the guard owns no data, so a panic-resumed
272    /// embed that poisons it is recovered via `into_inner`.
273    ///
274    /// Deliberate trade-off (codex PR-9 CONCERN-1, accepted): on the *timeout*
275    /// path the worker drops this guard while the abandoned detached embed
276    /// thread is still running lock-free, so serialization is briefly relaxed
277    /// until that thread finishes. This is the prescribed choice over holding
278    /// the guard inside the embed thread — which would let a genuinely-hung
279    /// embed hold it forever and deadlock the whole pool, exactly the wedge
280    /// ADR-0.6.0 Invariant 5 and this slice's spec forbid. Timeouts are the
281    /// fault path only; the embed circuit breaker (`embed_circuit_open`) caps
282    /// how many such abandoned threads can be alive at once. A future slice may
283    /// replace this hard serialize with an operator-configurable embed
284    /// concurrency limit (ADR-0.6.0 Invariant 4 pool-size override) for I/O-
285    /// or GPU-bound embedders; that knob is out of PR-9 scope.
286    embed_serialize: Mutex<()>,
287    /// PR-9 — embed circuit breaker. `live_embed_threads` counts watchdog embed
288    /// threads currently alive (incremented when one is spawned, decremented
289    /// when it finishes — see `embed_with_watchdog`). Under healthy serialized
290    /// operation this is 0 or 1; it only grows when timed-out embeds are
291    /// abandoned and keep running (ADR-0.6.0 Invariant 5 forbids aborting a
292    /// running embed). When a new embed would push the live count to
293    /// `embed_circuit_threshold`, the breaker latches `embed_circuit_open` and
294    /// projection jobs fail fast WITHOUT spawning further embeds — bounding the
295    /// abandoned-thread leak to ~threshold REGARDLESS of whether the embedder
296    /// hangs on every input or only intermittently (a returning embed
297    /// decrements the count rather than resetting a streak, so an
298    /// intermittently-hanging embedder still latches as its hung threads pile
299    /// up, and a merely-slow-but-returning embedder self-clears and never
300    /// false-trips). Latches for the engine session (a reopen resets it); a
301    /// half-open/cool-down retry is future work. `threshold == 0` disables it.
302    live_embed_threads: Arc<AtomicU64>,
303    embed_circuit_open: AtomicBool,
304    embed_circuit_threshold: AtomicU64,
305    /// EU-5b — streaming mean accumulator for the per-workspace mean
306    /// pinning lifecycle (`dev/design/embedder.md` §0.3). `Some(_)` iff
307    /// the identity is MC-required AND no mean has been pinned yet on
308    /// disk. The accumulator graduates to `None` after the at-pin
309    /// commit; subsequent docs feed nothing.
310    mean_accumulator: Mutex<Option<MeanAccumulator>>,
311    /// EU-5b — `MeanVecPinned` events queued by the projection-commit
312    /// transaction for the next test-seam drain. Production callers
313    /// consume these via the `OpenReport.embedder_events` channel; the
314    /// drain seam is `Engine::drain_mean_centering_events_for_test`.
315    pending_events: Mutex<Vec<EmbedderEvent>>,
316    /// EU-5f — serializes the body of `commit_projection_outcomes` across
317    /// the `PROJECTION_WORKERS` worker connections. Each worker commits on
318    /// its own connection; holding this gate for the whole commit makes the
319    /// commit transactions totally ordered, which is what makes the at-pin
320    /// re-quantize pass provably complete (every row is wholly before or
321    /// after the unique pin tx, so none can survive un-centered). Embedding
322    /// (`run_projection_job`) runs OUTSIDE the gate and stays parallel.
323    commit_gate: Mutex<()>,
324    /// 0.7.2 PR-2bc S1 fix-1 — overridable phase-2 rerank `LIMIT` for the
325    /// search hot path. Equals `SEARCH_RERANK_LIMIT` (10) in production; a
326    /// test seam (`set_search_limit_for_test`) can RAISE it (clamped to >=10,
327    /// so it can never shrink below production semantics) so the recall
328    /// harness can pull top-(10+slack) and exclude the self-retrieving
329    /// query-source doc before truncating to 10. Production reads this atomic
330    /// (default 10) — there is NO env var read on the hot path.
331    search_limit_override: AtomicUsize,
332    /// Slice 10 / G12-recency — dedicated recency-reweight flag, **off by
333    /// default** (NOT `fusion_mode`). When set, fused hits are reweighted toward
334    /// the more recent `write_cursor` AFTER bit-KNN. Flipped by the
335    /// `set_recency_reweight_enabled_for_test` seam; no production toggle yet.
336    recency_reweight_enabled: AtomicBool,
337    /// GA-2 / Slice-40 (◆ B-1) measurement seam, **off by default**. When set,
338    /// `read_search_in_tx` returns the pre-fusion VECTOR-branch ranking
339    /// (bit-KNN K=192 + f32 rerank) verbatim — the ANN-quantization fidelity
340    /// signal — INSTEAD of the unconditional RRF-fused result. This changes
341    /// nothing for any production caller (the flag is never set outside the
342    /// `eu7` recall harness via `set_vector_stage_only_for_test`); it does NOT
343    /// reintroduce a `fusion_mode` knob (RRF stays unconditional) and does NOT
344    /// alter `fuse_rrf` / `rerank_fused` / recency. It only lets the AC-075
345    /// recall gate measure ANN+ vector top-10 vs the exact-f32 VECTOR top-10
346    /// ground truth in isolation (the quantization-FIDELITY axis the 0.90 floor
347    /// is defined to measure), not the hybrid `search()` output.
348    vector_stage_only_for_test: AtomicBool,
349    /// 0.7.2 PR-2b — debug-only fault injection: when set, `recompute_mean_in_tx`
350    /// errors AFTER writing `mean_vec` but BEFORE finishing the re-quantize
351    /// pass, so the crash-atomicity test can prove the whole recompute rolls
352    /// back (no half-recentered corpus). One-shot (cleared on consume).
353    #[cfg(debug_assertions)]
354    force_recompute_failure: AtomicBool,
355}
356
357impl std::fmt::Debug for ProjectionRuntimeShared {
358    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
359        f.debug_struct("ProjectionRuntimeShared")
360            .field("path", &self.path)
361            .field("embedder_identity", &self.embedder_identity)
362            .finish_non_exhaustive()
363    }
364}
365
366#[derive(Debug)]
367struct ProjectionRuntime {
368    shared: Arc<ProjectionRuntimeShared>,
369    dispatcher: Mutex<Option<JoinHandle<()>>>,
370    workers: Mutex<Vec<JoinHandle<()>>>,
371}
372
373impl std::fmt::Debug for Engine {
374    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
375        f.debug_struct("Engine")
376            .field("path", &self.path)
377            .field("closed", &self.closed.load(Ordering::SeqCst))
378            .field("runtime_embedder_identity", &self.runtime_embedder_identity)
379            .finish_non_exhaustive()
380    }
381}
382
383/// Per-connection profile-callback context.
384///
385/// Holds the registry handle the callback dispatches to, plus shared
386/// references to the engine's profiling toggle and slow-statement
387/// threshold. The `Arc` clones here mirror the same atomics held by
388/// `Engine`, so `set_profiling` / `set_slow_threshold_ms` mutations are
389/// visible inside the callback without restart (REQ-006a / AC-005a /
390/// AC-007b runtime-toggle contract).
391#[derive(Debug)]
392struct ProfileContext {
393    subscribers: Arc<lifecycle::SubscriberRegistry>,
394    profiling_enabled: Arc<AtomicBool>,
395    slow_threshold_ms: Arc<AtomicU64>,
396}
397
398/// Thread-affine reader worker pool (Pack 6 F.0).
399///
400/// Per `dev/design/engine.md` § Writer / reader split, reader connections
401/// must not serialize behind a single mutex. Each worker thread owns
402/// exactly one read-only `Connection` for its lifetime; `Connection`
403/// objects never cross thread boundaries after startup. `Engine::search`
404/// dispatches a request via a per-worker bounded channel using a
405/// lock-free round-robin counter on the hot path.
406struct ReaderWorkerPool {
407    senders: Vec<SyncSender<ReaderRequest>>,
408    handles: Mutex<Option<Vec<JoinHandle<()>>>>,
409    next: AtomicUsize,
410    shutdown: AtomicBool,
411    live_workers: Arc<AtomicUsize>,
412}
413
414impl std::fmt::Debug for ReaderWorkerPool {
415    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
416        f.debug_struct("ReaderWorkerPool")
417            .field("worker_count", &self.senders.len())
418            .field("live_workers", &self.live_workers.load(Ordering::Relaxed))
419            .field("shutdown", &self.shutdown.load(Ordering::Relaxed))
420            .finish()
421    }
422}
423
424/// One request handled by exactly one reader worker. The response is
425/// returned through a fresh oneshot channel so requests cannot be
426/// routed to or duplicated across workers.
427enum ReaderRequest {
428    Search {
429        compiled: fathomdb_query::CompiledQuery,
430        /// Un-centered f32 query vector serialized for `vec_f32`. Phase 2
431        /// f32 rerank uses this verbatim.
432        query_vector: Option<String>,
433        /// EU-5a2 — (possibly centered) f32 query vector for the phase 1
434        /// `vec_quantize_binary` sign-quant. Equal to `query_vector` for
435        /// non-MC-required identities (the EU-5a2 default).
436        query_vector_bin: Option<String>,
437        /// 0.7.2 PR-2bc S1 fix-1 — phase-2 rerank `LIMIT`. Read from
438        /// `ProjectionRuntimeShared::search_limit_override` (default
439        /// `SEARCH_RERANK_LIMIT` = 10, clamped >=10) by `search_inner`
440        /// before dispatch, so the worker never reads any env var.
441        search_limit: usize,
442        /// G10 — optional closed metadata filter (`None` = unfiltered, the
443        /// byte-identical-to-0.7.2 path). Applied in the phase-1 candidates
444        /// statement (vector branch) and as a Rust post-filter (text branch).
445        /// Boxed so the `ReaderRequest::Search` variant stays small (the request
446        /// rides a `Result<(), ReaderRequest>` retry channel).
447        filter: Option<Box<SearchFilter>>,
448        /// G12-recency — whether the dedicated recency reweight is enabled for
449        /// this request (read from `recency_reweight_enabled`, off by default).
450        recency_enabled: bool,
451        /// GA-2 / Slice-40 (◆ B-1) measurement seam — when true the worker
452        /// returns the pre-fusion vector-branch ranking instead of the fused
453        /// result (read from `vector_stage_only_for_test`, off by default).
454        vector_stage_only: bool,
455        /// 0.8.1 Slice 10 (R1) — raw query text for the CE reranker. Passed
456        /// from `search_inner` to `read_search_in_tx` → `rerank_fused`.
457        /// FIX-4: `Box<str>` (16 bytes) instead of `String` (24 bytes) to keep
458        /// the Search variant smaller (mirroring the boxed `filter` field).
459        raw_query: Box<str>,
460        /// 0.8.1 Slice 10 (R1) — per-request rerank depth (snapshot of
461        /// `ProjectionRuntimeShared::rerank_depth`). `0` = identity path.
462        rerank_depth: usize,
463        /// 0.8.1 Slice 30 (R3) — when `true`, run the graph-BFS arm (seeded
464        /// from top-10 fused hits, depth ≤ 3, cap 50, temporal filter) and
465        /// fuse its candidates into the final ranking via `fuse_three_arms`.
466        /// When `false` (the default), the graph arm pool is `vec![]` and
467        /// results are byte-identical to the pre-Slice-30 two-arm pipeline.
468        use_graph_arm: bool,
469        /// 0.8.5 (EXP-0) — CE-blend weight (clamped to `[0,1]` in `ce_rerank`).
470        /// `0.3` is the byte-identical default; `1.0` is the measured-parity config.
471        alpha: f64,
472        /// 0.8.5 (EXP-0) — reranked-pool size (clamped to the hit count). The
473        /// binding resolves `pool_n.unwrap_or(rerank_depth)` before dispatch.
474        pool_n: usize,
475        /// 0.8.8 EXP-OBS (Slice 5) — when `true`, capture per-arm ranks + the
476        /// fused/CE score breakdown + query trace into a `SearchResult`
477        /// `Explanation` sidecar. `false` (the default for `search`/`search_filtered`/
478        /// `search_reranked`) does ZERO extra work and returns `explanation = None`
479        /// (R-OBS-2 zero-cost; byte-identical `results`).
480        explain: bool,
481        respond: SyncSender<ReaderResponse>,
482    },
483    /// Slice 30 (G2) — active-only point lookup by `logical_id`. Returns one
484    /// slot per requested id, in request order, `None` where no active row
485    /// carries that id. Its own typed `respond` channel keeps the `Search`
486    /// `ReaderResponse` byte-identical (no Search regression).
487    GetById {
488        logical_ids: Vec<String>,
489        respond: SyncSender<rusqlite::Result<Vec<Option<NodeRecord>>>>,
490    },
491    /// Slice 30 (G3) — paginated op-store read-back over `operational_mutations`
492    /// for a `collection`, `ORDER BY id`, with a MANDATORY (already-clamped)
493    /// limit + optional after-id cursor.
494    ReadCollection {
495        collection: String,
496        after_id: Option<i64>,
497        limit: usize,
498        respond: SyncSender<rusqlite::Result<Vec<OpStoreRow>>>,
499    },
500    /// Slice 35 (G4) — list active canonical nodes of a `kind`, filtered by
501    /// zero or more `Predicate`s (AND-combined), up to `limit` rows.
502    /// Path validation already happened at `Predicate` construction time;
503    /// the worker only compiles + executes parameterized SQL.
504    ReadList {
505        kind: String,
506        predicates: Vec<Predicate>,
507        limit: usize,
508        respond: SyncSender<rusqlite::Result<Vec<NodeRecord>>>,
509    },
510    /// Slice 20 (G5) — bounded BFS from a single root node over
511    /// `canonical_edges`. Returns the set of reachable nodes (excluding the
512    /// root) within `depth` hops, limited to the hard cap 50.
513    GraphNeighbors {
514        root_logical_id: String,
515        depth: u32,
516        direction: TraversalDirection,
517        respond: SyncSender<rusqlite::Result<Vec<NodeRecord>>>,
518    },
519    /// Slice 20 (G6) — compose the previous search result with BFS expansion.
520    /// Resolves search hit `write_cursor`s to `logical_id`s, runs G5 traversal
521    /// for each root, deduplicates, and returns a `SearchExpandResult`.
522    SearchExpand {
523        search_hits: Vec<SearchHit>,
524        depth: u32,
525        respond: SyncSender<rusqlite::Result<SearchExpandResult>>,
526    },
527    /// Slice 20 test seam — run `EXPLAIN QUERY PLAN` on the BFS CTE SQL for
528    /// the given root/depth/direction and return the plan detail lines.
529    #[doc(hidden)]
530    ExplainGraphNeighbors {
531        root_logical_id: String,
532        depth: u32,
533        direction: TraversalDirection,
534        respond: SyncSender<rusqlite::Result<Vec<String>>>,
535    },
536    Shutdown,
537    /// Pack 6.G G.1 — debug-only request that asks a worker to read its
538    /// own connection's `SQLITE_DBSTATUS_LOOKASIDE_USED` and return the
539    /// high-water mark (`hiwtr` out-param). Used solely by the integration
540    /// test that asserts post-warmup lookaside slots were consumed; not
541    /// on any production path.
542    #[cfg(debug_assertions)]
543    LookasideStatus {
544        respond: SyncSender<i32>,
545    },
546    /// Pack 6.G G.3.5 — debug-only request that asks a worker to read
547    /// `SQLITE_DBSTATUS_CACHE_HIT`, `_CACHE_MISS`, and `_CACHE_USED`
548    /// off its own connection and return them as `(hit, miss, used_bytes)`.
549    /// `snapshot_label` is opaque to the worker; the caller uses it to
550    /// distinguish pre/post snapshots in its own bookkeeping.
551    #[cfg(debug_assertions)]
552    CacheStatus {
553        snapshot_label: String,
554        respond: SyncSender<(String, i32, i32, i32)>,
555    },
556}
557
558// G0 Phase-2: the Search response carries a 4th element — the graph-arm frontier
559// meter (`GraphFrontierStats`). It rides the internal channel but is dropped before
560// `SearchResult` is built (kept OFF the governed surface); the
561// `_graph_frontier_stats_for_test` seam captures it. Default (all-zero) on non-graph paths.
562// 0.8.8 EXP-OBS (Slice 5): the Search response carries a 5th element — the opt-in
563// retrieval `Explanation` (`None` on every default `explain=false` path; `Some`
564// only on the `search_explained` path). Like the `GraphFrontierStats` 4th element
565// it rides the internal channel as a side-channel; unlike it, the explanation IS
566// surfaced (onto `SearchResult.explanation`) when requested.
567type ReaderResponse = rusqlite::Result<(
568    u64,
569    Option<SoftFallback>,
570    Vec<SearchHit>,
571    GraphFrontierStats,
572    Option<Explanation>,
573)>;
574
575/// Pack 6.G G.3.5 — per-worker cache-pressure snapshot. Carried only on
576/// the debug-only `CacheStatus` broadcast path and the test accessor;
577/// not part of the public 0.6.0 surface.
578#[cfg(debug_assertions)]
579#[doc(hidden)]
580#[derive(Clone, Debug)]
581pub struct CacheStatusReply {
582    pub worker_idx: usize,
583    pub snapshot_label: String,
584    pub cache_hit: i32,
585    pub cache_miss: i32,
586    pub cache_used_bytes: i32,
587}
588
589/// Per-worker outbound channel capacity. Round-robin dispatch keeps
590/// queue depth at ~0 on hot paths; the small slack absorbs jitter
591/// without a runtime mutex.
592const READER_WORKER_CHANNEL_CAPACITY: usize = 4;
593
594impl ReaderWorkerPool {
595    fn new(connections: Vec<Connection>) -> Self {
596        let live_workers = Arc::new(AtomicUsize::new(0));
597        let mut senders = Vec::with_capacity(connections.len());
598        let mut handles = Vec::with_capacity(connections.len());
599        for (idx, connection) in connections.into_iter().enumerate() {
600            let (tx, rx) = mpsc::sync_channel::<ReaderRequest>(READER_WORKER_CHANNEL_CAPACITY);
601            let live = Arc::clone(&live_workers);
602            let handle = thread::Builder::new()
603                .name(format!("fathomdb-reader-{idx}"))
604                .spawn(move || reader_worker_loop(connection, rx, live))
605                .expect("spawn reader worker");
606            senders.push(tx);
607            handles.push(handle);
608        }
609        Self {
610            senders,
611            handles: Mutex::new(Some(handles)),
612            next: AtomicUsize::new(0),
613            shutdown: AtomicBool::new(false),
614            live_workers,
615        }
616    }
617
618    fn worker_count(&self) -> usize {
619        self.senders.len()
620    }
621
622    fn live_count(&self) -> usize {
623        self.live_workers.load(Ordering::SeqCst)
624    }
625
626    /// Pack 6.G G.1 — broadcast a `LookasideStatus` request to every
627    /// worker (not round-robin) and collect each worker's
628    /// `SQLITE_DBSTATUS_LOOKASIDE_USED`. Used only by the debug
629    /// integration test for post-warmup lookaside-slot consumption.
630    #[cfg(debug_assertions)]
631    fn lookaside_used_per_worker(&self) -> Vec<i32> {
632        let mut results = Vec::with_capacity(self.senders.len());
633        for sender in &self.senders {
634            let (tx, rx) = mpsc::sync_channel::<i32>(1);
635            if sender.send(ReaderRequest::LookasideStatus { respond: tx }).is_ok() {
636                results.push(rx.recv().unwrap_or(-1));
637            } else {
638                results.push(-1);
639            }
640        }
641        results
642    }
643
644    /// Pack 6.G G.3.5 — broadcast a `CacheStatus` request to every
645    /// worker and collect each worker's `(cache_hit, cache_miss,
646    /// cache_used_bytes)` triple. Same broadcast pattern as G.1's
647    /// `lookaside_used_per_worker`. Returns one `CacheStatusReply` per
648    /// worker in worker-index order.
649    #[cfg(debug_assertions)]
650    fn cache_status_per_worker(&self, snapshot_label: &str) -> Vec<CacheStatusReply> {
651        let mut results = Vec::with_capacity(self.senders.len());
652        for (idx, sender) in self.senders.iter().enumerate() {
653            let (tx, rx) = mpsc::sync_channel::<(String, i32, i32, i32)>(1);
654            let request = ReaderRequest::CacheStatus {
655                snapshot_label: snapshot_label.to_string(),
656                respond: tx,
657            };
658            if sender.send(request).is_ok() {
659                if let Ok((label, hit, miss, used)) = rx.recv() {
660                    results.push(CacheStatusReply {
661                        worker_idx: idx,
662                        snapshot_label: label,
663                        cache_hit: hit,
664                        cache_miss: miss,
665                        cache_used_bytes: used,
666                    });
667                    continue;
668                }
669            }
670            results.push(CacheStatusReply {
671                worker_idx: idx,
672                snapshot_label: snapshot_label.to_string(),
673                cache_hit: -1,
674                cache_miss: -1,
675                cache_used_bytes: -1,
676            });
677        }
678        results
679    }
680
681    /// Hot path. Lock-free dispatch: `AtomicUsize::fetch_add` selects
682    /// the worker, then a single `SyncSender::send` enqueues the
683    /// request. No global mutex is taken on the request path.
684    // The `Search` variant contains a SyncSender and boxed fields (filter, raw_query);
685    // even after FIX-4 (raw_query: Box<str>), the variant remains large due to the
686    // SyncSender channel ownership. The Err return is only ever a no-worker/shutdown
687    // signal, never heap-allocated repeatedly, so the allow is justified by the
688    // channel ownership model.
689    #[allow(clippy::result_large_err)]
690    fn dispatch(&self, request: ReaderRequest) -> Result<(), ReaderRequest> {
691        if self.shutdown.load(Ordering::Relaxed) {
692            return Err(request);
693        }
694        let n = self.senders.len();
695        if n == 0 {
696            return Err(request);
697        }
698        let idx = self.next.fetch_add(1, Ordering::Relaxed) % n;
699        self.senders[idx].send(request).map_err(|err| err.0)
700    }
701
702    /// Signal every worker to exit and join its thread. Idempotent —
703    /// safe to call from `Engine::close` and again from
704    /// `ReaderWorkerPool::Drop`.
705    fn shutdown(&self) {
706        if self.shutdown.swap(true, Ordering::SeqCst) {
707            return;
708        }
709        for sender in &self.senders {
710            let _ = sender.send(ReaderRequest::Shutdown);
711        }
712        if let Ok(mut slot) = self.handles.lock() {
713            if let Some(handles) = slot.take() {
714                for handle in handles {
715                    let _ = handle.join();
716                }
717            }
718        }
719    }
720}
721
722impl Drop for ReaderWorkerPool {
723    fn drop(&mut self) {
724        self.shutdown();
725    }
726}
727
728fn reader_worker_loop(
729    mut connection: Connection,
730    rx: Receiver<ReaderRequest>,
731    live_workers: Arc<AtomicUsize>,
732) {
733    live_workers.fetch_add(1, Ordering::SeqCst);
734    // Drop guard so the live counter decrements even on panic.
735    struct LiveGuard(Arc<AtomicUsize>);
736    impl Drop for LiveGuard {
737        fn drop(&mut self) {
738            self.0.fetch_sub(1, Ordering::SeqCst);
739        }
740    }
741    let _guard = LiveGuard(live_workers);
742
743    while let Ok(request) = rx.recv() {
744        match request {
745            ReaderRequest::Shutdown => break,
746            ReaderRequest::Search {
747                compiled,
748                query_vector,
749                query_vector_bin,
750                search_limit,
751                filter,
752                recency_enabled,
753                vector_stage_only,
754                raw_query,
755                rerank_depth,
756                use_graph_arm,
757                alpha,
758                pool_n,
759                explain,
760                respond,
761            } => {
762                let result = read_search_in_tx(
763                    &mut connection,
764                    &compiled,
765                    query_vector.as_deref(),
766                    query_vector_bin.as_deref(),
767                    search_limit,
768                    filter.as_deref(),
769                    recency_enabled,
770                    vector_stage_only,
771                    &raw_query,
772                    rerank_depth,
773                    use_graph_arm,
774                    alpha,
775                    pool_n,
776                    explain,
777                );
778                // Receiver may have been dropped if the caller went
779                // away; nothing to do in that case.
780                let _ = respond.send(result);
781            }
782            ReaderRequest::GetById { logical_ids, respond } => {
783                let result = read_get_by_id_in_tx(&mut connection, &logical_ids);
784                let _ = respond.send(result);
785            }
786            ReaderRequest::ReadCollection { collection, after_id, limit, respond } => {
787                let result = read_collection_in_tx(&mut connection, &collection, after_id, limit);
788                let _ = respond.send(result);
789            }
790            ReaderRequest::ReadList { kind, predicates, limit, respond } => {
791                let result = read_list_in_tx(&mut connection, &kind, &predicates, limit);
792                let _ = respond.send(result);
793            }
794            ReaderRequest::GraphNeighbors { root_logical_id, depth, direction, respond } => {
795                let result =
796                    graph_neighbors_in_tx(&mut connection, &root_logical_id, depth, direction);
797                let _ = respond.send(result);
798            }
799            ReaderRequest::SearchExpand { search_hits, depth, respond } => {
800                let result = search_expand_in_tx(&mut connection, &search_hits, depth);
801                let _ = respond.send(result);
802            }
803            ReaderRequest::ExplainGraphNeighbors { root_logical_id, depth, direction, respond } => {
804                let result = explain_graph_neighbors_in_tx(
805                    &mut connection,
806                    &root_logical_id,
807                    depth,
808                    direction,
809                );
810                let _ = respond.send(result);
811            }
812            #[cfg(debug_assertions)]
813            ReaderRequest::LookasideStatus { respond } => {
814                let _ = respond.send(read_lookaside_used_hiwtr(&connection));
815            }
816            #[cfg(debug_assertions)]
817            ReaderRequest::CacheStatus { snapshot_label, respond } => {
818                let (hit, miss, used) = read_cache_status(&connection);
819                let _ = respond.send((snapshot_label, hit, miss, used));
820            }
821        }
822    }
823
824    // Per `dev/design/engine.md` § Close path, uninstall the profile
825    // callback before dropping the connection so SQLite cannot fire
826    // one last callback against a `ProfileContext` whose Box is about
827    // to free.
828    uninstall_profile_callback(&connection);
829    drop(connection);
830}
831
832impl ProjectionRuntime {
833    fn new(
834        path: PathBuf,
835        embedder: Option<Arc<dyn Embedder>>,
836        embedder_identity: EmbedderIdentity,
837        mean_already_pinned: bool,
838    ) -> Self {
839        // EU-5b/EU-5f — only allocate the streaming accumulator when the
840        // workspace's identity is MC-required AND no mean has been pinned
841        // yet on disk. Allocating it for an already-pinned workspace would
842        // let a later 256-doc run RE-pin and overwrite the compute-once
843        // mean (violating `dev/design/embedder.md` §0.3). Other identities
844        // pay no memory cost (`Option::None`).
845        let mc_required = identity_requires_mean_centering(&embedder_identity);
846        let mean_accumulator = if mc_required && !mean_already_pinned {
847            Some(MeanAccumulator::new(embedder_identity.dimension as usize))
848        } else {
849            None
850        };
851        let shared = Arc::new(ProjectionRuntimeShared {
852            path,
853            embedder,
854            embedder_identity,
855            state: Mutex::new(ProjectionRuntimeState::default()),
856            state_cvar: Condvar::new(),
857            queue: Mutex::new(VecDeque::new()),
858            queue_cvar: Condvar::new(),
859            retry_delays_ms: Mutex::new(DEFAULT_PROJECTION_RETRY_DELAYS_MS.to_vec()),
860            embed_timeout_ms: AtomicU64::new(DEFAULT_EMBED_TIMEOUT_MS),
861            embed_serialize: Mutex::new(()),
862            live_embed_threads: Arc::new(AtomicU64::new(0)),
863            embed_circuit_open: AtomicBool::new(false),
864            embed_circuit_threshold: AtomicU64::new(DEFAULT_EMBED_CIRCUIT_THRESHOLD),
865            mean_accumulator: Mutex::new(mean_accumulator),
866            pending_events: Mutex::new(Vec::new()),
867            commit_gate: Mutex::new(()),
868            search_limit_override: AtomicUsize::new(SEARCH_RERANK_LIMIT),
869            recency_reweight_enabled: AtomicBool::new(false),
870            vector_stage_only_for_test: AtomicBool::new(false),
871            #[cfg(debug_assertions)]
872            force_recompute_failure: AtomicBool::new(false),
873        });
874
875        let dispatcher_shared = Arc::clone(&shared);
876        let dispatcher = thread::spawn(move || projection_dispatcher_loop(dispatcher_shared));
877
878        let mut workers = Vec::with_capacity(PROJECTION_WORKERS);
879        for _ in 0..PROJECTION_WORKERS {
880            let worker_shared = Arc::clone(&shared);
881            workers.push(thread::spawn(move || projection_worker_loop(worker_shared)));
882        }
883
884        Self { shared, dispatcher: Mutex::new(Some(dispatcher)), workers: Mutex::new(workers) }
885    }
886
887    fn notify_new_work(&self) {
888        if let Ok(mut state) = self.shared.state.lock() {
889            state.pending_scan = true;
890            self.shared.state_cvar.notify_all();
891        }
892    }
893
894    fn set_frozen(&self, frozen: bool) {
895        if let Ok(mut state) = self.shared.state.lock() {
896            state.frozen = frozen;
897            if !frozen {
898                state.pending_scan = true;
899            }
900            self.shared.state_cvar.notify_all();
901        }
902    }
903
904    fn wait_for_idle(&self, timeout_ms: u64) -> bool {
905        let deadline = Instant::now() + Duration::from_millis(timeout_ms);
906        let mut state = match self.shared.state.lock() {
907            Ok(state) => state,
908            Err(_) => return false,
909        };
910        loop {
911            if state.active_jobs == 0 && state.queued_jobs == 0 {
912                drop(state);
913                if !database_has_pending_projection_work(&self.shared.path).unwrap_or(true) {
914                    return true;
915                }
916                state = match self.shared.state.lock() {
917                    Ok(state) => state,
918                    Err(_) => return false,
919                };
920            }
921            let now = Instant::now();
922            if now >= deadline {
923                return false;
924            }
925            let wait = deadline.saturating_duration_since(now);
926            let Ok((next_state, _)) = self.shared.state_cvar.wait_timeout(state, wait) else {
927                return false;
928            };
929            state = next_state;
930        }
931    }
932
933    fn set_retry_delays_for_test(&self, delays_ms: &[u64]) {
934        if let Ok(mut delays) = self.shared.retry_delays_ms.lock() {
935            *delays = delays_ms.to_vec();
936        }
937    }
938
939    fn set_embed_timeout_ms_for_test(&self, timeout_ms: u64) {
940        self.shared.embed_timeout_ms.store(timeout_ms, Ordering::Relaxed);
941    }
942
943    fn set_embed_circuit_threshold_for_test(&self, threshold: u64) {
944        self.shared.embed_circuit_threshold.store(threshold, Ordering::Relaxed);
945    }
946
947    fn embed_circuit_open_for_test(&self) -> bool {
948        self.shared.embed_circuit_open.load(Ordering::Relaxed)
949    }
950
951    fn stop(&self) {
952        if let Ok(mut state) = self.shared.state.lock() {
953            if state.stopping {
954                return;
955            }
956            state.stopping = true;
957            state.pending_scan = false;
958            self.shared.state_cvar.notify_all();
959        }
960        if let Ok(mut queue) = self.shared.queue.lock() {
961            queue.clear();
962            self.shared.queue_cvar.notify_all();
963        }
964
965        if let Ok(mut dispatcher) = self.dispatcher.lock() {
966            if let Some(handle) = dispatcher.take() {
967                let _ = handle.join();
968            }
969        }
970        if let Ok(mut workers) = self.workers.lock() {
971            for handle in workers.drain(..) {
972                let _ = handle.join();
973            }
974        }
975    }
976}
977
978#[derive(Clone, Debug, Eq, PartialEq)]
979pub struct OpenReport {
980    pub schema_version_before: u32,
981    pub schema_version_after: u32,
982    pub migration_steps: Vec<MigrationStepReport>,
983    pub embedder_warmup_ms: u64,
984    pub query_backend: &'static str,
985    pub default_embedder: EmbedderIdentity,
986    /// Total wall time the loader spent materializing default-embedder
987    /// weights — covers HF GETs, sha256 verification, atomic rename,
988    /// parent-dir fsync (POSIX), and cache directory writes. This is
989    /// the "engine open paid by the embedder" envelope, useful for SLA
990    /// budgeting; it is intentionally wider than just the bytes-flowing
991    /// time so callers see the full first-use cost.
992    ///
993    /// `Some(ms)` when network bytes flowed (`bytes_downloaded > 0`);
994    /// `None` for caller-supplied embedders (loader bypassed) and on
995    /// full cache hits (no bytes flowed). For pure per-file network
996    /// analysis, use the `DefaultEmbedderDownload` events on
997    /// [`embedder_events`](Self::embedder_events) — each event carries
998    /// the file's bytes + sha256 + cache path.
999    pub embedder_download_ms: Option<u64>,
1000    /// Structured loader events (`dev/design/embedder.md` §7). Empty for
1001    /// caller-supplied embedders; populated from `LoadedWeights.events`
1002    /// for the Default path.
1003    pub embedder_events: Vec<EmbedderEvent>,
1004    /// Static identity capability (`dev/design/embedder.md` §0.6). True
1005    /// iff the live embedder identity is the bge-small default, which is
1006    /// the only identity that ships with the EU-5a2 mean-centering apply
1007    /// paths. `false` for `fathomdb-noop` and for any other
1008    /// caller-supplied identity. EU-5b's identity flip makes the Default
1009    /// path return `true` here.
1010    pub embedder_mean_centering_required: bool,
1011    /// Dynamic workspace state (`dev/design/embedder.md` §0.6). True iff
1012    /// `_fathomdb_embedder_profiles.mean_vec IS NOT NULL` for the default
1013    /// profile. EU-5a2 reads from the schema column added in migration
1014    /// step 10; the value is dimension-validated (§0.2) at open time
1015    /// and fails closed via `EmbedderIdentityMismatch` on drift.
1016    pub embedder_mean_vec_pinned: bool,
1017}
1018
1019#[derive(Debug)]
1020pub struct OpenedEngine {
1021    pub engine: Engine,
1022    pub report: OpenReport,
1023}
1024
1025/// EU-5b — loader-supplied open-time telemetry threaded into
1026/// `OpenReport.embedder_download_ms` and `OpenReport.embedder_events`.
1027#[derive(Clone, Debug)]
1028struct LoaderInfo {
1029    download_ms: Option<u64>,
1030    events: Vec<EmbedderEvent>,
1031}
1032
1033#[derive(Clone, Debug, Eq, PartialEq)]
1034pub struct WriteReceipt {
1035    /// The batch high-water cursor — the `write_cursor` of the last row written
1036    /// (also the engine's new `next_cursor`). Unchanged from 0.7.x.
1037    pub cursor: u64,
1038    /// G0 (Slice 15) — the per-row `write_cursor` of each row in the batch, 1:1
1039    /// with input order. This is the `write_cursor`-as-row-id identity carrier
1040    /// (HITL-accepted for 0.8.0; a dedicated `row_id` is deferred). For an
1041    /// N-row batch this is `[cursor-N+1, …, cursor]`.
1042    pub row_cursors: Vec<u64>,
1043    /// G8 (Slice 20 / F10) — count of edge endpoints in this batch that point at
1044    /// a non-existent **or superseded** canonical node. An endpoint is dangling
1045    /// when no **active** node (`superseded_at IS NULL`) carries its `logical_id`;
1046    /// `from_id` and `to_id` are probed independently, so one edge contributes 0,
1047    /// 1, or 2. This is **informational** (default FLAG-AND-COUNT: the batch
1048    /// commits regardless) and `0` whenever the batch committed no active edges.
1049    pub dangling_edge_endpoints: u64,
1050}
1051
1052/// Soft-fallback signal carried on hybrid `search` results.
1053///
1054/// Per `dev/design/retrieval.md` § Soft-fallback signal, this record is
1055/// present only when one non-essential branch could not contribute. Total
1056/// request failure is not expressed via this carrier.
1057#[derive(Clone, Debug, Eq, PartialEq)]
1058pub struct SoftFallback {
1059    pub branch: SoftFallbackBranch,
1060}
1061
1062/// Which retrieval branch produced a hit (or could not contribute).
1063///
1064/// `Vector` = ANN vector branch (node bodies); `Text` = node-body FTS branch;
1065/// `TextEdge` = edge-body hit (FTS via `search_index_edges` OR vector-projected
1066/// edge facts — both produce the same kind="edge_fact" row shape and share the
1067/// same downstream handling in `search_expand_in_tx`). `Vector`/`Text` also
1068/// used as soft-fallback signal when the respective branch is empty.
1069/// `GraphArm` = R3 (Slice 30) BFS-reachable node from the temporal fact-edge
1070/// graph arm. Owned by `dev/design/retrieval.md`.
1071#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1072pub enum SoftFallbackBranch {
1073    Vector,
1074    Text,
1075    /// G11 (Slice 15) — edge-body hit from `search_index_edges` FTS or from
1076    /// `vector_default` edge-fact projection. `kind = "edge_fact"` in both cases.
1077    TextEdge,
1078    /// R3 (Slice 30) — BFS-reachable node from the temporal fact-edge graph arm.
1079    /// Only present when `use_graph_arm = true`. Nodes in the graph arm were NOT
1080    /// in the initial vector/text fused result (newly-reached nodes only).
1081    GraphArm,
1082}
1083
1084/// A single structured search hit (G1 / AC-057a-clean).
1085///
1086/// Both retrieval branches emit this shape. `id` is the canonical row's
1087/// `write_cursor` — the **interim** identity carrier per
1088/// `dev/adr/ADR-0.8.0-canonical-identity-substrate.md`; it swaps to
1089/// `logical_id` at the G0 keystone (Slice 15) with no carrier reshape.
1090/// `score` is the **G9 RRF-fused** relevance (`Σ 1/(RRF_K + rank)` over the
1091/// branches that surfaced this body; higher = more relevant), optionally
1092/// recency-reweighted when the dedicated recency flag is on. Raw `vec_distance_l2`
1093/// and `bm25()` are fused on **rank**, never compared raw (they are not
1094/// comparable). `branch` tags which retrieval branch produced the representative
1095/// hit (vector-first when a body is surfaced by both).
1096///
1097/// `source_id` (G0 Phase-2 / BLOCK-2) carries the source-document provenance of
1098/// a hit. For the GraphArm branch it is the **traversed edge's** `source_id`
1099/// (the session the fact-edge was extracted from), enabling `doc_id_of` to
1100/// resolve a graph-reached entity back to a gold session id. For every two-arm
1101/// (vector/text/edge) hit it is `None` — those resolve via the cursor map. The
1102/// field is additive and nullable; `use_graph_arm=false` results are byte-stable
1103/// (every hit `source_id == None`).
1104///
1105/// Derives `Clone, Debug, PartialEq` but **not `Eq`** — `score: f64` forbids
1106/// total equality.
1107#[derive(Clone, Debug, PartialEq)]
1108pub struct SearchHit {
1109    pub id: u64,
1110    pub kind: String,
1111    pub body: String,
1112    pub score: f64,
1113    pub branch: SoftFallbackBranch,
1114    pub source_id: Option<String>,
1115    /// 0.8.5 (EXP-0) — per-candidate cross-encoder score `ce_norm =
1116    /// sigmoid(ce_logit) ∈ [0,1]`. `Some` ONLY for hits inside the reranked pool
1117    /// (the top `pool_n` when the CE model is loaded); `None` for the unreranked
1118    /// remainder, the `rerank_depth == 0` identity path, an empty list, and the
1119    /// no-CE-model soft-fallback. Additive + nullable: it never participates in
1120    /// ranking, so default-path ordering/scores stay byte-stable.
1121    pub ce_score: Option<f64>,
1122}
1123
1124/// G0 Phase-2 (E0a / BLOCK-1) — graph-arm frontier instrumentation. A
1125/// **side-channel** meter (deliberately NOT a `SearchResult`/`SearchHit` field —
1126/// byte stability) that proves whether the graph arm seeds a non-empty frontier.
1127/// Under the current doc-seeded path the frontier is empty (doc nodes carry
1128/// `logical_id = NULL`), so `seeds_resolved == 0` and `resolved_seed_rate == 0.0`
1129/// — this meter is the measurement that proves it (and, post-C1, the 0→>0 flip).
1130///
1131/// `resolved_seed_rate = seeds_resolved / seeds_considered`, with `0/0 → 0.0`.
1132#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1133pub struct GraphFrontierStats {
1134    /// Hits inspected as seed candidates (the `take(SEED_N)` window, skipping TextEdge).
1135    pub seeds_considered: u32,
1136    /// Seed candidates that resolved to an active `logical_id` (pushed onto the frontier).
1137    pub seeds_resolved: u32,
1138    /// Whether the BFS frontier was non-empty after seeding.
1139    pub frontier_nonempty: bool,
1140    /// Number of graph-arm `SearchHit`s emitted (reachable, not already in the two-arm result).
1141    pub graph_candidates_emitted: u32,
1142}
1143
1144impl GraphFrontierStats {
1145    /// `seeds_resolved / seeds_considered`, defined as `0.0` when nothing was considered.
1146    pub fn resolved_seed_rate(&self) -> f64 {
1147        if self.seeds_considered == 0 {
1148            0.0
1149        } else {
1150            f64::from(self.seeds_resolved) / f64::from(self.seeds_considered)
1151        }
1152    }
1153}
1154
1155/// Slice 30 (G2) — an active canonical node row returned by `read.get` /
1156/// `read.get_many`.
1157///
1158/// `logical_id` is the queried stable identity (echoed). `write_cursor` is the
1159/// interim id carrier (same column `SearchHit.id` carries). Only ACTIVE rows
1160/// (`superseded_at IS NULL`) are ever materialised into this shape; a missing or
1161/// superseded `logical_id` is a normal absence (`None`), never an error.
1162#[derive(Clone, Debug, Eq, PartialEq)]
1163pub struct NodeRecord {
1164    pub logical_id: String,
1165    pub kind: String,
1166    pub body: String,
1167    pub write_cursor: u64,
1168}
1169
1170/// Slice 30 (G3) — one `operational_mutations` row returned by `read.collection`
1171/// / `read.mutations`. `id` is the autoincrement PK (the after-id cursor key).
1172#[derive(Clone, Debug, Eq, PartialEq)]
1173pub struct OpStoreRow {
1174    pub id: i64,
1175    pub collection: String,
1176    pub record_key: String,
1177    pub op_kind: String,
1178    pub payload: String,
1179    pub schema_id: Option<String>,
1180    pub write_cursor: u64,
1181}
1182
1183/// Hybrid `search` result. `results` carries structured [`SearchHit`]s in
1184/// vector-first, dedup-on-body order. Derives `Clone, Debug, PartialEq` but
1185/// **not `Eq`** — each hit carries a `score: f64`.
1186#[derive(Clone, Debug, PartialEq)]
1187// 0.8.8 EXP-OBS (field-set ratification): non_exhaustive so future additive fields
1188// (e.g. the deferred QueryTrace.timings_ms, Q3) are non-breaking. All construction
1189// is in-crate (engine + tests); external crates read fields only.
1190#[non_exhaustive]
1191pub struct SearchResult {
1192    pub projection_cursor: u64,
1193    pub soft_fallback: Option<SoftFallback>,
1194    pub results: Vec<SearchHit>,
1195    /// 0.8.8 EXP-OBS (Slice 5) — opt-in retrieval explanation **sidecar**.
1196    /// `Some` ONLY on the `search_explained` path; `None` for every default
1197    /// (`explain=false`) search, so `results` + `projection_cursor` stay
1198    /// byte-identical to the pre-0.8.8 shape (R-OBS-2 zero-cost contract,
1199    /// HITL-ratified sidecar carrier — see
1200    /// `dev/design/0.8.8-explain-and-telemetry-adr.md` §A.2). Field-set is
1201    /// PROPOSED/ratification-pending; additive inside `Explanation` so later
1202    /// amendments do not reshape `SearchResult`/`SearchHit`.
1203    pub explanation: Option<Explanation>,
1204}
1205
1206/// 0.8.8 EXP-OBS (Slice 5) — the opt-in retrieval explanation payload returned
1207/// behind `search_explained` (the `explain=true` surface). Built from the
1208/// engine's OWN fusion/rerank machinery (`fuse_three_arms` per-arm ranks,
1209/// `ce_rerank` blend components) — no parallel machinery (R-OBS-3). Carries a
1210/// query-level [`QueryTrace`] plus a per-hit breakdown parallel to (and in the
1211/// same order as) `SearchResult.results`.
1212///
1213/// Derives `Clone, Debug, PartialEq` but **not `Eq`** — scores are `f64`.
1214#[derive(Clone, Debug, PartialEq)]
1215#[non_exhaustive] // 0.8.8 field-set ratification — additive-safe sidecar
1216pub struct Explanation {
1217    pub trace: QueryTrace,
1218    pub per_hit: Vec<PerHitExplain>,
1219}
1220
1221/// 0.8.8 EXP-OBS (Slice 5) — query-level retrieval trace. Reuses the existing
1222/// `search_reranked` knobs + the active embedder identity; timings are coarse
1223/// per-stage wall-clock (monotonic) captured only on the explain path.
1224#[derive(Clone, Debug, PartialEq)]
1225// 0.8.8 field-set ratification — HARD: leaf absorbs the deferred `timings_ms` (Q3)
1226// and any future trace field without a contract break.
1227#[non_exhaustive]
1228pub struct QueryTrace {
1229    /// Query LENGTH only (chars) — never the query text (privacy; ADR §C).
1230    pub query_chars: u32,
1231    /// Final result limit (`SEARCH_RERANK_LIMIT`-derived `final_limit`).
1232    pub k: u32,
1233    pub rerank_depth: u32,
1234    pub pool_n: u32,
1235    pub alpha: f64,
1236    pub use_graph_arm: bool,
1237    /// Recency reweight (the dedicated G12 flag) was applied.
1238    pub recency: bool,
1239    /// Active embedder identity `name@revision` (+ dim), or empty when none.
1240    pub embedder_id: String,
1241    /// The CE cross-encoder actually reranked the pool (model loaded + depth>0).
1242    pub ce_active: bool,
1243    /// Per-arm input hit counts (pre-fusion).
1244    pub vector_hits: u32,
1245    pub text_hits: u32,
1246    pub graph_hits: u32,
1247}
1248
1249/// 0.8.8 EXP-OBS (Slice 5) — per-hit provenance + score breakdown. One entry per
1250/// returned `SearchHit`, same order. `*_rank` is the 0-based rank the hit's body
1251/// held in that arm's pre-fusion list (`None` = absent from that arm).
1252///
1253/// Derives `Clone, Debug, PartialEq` but **not `Eq`** — scores are `f64`.
1254#[derive(Clone, Debug, PartialEq)]
1255// 0.8.8 field-set ratification — HARD: leaf absorbs future arms / score components.
1256#[non_exhaustive]
1257pub struct PerHitExplain {
1258    pub id: u64,
1259    /// Winning arm after RRF dedup (vector-first), == `SearchHit.branch`.
1260    pub arm: SoftFallbackBranch,
1261    pub vector_rank: Option<u32>,
1262    pub text_rank: Option<u32>,
1263    pub graph_rank: Option<u32>,
1264    /// Raw RRF fused score AFTER recency reweight, BEFORE CE blend (the value
1265    /// `ce_rerank` normalizes). Faithful to the engine computation — downstream
1266    /// may normalize. (ADR §A.4 Q1: raw exposed; normalization deferred.)
1267    pub fused_score: f64,
1268    /// In-pool cross-encoder score `sigmoid(ce_logit) ∈ [0,1]`, == the returned
1269    /// `SearchHit.ce_score`; `None` outside the reranked pool / no-CE path.
1270    pub ce_score: Option<f64>,
1271    /// Final blended score, == the returned `SearchHit.score`.
1272    pub blended: f64,
1273}
1274
1275// ===== G4 filter grammar types (Slice 35) ===============================
1276
1277/// G4 (Slice 35) — scalar value for [`Predicate`] comparisons.
1278///
1279/// Shared vocabulary with G10 — defined once at the `fathomdb-engine` crate
1280/// root so reserved-gap 37 (full G4↔G10 unification) can import it without a
1281/// path change. Derives `Clone, Debug, PartialEq` per the ADR contract
1282/// (D-F1 exhaustiveness: exactly `{Text, Integer, Bool}`).
1283#[derive(Clone, Debug, PartialEq)]
1284pub enum ScalarValue {
1285    Text(String),
1286    Integer(i64),
1287    Bool(bool),
1288}
1289
1290/// G4 (Slice 35) — comparison operator for [`Predicate::JsonPathCompare`].
1291///
1292/// Shared vocabulary (same crate-root export as `ScalarValue`). Closed
1293/// enum: `{Gt, Gte, Lt, Lte}` per D-F1. Derives `Clone, Debug, PartialEq`.
1294#[derive(Clone, Debug, PartialEq)]
1295pub enum ComparisonOp {
1296    Gt,
1297    Gte,
1298    Lt,
1299    Lte,
1300}
1301
1302/// Allowed JSON paths for [`Predicate`] constructors. The SQL compilation in
1303/// [`Engine::read_list`] uses the **allowlist constant** (a server-side literal),
1304/// never the caller-supplied string, so only paths in this set reach
1305/// `json_extract`. Callers receive [`EngineError::InvalidFilter`] for any
1306/// non-allowlisted path — no passthrough, no panic.
1307///
1308/// To extend: add an entry here. No API change is needed; the constructor
1309/// accepts the new path string once it appears in this array.
1310const PREDICATE_PATH_ALLOWLIST: &[&str] =
1311    &["$.status", "$.priority", "$.tags", "$.kind", "$.created_at"];
1312
1313/// G4 (Slice 35) — closed typed predicate for [`Engine::read_list`] filter.
1314///
1315/// Exactly two variants per ADR D-F1 (`{JsonPathEq, JsonPathCompare}`).
1316/// The fused variants (`JsonPathFused*`) and all `*_unchecked` builders are
1317/// explicitly EXCLUDED (ADR D-F2). Use the validated constructors
1318/// [`Predicate::json_path_eq`] / [`Predicate::json_path_compare`]; they
1319/// enforce the path allowlist at construction time.
1320///
1321/// Multiple predicates in [`Engine::read_list`] are combined by implicit AND
1322/// (D-F5). Compilation target: `json_extract(body, '$.field') <op> ?` with
1323/// a bound parameter (never interpolated — injection-safe per D-F4).
1324#[derive(Clone, Debug, PartialEq)]
1325pub enum Predicate {
1326    /// `json_extract(body, path) = ?` (equality).
1327    JsonPathEq { path: String, value: ScalarValue },
1328    /// `json_extract(body, path) <op> ?` (inequality).
1329    JsonPathCompare { path: String, op: ComparisonOp, value: ScalarValue },
1330}
1331
1332impl Predicate {
1333    /// Construct a `JsonPathEq` predicate with allowlist validation.
1334    ///
1335    /// Returns [`EngineError::InvalidFilter`] if `path` is not in
1336    /// [`PREDICATE_PATH_ALLOWLIST`]; never panics on bad input.
1337    pub fn json_path_eq(path: impl Into<String>, value: ScalarValue) -> Result<Self, EngineError> {
1338        let path = path.into();
1339        if !PREDICATE_PATH_ALLOWLIST.contains(&path.as_str()) {
1340            return Err(EngineError::InvalidFilter {
1341                reason: format!("path '{path}' is not in the predicate path allowlist"),
1342            });
1343        }
1344        Ok(Self::JsonPathEq { path, value })
1345    }
1346
1347    /// Construct a `JsonPathCompare` predicate with allowlist validation.
1348    ///
1349    /// Returns [`EngineError::InvalidFilter`] if `path` is not in
1350    /// [`PREDICATE_PATH_ALLOWLIST`]; never panics on bad input.
1351    pub fn json_path_compare(
1352        path: impl Into<String>,
1353        op: ComparisonOp,
1354        value: ScalarValue,
1355    ) -> Result<Self, EngineError> {
1356        let path = path.into();
1357        if !PREDICATE_PATH_ALLOWLIST.contains(&path.as_str()) {
1358            return Err(EngineError::InvalidFilter {
1359                reason: format!("path '{path}' is not in the predicate path allowlist"),
1360            });
1361        }
1362        Ok(Self::JsonPathCompare { path, op, value })
1363    }
1364
1365    /// Return the validated path string for use in SQL compilation.
1366    /// This always returns a path that is in `PREDICATE_PATH_ALLOWLIST`.
1367    fn path(&self) -> &str {
1368        match self {
1369            Self::JsonPathEq { path, .. } => path.as_str(),
1370            Self::JsonPathCompare { path, .. } => path.as_str(),
1371        }
1372    }
1373
1374    /// Compile this predicate to a SQL WHERE clause fragment.
1375    /// The path is validated at construction time and is always an allowlist
1376    /// constant — never the raw caller-supplied string.
1377    fn to_sql_clause(&self, param_idx: usize) -> String {
1378        // The path is already validated against the allowlist at construction.
1379        // We use the allowlist entry (the stored path) directly as a SQL literal.
1380        // The VALUE is always a bound `?` parameter (injection-safe).
1381        //
1382        // Type guards prevent cross-type matches caused by SQLite's json_extract
1383        // coercing JSON booleans to integer 1/0:
1384        //   - Bool predicates: AND json_type IN ('true', 'false') — exclude integers
1385        //   - Integer predicates: AND json_type = 'integer' — exclude booleans
1386        // Text predicates need no guard: json_extract returns TEXT for strings and
1387        // the coercion never conflates TEXT with integer/bool.
1388        let path = self.path();
1389        match self {
1390            Self::JsonPathEq { value, .. } => match value {
1391                ScalarValue::Bool(_) => format!(
1392                    "json_extract(body, '{path}') = ?{param_idx} \
1393                     AND json_type(body, '{path}') IN ('true', 'false')"
1394                ),
1395                ScalarValue::Integer(_) => format!(
1396                    "json_extract(body, '{path}') = ?{param_idx} \
1397                     AND json_type(body, '{path}') = 'integer'"
1398                ),
1399                ScalarValue::Text(_) => {
1400                    format!("json_extract(body, '{path}') = ?{param_idx}")
1401                }
1402            },
1403            Self::JsonPathCompare { op, value, .. } => {
1404                let op_str = match op {
1405                    ComparisonOp::Gt => ">",
1406                    ComparisonOp::Gte => ">=",
1407                    ComparisonOp::Lt => "<",
1408                    ComparisonOp::Lte => "<=",
1409                };
1410                match value {
1411                    ScalarValue::Bool(_) => format!(
1412                        "json_extract(body, '{path}') {op_str} ?{param_idx} \
1413                         AND json_type(body, '{path}') IN ('true', 'false')"
1414                    ),
1415                    ScalarValue::Integer(_) => format!(
1416                        "json_extract(body, '{path}') {op_str} ?{param_idx} \
1417                         AND json_type(body, '{path}') = 'integer'"
1418                    ),
1419                    ScalarValue::Text(_) => format!(
1420                        "json_extract(body, '{path}') {op_str} ?{param_idx} \
1421                         AND json_type(body, '{path}') = 'text'"
1422                    ),
1423                }
1424            }
1425        }
1426    }
1427
1428    /// Bind the value of this predicate as a rusqlite parameter.
1429    fn bind_value(&self) -> rusqlite::types::Value {
1430        let value = match self {
1431            Self::JsonPathEq { value, .. } => value,
1432            Self::JsonPathCompare { value, .. } => value,
1433        };
1434        match value {
1435            ScalarValue::Text(s) => rusqlite::types::Value::Text(s.clone()),
1436            ScalarValue::Integer(i) => rusqlite::types::Value::Integer(*i),
1437            ScalarValue::Bool(b) => rusqlite::types::Value::Integer(i64::from(*b)),
1438        }
1439    }
1440}
1441
1442// ===== Slice 20 (G5/G6) — graph traversal types =========================
1443
1444/// Slice 20 (G5) — direction of graph traversal for
1445/// [`Engine::graph_neighbors`] / [`Engine::search_expand`].
1446///
1447/// `Outgoing` follows edges where the root is the `from_id` (source).
1448/// `Incoming` follows edges where the root is the `to_id` (target).
1449/// `Both` follows edges in either direction.
1450#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1451pub enum TraversalDirection {
1452    Outgoing,
1453    Incoming,
1454    Both,
1455}
1456
1457/// Slice 20 (G6) — result of [`Engine::search_expand`]: initial search hits
1458/// plus nodes reached by bounded BFS expansion that are not already in the
1459/// search hit set.
1460#[derive(Clone, Debug)]
1461pub struct SearchExpandResult {
1462    /// Original RRF-scored search results (G1+G9 hybrid).
1463    pub search_hits: Vec<SearchHit>,
1464    /// Nodes reached by graph traversal but NOT already in `search_hits`.
1465    /// Each entry is `(node, hop_count)` where `hop_count` is the BFS depth
1466    /// from the nearest search hit that reached this node.
1467    pub expanded: Vec<(NodeRecord, u32)>,
1468    /// Deduplicated union of all logical_ids (search hits first, then expanded).
1469    pub all_logical_ids: Vec<String>,
1470}
1471
1472/// G10 — closed metadata filter for [`Engine::search_filtered`] (Slice 10).
1473///
1474/// All fields are optional; a `None` field imposes no constraint, and an
1475/// all-`None` filter (or `None` filter) is the unfiltered path whose phase-1 SQL
1476/// is byte-identical to 0.7.2. This is a **closed struct**, not an open filter
1477/// DSL (ADR-0.8.0-agent-memory-retrieval-and-identity Q1); the filter-grammar /
1478/// `list` decision stays a later-slice concern.
1479///
1480/// `created_after` is a `created_at >= bound` lower bound in unix seconds.
1481/// `status` is wired through to the vec0 `status` metadata column. vec0 TEXT
1482/// metadata columns are **NOT NULL-able**, so the "no real population yet" state
1483/// is an **empty-string sentinel** `''` (a forced deviation from the planned
1484/// "NULL plumbing"; a real population source is reserved-gap candidate 13). A
1485/// `status = Some("open")`-style filter therefore prunes every row until that
1486/// population slice lands.
1487#[derive(Clone, Debug, Default, Eq, PartialEq)]
1488pub struct SearchFilter {
1489    pub source_type: Option<String>,
1490    pub kind: Option<String>,
1491    pub created_after: Option<i64>,
1492    pub status: Option<String>,
1493}
1494
1495impl SearchFilter {
1496    /// True when no field constrains the search — equivalent to `None`. Used to
1497    /// keep the unfiltered code path (and its byte-identical SQL) on the
1498    /// all-`None` struct.
1499    fn is_unfiltered(&self) -> bool {
1500        self.source_type.is_none()
1501            && self.kind.is_none()
1502            && self.created_after.is_none()
1503            && self.status.is_none()
1504    }
1505}
1506
1507/// G11 (Slice 15) — a document sent to a BYO-LLM extraction harness via
1508/// [`Engine::ingest_with_extractor`].
1509#[derive(Clone, Debug)]
1510pub struct ExtractDocument {
1511    /// Stable opaque identifier for this document. Used as `source_id` on
1512    /// ingested edges and for provenance tracking.
1513    pub source_doc_id: String,
1514    /// Full text body of the document to extract entities and relationships from.
1515    pub body: String,
1516}
1517
1518/// G11 (Slice 15) — receipt returned by [`Engine::ingest_with_extractor`].
1519#[derive(Clone, Debug, Default)]
1520pub struct IngestWithExtractorReceipt {
1521    /// Number of `canonical_nodes` rows written (new entity insertions; skipped
1522    /// for entities that already have a matching active logical_id).
1523    pub nodes_written: u64,
1524    /// Number of `canonical_edges` rows written (new fact-edge insertions;
1525    /// superseded prior edges are ALSO counted as rows written).
1526    pub edges_written: u64,
1527    /// Number of documents processed (including no-facts documents).
1528    pub docs_processed: u64,
1529}
1530
1531/// Batch input shape for [`Engine::write`].
1532///
1533/// Marked `#[non_exhaustive]` per ADR-0.6.0-prepared-write-shape; new
1534/// entity variants land in 0.6.x without a major bump. Adding fields to
1535/// existing variants remains a binding-coordination change.
1536#[non_exhaustive]
1537#[derive(Clone, Debug, PartialEq)]
1538pub enum PreparedWrite {
1539    Node {
1540        kind: String,
1541        body: String,
1542        /// REQ-026 / AC-028 / AC-042 recovery seam. `None` is the
1543        /// back-compat default and lands as NULL on disk; callers that
1544        /// participate in `excise_source` / `trace_source_ref` must
1545        /// supply a stable identifier.
1546        source_id: Option<String>,
1547        /// G0 (Slice 15) — stable cross-re-ingestion identity. `Some(id)`
1548        /// makes this write a transaction-time supersession of the prior
1549        /// active version of `(logical_id, kind)` (tombstone-then-insert).
1550        /// `None` is the legacy/own-identity default: a plain insert with a
1551        /// NULL `logical_id` (NULL-safe — never collides with other NULLs).
1552        logical_id: Option<String>,
1553    },
1554    Edge {
1555        kind: String,
1556        from: String,
1557        to: String,
1558        /// REQ-026 / AC-028 / AC-042 recovery seam — see Node.
1559        source_id: Option<String>,
1560        /// G0 (Slice 15) — see Node. Supersession semantics are identical on
1561        /// edges (keyed by `(logical_id, kind)`).
1562        logical_id: Option<String>,
1563        /// G11 (Slice 15) — the fact/relationship text. When `Some`, triggers
1564        /// FTS projection into `search_index_edges` and vector projection via
1565        /// the projection scheduler (kind `"edge_fact"`). Also triggers
1566        /// invalidate-not-accumulate on `(from_id, to_id, kind)`.
1567        body: Option<String>,
1568        /// G11 (Slice 15) — event valid-time (ISO-8601). NULL = unknown / still valid.
1569        t_valid: Option<String>,
1570        /// G11 (Slice 15) — event invalid-time (ISO-8601). NULL = still valid.
1571        t_invalid: Option<String>,
1572        /// G11 (Slice 15) — extraction confidence ∈ [0.0, 1.0]. NULL for
1573        /// non-BYO-LLM-ingested edges.
1574        confidence: Option<f64>,
1575        /// G11 (Slice 15) — opaque model/provider id from the BYO-LLM harness
1576        /// `ready.model` field. NULL for non-BYO-LLM edges.
1577        extractor_model_id: Option<String>,
1578        /// R3 (Slice 30, SCHEMA-GATE-1, HITL-SIGNED 2026-06-13) — set when the
1579        /// ELPS extractor defaulted this edge's `t_valid` to `created_at` rather
1580        /// than deriving it from the document text. Such edges have untrustworthy
1581        /// event times and are excluded from graph-arm BFS temporal queries.
1582        /// `None`/`false` = not a fallback; `Some(true)` = fallback.
1583        temporal_fallback: Option<bool>,
1584    },
1585    OpStore {
1586        collection: String,
1587        record_key: String,
1588        schema_id: Option<String>,
1589        body: String,
1590    },
1591    AdminSchema {
1592        name: String,
1593        kind: String,
1594        schema_json: String,
1595        retention_json: String,
1596    },
1597}
1598
1599/// Snapshot of engine-internal counters returned by [`Engine::counters`].
1600///
1601/// Public key set is owned by `dev/design/lifecycle.md` § Public key set
1602/// and locked by AC-004a. Reading a snapshot is non-perturbing per
1603/// AC-004c. The 0.6.0 surface exposes exactly these seven fields.
1604#[derive(Clone, Debug, Default, Eq, PartialEq)]
1605pub struct CounterSnapshot {
1606    pub queries: u64,
1607    pub writes: u64,
1608    pub write_rows: u64,
1609    pub errors_by_code: BTreeMap<String, u64>,
1610    pub admin_ops: u64,
1611    pub cache_hit: u64,
1612    pub cache_miss: u64,
1613}
1614
1615pub use lifecycle::Subscription;
1616
1617/// Stable corruption-on-open detail carried by
1618/// [`EngineOpenError::Corruption`].
1619///
1620/// Layout owned by `dev/design/errors.md` § Corruption detail owner.
1621#[derive(Clone, Debug, Eq, PartialEq)]
1622pub struct CorruptionDetail {
1623    pub kind: CorruptionKind,
1624    pub stage: OpenStage,
1625    pub locator: CorruptionLocator,
1626    pub recovery_hint: RecoveryHint,
1627}
1628
1629/// Open-path corruption category.
1630///
1631/// 0.6.0 emits exactly the four members below; per
1632/// `dev/design/errors.md` § Engine.open corruption table, doctor-only
1633/// finding codes are not represented here.
1634#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1635pub enum CorruptionKind {
1636    WalReplayFailure,
1637    HeaderMalformed,
1638    SchemaInconsistent,
1639    EmbedderIdentityDrift,
1640}
1641
1642/// `Engine.open` stage at which corruption was detected.
1643///
1644/// Per ADR-0.6.0-corruption-open-behavior, `LockAcquisition` is intentionally
1645/// not a member here; lock contention is surfaced via
1646/// [`EngineOpenError::DatabaseLocked`].
1647#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1648pub enum OpenStage {
1649    WalReplay,
1650    HeaderProbe,
1651    SchemaProbe,
1652    EmbedderIdentity,
1653}
1654
1655/// Locator pointing at the corrupted region of the database file.
1656///
1657/// Variant set owned by `dev/design/errors.md` § CorruptionLocator
1658/// ownership. `OpaqueSqliteError` is the required fallback when SQLite
1659/// surfaces corruption without a usable structured locator.
1660#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1661pub enum CorruptionLocator {
1662    FileOffset { offset: u64 },
1663    PageId { page: u32 },
1664    TableRow { table: &'static str, rowid: i64 },
1665    Vec0ShadowRow { partition: &'static str, rowid: i64 },
1666    MigrationStep { from: u32, to: u32 },
1667    OpaqueSqliteError { sqlite_extended_code: i32 },
1668}
1669
1670/// Recovery dispatch surface attached to a corruption detail.
1671///
1672/// `code` is the stable dispatch key used by bindings and doctor output;
1673/// `doc_anchor` points at the documentation section that explains the
1674/// remediation path.
1675#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1676pub struct RecoveryHint {
1677    pub code: &'static str,
1678    pub doc_anchor: &'static str,
1679}
1680
1681#[derive(Clone, Debug, Eq, PartialEq)]
1682pub enum EngineOpenError {
1683    DatabaseLocked {
1684        holder_pid: Option<u32>,
1685    },
1686    Corruption(CorruptionDetail),
1687    IncompatibleSchemaVersion {
1688        seen: u32,
1689        supported: u32,
1690    },
1691    MigrationError {
1692        schema_version_before: u32,
1693        schema_version_current: u32,
1694        step_id: u32,
1695    },
1696    EmbedderIdentityMismatch {
1697        stored: EmbedderIdentity,
1698        supplied: EmbedderIdentity,
1699    },
1700    EmbedderDimensionMismatch {
1701        stored: u32,
1702        supplied: u32,
1703    },
1704    /// Embedder runtime returned a typed error during `Engine::open`.
1705    Embedder(RuntimeEmbedderError),
1706    Io {
1707        message: String,
1708    },
1709}
1710
1711/// Caller-facing selector for the embedder used by an opened engine
1712/// (`dev/design/embedder.md` §0).
1713#[derive(Clone)]
1714pub enum EmbedderChoice {
1715    /// Use the engine's default embedder. With the `default-embedder`
1716    /// Cargo feature enabled, this materializes a `CandleBgeEmbedder`
1717    /// via the EU-3 loader at `Engine::open`; on first use the loader
1718    /// downloads pinned bge-small-en-v1.5 weights from HuggingFace per
1719    /// `ADR-0.7.1-default-embedder-weight-fetch`. Without the feature,
1720    /// this returns `EmbedderError::Failed` directing the caller to
1721    /// rebuild with `--features default-embedder` or supply
1722    /// `EmbedderChoice::Caller`.
1723    Default,
1724    /// Caller supplies the embedder instance. The supplied embedder's
1725    /// `identity()` becomes the workspace's default-profile identity.
1726    Caller(Arc<dyn Embedder>),
1727    /// No embedder configured. Engine opens; subsequent vector writes
1728    /// fail with `EngineError::EmbedderNotConfigured`. Useful for
1729    /// read-only or canonical-only flows.
1730    None,
1731}
1732
1733impl Display for EngineOpenError {
1734    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1735        match self {
1736            Self::DatabaseLocked { holder_pid } => match holder_pid {
1737                Some(pid) => write!(f, "database is locked by process {pid}"),
1738                None => write!(f, "database is locked by another engine instance"),
1739            },
1740            Self::Corruption(detail) => {
1741                write!(
1742                    f,
1743                    "engine corruption at {:?} stage: {}",
1744                    detail.stage, detail.recovery_hint.code
1745                )
1746            }
1747            Self::IncompatibleSchemaVersion { seen, supported } => write!(
1748                f,
1749                "database schema version {seen} is incompatible with supported version {supported}"
1750            ),
1751            Self::MigrationError {
1752                schema_version_before,
1753                schema_version_current,
1754                step_id,
1755            } => write!(
1756                f,
1757                "schema migration failed at step {step_id}; schema version remained between {schema_version_before} and {schema_version_current}"
1758            ),
1759            Self::EmbedderIdentityMismatch { stored, supplied } => write!(
1760                f,
1761                "embedder identity mismatch: stored {}@{}, supplied {}@{}",
1762                stored.name, stored.revision, supplied.name, supplied.revision,
1763            ),
1764            Self::EmbedderDimensionMismatch { stored, supplied } => write!(
1765                f,
1766                "embedder vector dimension mismatch: stored {stored}, supplied {supplied}",
1767            ),
1768            Self::Embedder(err) => match err {
1769                RuntimeEmbedderError::Timeout => write!(f, "embedder timeout during open"),
1770                RuntimeEmbedderError::Failed { message } => {
1771                    write!(f, "embedder failure during open: {message}")
1772                }
1773            },
1774            Self::Io { message } => write!(f, "database I/O error: {message}"),
1775        }
1776    }
1777}
1778
1779impl Error for EngineOpenError {}
1780
1781#[derive(Clone, Debug, Eq, PartialEq)]
1782pub enum EngineError {
1783    Storage,
1784    Projection,
1785    Vector,
1786    Embedder,
1787    EmbedderNotConfigured,
1788    KindNotVectorIndexed,
1789    EmbedderDimensionMismatch {
1790        expected: u32,
1791        actual: u32,
1792    },
1793    Scheduler,
1794    OpStore,
1795    WriteValidation,
1796    SchemaValidation,
1797    Overloaded,
1798    Closing,
1799    /// G11 (Slice 15) — BYO-LLM extractor subprocess error (protocol mismatch,
1800    /// spawn failure, or harness-returned error code).
1801    Extractor,
1802    /// G4 (Slice 35) — filter predicate construction error: non-allowlisted
1803    /// path or invalid filter argument. NOT a panic — returned as a typed error
1804    /// from [`Predicate::json_path_eq`] / [`Predicate::json_path_compare`].
1805    InvalidFilter {
1806        reason: String,
1807    },
1808    /// Slice 20 (G5/G6) — an argument is out of the accepted range (e.g.
1809    /// `depth > 3` for graph traversal). The `msg` field carries a
1810    /// human-readable explanation; it is intentionally non-exhaustive so the
1811    /// binding layer can forward it as a `ValueError` / `TypeError`.
1812    InvalidArgument {
1813        msg: String,
1814    },
1815}
1816
1817impl Display for EngineError {
1818    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1819        match self {
1820            Self::Storage => write!(f, "storage error"),
1821            Self::Projection => write!(f, "projection error"),
1822            Self::Vector => write!(f, "vector error"),
1823            Self::Embedder => write!(f, "embedder error"),
1824            Self::EmbedderNotConfigured => write!(f, "embedder is not configured"),
1825            Self::KindNotVectorIndexed => write!(f, "kind is not configured for vector indexing"),
1826            Self::EmbedderDimensionMismatch { expected, actual } => {
1827                write!(f, "embedder dimension mismatch: expected {expected}, actual {actual}")
1828            }
1829            Self::Scheduler => write!(f, "scheduler error"),
1830            Self::OpStore => write!(f, "op-store error"),
1831            Self::WriteValidation => write!(f, "write validation error"),
1832            Self::SchemaValidation => write!(f, "schema validation error"),
1833            Self::Overloaded => write!(f, "engine overloaded"),
1834            Self::Closing => write!(f, "engine is closing"),
1835            Self::Extractor => write!(f, "extractor error"),
1836            Self::InvalidFilter { reason } => write!(f, "invalid filter: {reason}"),
1837            Self::InvalidArgument { msg } => write!(f, "invalid argument: {msg}"),
1838        }
1839    }
1840}
1841
1842impl EngineError {
1843    /// Stable machine-readable code for `errors_by_code` keys.
1844    ///
1845    /// Names match the binding-facing class stems in
1846    /// `dev/design/errors.md` § Binding-facing class matrix.
1847    fn stable_code(&self) -> &'static str {
1848        match self {
1849            Self::Storage => "StorageError",
1850            Self::Projection => "ProjectionError",
1851            Self::Vector => "VectorError",
1852            Self::Embedder => "EmbedderError",
1853            Self::EmbedderNotConfigured => "EmbedderNotConfiguredError",
1854            Self::KindNotVectorIndexed => "KindNotVectorIndexedError",
1855            Self::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
1856            Self::Scheduler => "SchedulerError",
1857            Self::OpStore => "OpStoreError",
1858            Self::WriteValidation => "WriteValidationError",
1859            Self::SchemaValidation => "SchemaValidationError",
1860            Self::Overloaded => "OverloadedError",
1861            Self::Closing => "ClosingError",
1862            Self::Extractor => "ExtractorError",
1863            Self::InvalidFilter { .. } => "InvalidFilterError",
1864            Self::InvalidArgument { .. } => "InvalidArgumentError",
1865        }
1866    }
1867}
1868
1869impl Error for EngineError {}
1870
1871/// Doctor `check-integrity` invocation flags. `quick` and `round_trip`
1872/// are accepted in 0.6.0 but treated as default; only `full` activates
1873/// `PRAGMA integrity_check`. Per `dev/design/recovery.md` § Doctor-only
1874/// flags.
1875#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1876pub struct CheckIntegrityOpts {
1877    pub quick: bool,
1878    pub full: bool,
1879    pub round_trip: bool,
1880}
1881
1882/// One section of an [`IntegrityReport`]. Either every check in the
1883/// section was clean, or one or more typed [`Finding`]s describe the
1884/// detected issue. Per AC-043b.
1885#[derive(Clone, Debug, Eq, PartialEq)]
1886pub enum Section {
1887    Clean,
1888    Findings(Vec<Finding>),
1889}
1890
1891/// Single doctor finding record. Stable report-shape per AC-043c. The
1892/// `code` and `doc_anchor` strings are stable dispatch keys owned by
1893/// `dev/design/recovery.md` § Code-to-operator-action cross-reference.
1894#[derive(Clone, Debug, Eq, PartialEq)]
1895pub struct Finding {
1896    pub code: &'static str,
1897    pub stage: &'static str,
1898    pub locator: CorruptionLocator,
1899    pub doc_anchor: &'static str,
1900    pub detail: String,
1901}
1902
1903/// Three-section integrity report. AC-043a pins exactly these three
1904/// keys.
1905#[derive(Clone, Debug, Eq, PartialEq)]
1906pub struct IntegrityReport {
1907    pub physical: Section,
1908    pub logical: Section,
1909    pub semantic: Section,
1910}
1911
1912/// Result of a successful [`Engine::safe_export`] call. The returned
1913/// `manifest_sha256` equals the SHA-256 of the export file bytes (per
1914/// AC-039a) and matches the `sha256` field written into the manifest
1915/// JSON.
1916#[derive(Clone, Debug, Eq, PartialEq)]
1917pub struct SafeExportArtifact {
1918    pub export_path: PathBuf,
1919    pub manifest_path: PathBuf,
1920    pub manifest_sha256: String,
1921}
1922
1923/// Phase 9 Pack B trace report (AC-042). One event per canonical row
1924/// attributable to the requested `source_id`, ordered by `write_cursor`
1925/// ascending.
1926#[derive(Clone, Debug, Eq, PartialEq)]
1927pub struct TraceReport {
1928    pub source_ref: String,
1929    pub events: Vec<TraceEvent>,
1930}
1931
1932/// Single canonical-row tracing record. `table` is one of
1933/// `"canonical_nodes"` or `"canonical_edges"`.
1934#[derive(Clone, Debug, Eq, PartialEq)]
1935pub struct TraceEvent {
1936    pub write_cursor: u64,
1937    pub kind: String,
1938    pub table: &'static str,
1939}
1940
1941/// Which shadow-state surface a [`RebuildReport`] describes.
1942/// `Projections` covers the full FTS5 + vec0 + projection-terminal
1943/// rebuild emitted by [`Engine::rebuild_projections`]. `Vec0` covers
1944/// the vec0-only path emitted by [`Engine::rebuild_vec0`].
1945#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1946pub enum RebuildKind {
1947    Projections,
1948    Vec0,
1949}
1950
1951/// Structured result of a rebuild operation. `rows_invalidated` is the
1952/// total shadow-state rows truncated before re-derivation; `rows_rebuilt`
1953/// is the count of rows the synchronous rebuild loop re-materialised
1954/// (asynchronous re-enqueue work performed by the projection scheduler is
1955/// not counted here). `projection_cursor_after` is the post-rebuild value
1956/// of the projection cursor.
1957#[derive(Clone, Debug, Eq, PartialEq)]
1958pub struct RebuildReport {
1959    pub kind: RebuildKind,
1960    pub rows_invalidated: u64,
1961    pub rows_rebuilt: u64,
1962    pub projection_cursor_after: u64,
1963}
1964
1965/// Phase 9 Pack B excise report (AC-028a/b/c). Counts are post-excise
1966/// totals; `projections_invalidated` reports the shadow-row invalidation
1967/// total (FTS5 + vec0 + projection terminal) for the excised source.
1968#[derive(Clone, Debug, Eq, PartialEq)]
1969pub struct ExciseReport {
1970    pub source_ref: String,
1971    pub nodes_excised: u64,
1972    pub edges_excised: u64,
1973    pub projections_invalidated: u64,
1974}
1975
1976/// Typed outcome of [`Engine::verify_embedder`]. Mismatches do not raise
1977/// `EngineError`; the operator workflow needs to see the stored vs.
1978/// supplied pair to decide on next action.
1979#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1980pub enum VerifyEmbedderStatus {
1981    Match,
1982    IdentityMismatch,
1983    DimensionMismatch,
1984    BothMismatch,
1985}
1986
1987/// Result of [`Engine::verify_embedder`]. `stored_identity` is the
1988/// `name:revision` pair persisted in `_fathomdb_embedder_profiles`;
1989/// `supplied_identity` echoes the operator's input verbatim.
1990#[derive(Clone, Debug, Eq, PartialEq)]
1991pub struct VerifyEmbedderReport {
1992    pub stored_identity: String,
1993    pub stored_dimension: u32,
1994    pub supplied_identity: String,
1995    pub supplied_dimension: u32,
1996    pub status: VerifyEmbedderStatus,
1997}
1998
1999/// Single table or index entry emitted by [`Engine::dump_schema`].
2000#[derive(Clone, Debug, Eq, PartialEq)]
2001pub struct SchemaObject {
2002    pub name: String,
2003    pub sql: String,
2004}
2005
2006/// Result of [`Engine::dump_schema`]. `user_version` is the
2007/// `PRAGMA user_version` sentinel. Canonical tables appear first per
2008/// [`fathomdb_schema::CANONICAL_TABLES`], then remaining non-`sqlite_*`
2009/// tables alphabetically. Indexes follow the same alphabetical rule.
2010#[derive(Clone, Debug, Eq, PartialEq)]
2011pub struct DumpSchemaReport {
2012    pub user_version: u32,
2013    pub tables: Vec<SchemaObject>,
2014    pub indexes: Vec<SchemaObject>,
2015}
2016
2017/// Single canonical-table row count emitted by [`Engine::dump_row_counts`].
2018#[derive(Clone, Debug, Eq, PartialEq)]
2019pub struct TableRowCount {
2020    pub name: String,
2021    pub rows: u64,
2022}
2023
2024/// Result of [`Engine::dump_row_counts`]. Canonical tables only;
2025/// projection / FTS / vec0 shadow tables are excluded. Order matches
2026/// [`fathomdb_schema::CANONICAL_TABLES`].
2027#[derive(Clone, Debug, Eq, PartialEq)]
2028pub struct DumpRowCountsReport {
2029    pub counts: Vec<TableRowCount>,
2030}
2031
2032/// Result of [`Engine::dump_profile`]. Mirrors the open-time embedder
2033/// posture + the per-kind vector configuration registered in
2034/// `_fathomdb_vector_kinds`.
2035#[derive(Clone, Debug, Eq, PartialEq)]
2036pub struct DumpProfileReport {
2037    pub embedder_identity: String,
2038    pub embedder_dimension: u32,
2039    pub vectorized_kinds: Vec<String>,
2040}
2041
2042/// 0.7.2 PR-2b — result of [`Engine::recompute_mean`] (the manual
2043/// `doctor recompute-mean` path) and of the shared in-transaction
2044/// recompute core. `drift_cos_before` is the cosine between the freshly
2045/// derived corpus mean and the previously-pinned mean (1.0 when nothing
2046/// was pinned yet, i.e. a first pin). `mean_was_pinned` distinguishes a
2047/// refresh of an existing mean from an initial pin. See
2048/// `dev/design/embedder.md` §0.3.
2049#[derive(Clone, Debug, PartialEq)]
2050pub struct MeanRecomputeReport {
2051    pub dim: u32,
2052    pub old_doc_count: u64,
2053    pub doc_count_requantized: u64,
2054    pub drift_cos_before: f32,
2055    pub mean_was_pinned: bool,
2056    pub elapsed_ms: u64,
2057}
2058
2059/// Typed outcome of [`Engine::truncate_wal`]. `Done` matches SQLite's
2060/// `busy = 0` return from `PRAGMA wal_checkpoint(TRUNCATE)`; any other
2061/// value surfaces as `Busy`.
2062#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2063pub enum TruncateWalStatus {
2064    Done,
2065    Busy,
2066}
2067
2068/// Result of [`Engine::truncate_wal`]. Carries the three counters
2069/// returned by `PRAGMA wal_checkpoint(TRUNCATE)`: `busy`, `log_frames`,
2070/// `checkpointed_frames`.
2071#[derive(Clone, Debug, Eq, PartialEq)]
2072pub struct TruncateWalReport {
2073    pub status: TruncateWalStatus,
2074    pub busy: u32,
2075    pub log_frames: u32,
2076    pub checkpointed_frames: u32,
2077}
2078
2079impl Drop for Engine {
2080    fn drop(&mut self) {
2081        let _ = self.close();
2082    }
2083}
2084
2085impl Engine {
2086    pub fn open(path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
2087        Self::open_with_embedder_and_subscriber(
2088            path,
2089            default_embedder_identity(),
2090            None,
2091            None,
2092            None,
2093            &mut |_| {},
2094        )
2095    }
2096
2097    /// Open an engine with an explicit [`EmbedderChoice`].
2098    ///
2099    /// Per `dev/design/embedder.md` §0 + the 0.7.1 EU-5 campaign, this is
2100    /// the canonical entry point for selecting how the workspace's
2101    /// default embedder is supplied. See [`EmbedderChoice`] for the
2102    /// semantics of each variant; in particular `Default` materializes
2103    /// the pinned BGE embedder via the loader when the `default-embedder`
2104    /// feature is enabled.
2105    pub fn open_with_choice(
2106        path: impl Into<PathBuf>,
2107        choice: EmbedderChoice,
2108    ) -> Result<OpenedEngine, EngineOpenError> {
2109        match choice {
2110            EmbedderChoice::Default => Self::open_default_embedder(path),
2111            EmbedderChoice::Caller(embedder) => {
2112                let identity = embedder.identity();
2113                Self::open_with_embedder_and_subscriber(
2114                    path,
2115                    identity,
2116                    Some(embedder),
2117                    None,
2118                    None,
2119                    &mut |_| {},
2120                )
2121            }
2122            EmbedderChoice::None => Self::open_with_embedder_and_subscriber(
2123                path,
2124                default_embedder_identity(),
2125                None,
2126                None,
2127                None,
2128                &mut |_| {},
2129            ),
2130        }
2131    }
2132
2133    /// EU-5b: materialize the engine's pinned default embedder
2134    /// (`CandleBgeEmbedder` backed by the EU-3 loader) and open the
2135    /// workspace with it. Without the `default-embedder` feature, fails
2136    /// with a typed `Embedder` error rather than touching the network.
2137    #[cfg(feature = "default-embedder")]
2138    fn open_default_embedder(path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
2139        use std::time::Instant as DownloadInstant;
2140        let download_start = DownloadInstant::now();
2141        let weights = fathomdb_embedder::loader::load_pinned_default_embedder().map_err(|err| {
2142            EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
2143                message: format!("default embedder loader: {err}"),
2144            })
2145        })?;
2146        let events = weights.events.clone();
2147        let download_ms = if weights.bytes_downloaded > 0 {
2148            Some(u64::try_from(download_start.elapsed().as_millis()).unwrap_or(u64::MAX))
2149        } else {
2150            None
2151        };
2152        let embedder =
2153            fathomdb_embedder::CandleBgeEmbedder::new_from_weights(weights).map_err(|err| {
2154                EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
2155                    message: format!("default embedder construct: {err}"),
2156                })
2157            })?;
2158        let embedder: Arc<dyn Embedder> = Arc::new(embedder);
2159        let identity = embedder.identity();
2160        let loader_info = LoaderInfo { download_ms, events };
2161        Self::open_with_embedder_and_subscriber(
2162            path,
2163            identity,
2164            Some(embedder),
2165            Some(loader_info),
2166            None,
2167            &mut |_| {},
2168        )
2169    }
2170
2171    #[cfg(not(feature = "default-embedder"))]
2172    fn open_default_embedder(_path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
2173        Err(EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
2174            message: "EmbedderChoice::Default requires the `default-embedder` Cargo feature"
2175                .to_string(),
2176        }))
2177    }
2178
2179    pub fn open_with_migration_event_sink(
2180        path: impl Into<PathBuf>,
2181        mut emit_migration_event: impl FnMut(&MigrationStepReport),
2182    ) -> Result<OpenedEngine, EngineOpenError> {
2183        Self::open_with_embedder_and_subscriber(
2184            path,
2185            default_embedder_identity(),
2186            None,
2187            None,
2188            None,
2189            &mut emit_migration_event,
2190        )
2191    }
2192
2193    #[cfg(debug_assertions)]
2194    #[doc(hidden)]
2195    pub fn open_with_migrations_for_test(
2196        path: impl Into<PathBuf>,
2197        migrations: &'static [fathomdb_schema::Migration],
2198        mut emit_migration_event: impl FnMut(&MigrationStepReport),
2199    ) -> Result<OpenedEngine, EngineOpenError> {
2200        Self::open_with_migrations(
2201            path,
2202            migrations,
2203            default_embedder_identity(),
2204            None,
2205            None,
2206            &mut emit_migration_event,
2207            None,
2208        )
2209    }
2210
2211    #[doc(hidden)]
2212    pub fn open_with_subscriber_for_test(
2213        path: impl Into<PathBuf>,
2214        subscriber: Arc<dyn lifecycle::Subscriber>,
2215    ) -> Result<OpenedEngine, EngineOpenError> {
2216        Self::open_with_embedder_and_subscriber(
2217            path,
2218            default_embedder_identity(),
2219            None,
2220            None,
2221            Some(subscriber),
2222            &mut |_| {},
2223        )
2224    }
2225
2226    #[doc(hidden)]
2227    pub fn open_without_embedder_for_test(
2228        path: impl Into<PathBuf>,
2229    ) -> Result<OpenedEngine, EngineOpenError> {
2230        Self::open_with_embedder_and_subscriber(
2231            path,
2232            default_embedder_identity(),
2233            None,
2234            None,
2235            None,
2236            &mut |_| {},
2237        )
2238    }
2239
2240    #[doc(hidden)]
2241    pub fn open_with_embedder_for_test(
2242        path: impl Into<PathBuf>,
2243        embedder: Arc<dyn Embedder>,
2244    ) -> Result<OpenedEngine, EngineOpenError> {
2245        let identity = embedder.identity();
2246        Self::open_with_embedder_and_subscriber(
2247            path,
2248            identity,
2249            Some(embedder),
2250            None,
2251            None,
2252            &mut |_| {},
2253        )
2254    }
2255
2256    fn open_with_embedder_and_subscriber(
2257        path: impl Into<PathBuf>,
2258        embedder_identity: EmbedderIdentity,
2259        runtime_embedder: Option<Arc<dyn Embedder>>,
2260        loader_info: Option<LoaderInfo>,
2261        initial_subscriber: Option<Arc<dyn lifecycle::Subscriber>>,
2262        emit_migration_event: &mut impl FnMut(&MigrationStepReport),
2263    ) -> Result<OpenedEngine, EngineOpenError> {
2264        Self::open_with_migrations(
2265            path,
2266            MIGRATIONS,
2267            embedder_identity,
2268            runtime_embedder,
2269            loader_info,
2270            emit_migration_event,
2271            initial_subscriber,
2272        )
2273    }
2274
2275    fn open_with_migrations(
2276        path: impl Into<PathBuf>,
2277        migrations: &'static [fathomdb_schema::Migration],
2278        embedder_identity: EmbedderIdentity,
2279        runtime_embedder: Option<Arc<dyn Embedder>>,
2280        loader_info: Option<LoaderInfo>,
2281        emit_migration_event: &mut impl FnMut(&MigrationStepReport),
2282        initial_subscriber: Option<Arc<dyn lifecycle::Subscriber>>,
2283    ) -> Result<OpenedEngine, EngineOpenError> {
2284        let canonical_path = canonical_database_path(&path.into())?;
2285        let lock = acquire_lock(&canonical_path)?;
2286        let open_result = Self::open_locked(
2287            canonical_path.clone(),
2288            migrations,
2289            &embedder_identity,
2290            emit_migration_event,
2291        );
2292
2293        match open_result {
2294            Ok((connection, readers, mut report, reader_lookaside_rcs)) => {
2295                // EU-5b — splice the loader's measurements + structured
2296                // events into the report. The loader path is the only
2297                // surface that produces these today; caller-supplied
2298                // embedders and EmbedderChoice::None leave them as the
2299                // open_locked defaults (None / empty).
2300                if let Some(info) = loader_info {
2301                    if info.download_ms.is_some() {
2302                        report.embedder_download_ms = info.download_ms;
2303                    }
2304                    if !info.events.is_empty() {
2305                        report.embedder_events = info.events;
2306                    }
2307                }
2308                let next_cursor = load_next_cursor(&connection);
2309                let subscribers = Arc::new(lifecycle::SubscriberRegistry::new());
2310                let profiling_enabled = Arc::new(AtomicBool::new(false));
2311                let slow_threshold_ms = Arc::new(AtomicU64::new(DEFAULT_SLOW_THRESHOLD_MS));
2312                let mut profile_contexts: Vec<Box<ProfileContext>> = Vec::new();
2313                let projection_runtime = ProjectionRuntime::new(
2314                    canonical_path.clone(),
2315                    runtime_embedder.clone(),
2316                    embedder_identity.clone(),
2317                    report.embedder_mean_vec_pinned,
2318                );
2319
2320                install_profile_callback(
2321                    &connection,
2322                    &subscribers,
2323                    &profiling_enabled,
2324                    &slow_threshold_ms,
2325                    &mut profile_contexts,
2326                );
2327                for reader in &readers {
2328                    install_profile_callback(
2329                        reader,
2330                        &subscribers,
2331                        &profiling_enabled,
2332                        &slow_threshold_ms,
2333                        &mut profile_contexts,
2334                    );
2335                }
2336
2337                let opened = OpenedEngine {
2338                    engine: Self {
2339                        path: canonical_path.clone(),
2340                        next_cursor: AtomicU64::new(next_cursor),
2341                        closed: AtomicBool::new(false),
2342                        lock: Mutex::new(Some(lock)),
2343                        connection: Mutex::new(Some(connection)),
2344                        reader_pool: ReaderWorkerPool::new(readers),
2345                        counters: lifecycle::Counters::new(),
2346                        subscribers,
2347                        profiling_enabled,
2348                        slow_threshold_ms,
2349                        runtime_embedder,
2350                        runtime_embedder_identity: embedder_identity,
2351                        projection_runtime,
2352                        provenance_row_cap: AtomicU64::new(DEFAULT_PROVENANCE_ROW_CAP),
2353                        profile_contexts: Mutex::new(profile_contexts),
2354                        reader_lookaside_rcs,
2355                        telemetry: Mutex::new(None),
2356                        telemetry_enabled: AtomicBool::new(false),
2357                        #[cfg(debug_assertions)]
2358                        force_next_commit_failure: AtomicBool::new(false),
2359                    },
2360                    report,
2361                };
2362                if let Some(subscriber) = initial_subscriber {
2363                    opened.engine.subscribers.attach_persistent(subscriber);
2364                }
2365                if database_has_pending_projection_work(&canonical_path).unwrap_or(false) {
2366                    opened.engine.projection_runtime.notify_new_work();
2367                }
2368                Ok(opened)
2369            }
2370            Err(err) => {
2371                if let Some(subscriber) = initial_subscriber {
2372                    emit_open_error_event(&subscriber, &err);
2373                }
2374                drop(lock);
2375                Err(err)
2376            }
2377        }
2378    }
2379
2380    fn open_locked(
2381        path: PathBuf,
2382        migrations: &'static [fathomdb_schema::Migration],
2383        embedder_identity: &EmbedderIdentity,
2384        emit_migration_event: &mut impl FnMut(&MigrationStepReport),
2385    ) -> Result<(Connection, Vec<Connection>, OpenReport, Vec<i32>), EngineOpenError> {
2386        init_perf_experiments_runtime();
2387        register_sqlite_vec_extension();
2388        let mut connection = Connection::open(&path)
2389            .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))?;
2390        // Order pinned by `dev/design/errors.md` § OpenStage matrix: each
2391        // step routes its own SQLite-level error to a distinct
2392        // `CorruptionKind` (Header → WalReplay → Schema → EmbedderIdentity).
2393        // The schema and WAL probes both happen BEFORE `pragma WAL`
2394        // because that pragma also reads page 1 — letting it run first
2395        // would reclassify schema-side corruption as a WAL replay
2396        // failure, breaking the AC-035b stable-code contract.
2397        probe_database_header(&connection)?;
2398        probe_open_integrity(&connection)?;
2399        probe_wal_sidecar(&path)?;
2400        // 0.7.0 perf-experiments: apply writer-side experiment PRAGMAs
2401        // (page_size, etc.) BEFORE journal_mode + migrations. page_size
2402        // is silently ignored once any table exists; this is the only
2403        // legal window to set it on a fresh DB. Gated on
2404        // FATHOMDB_PERF_EXPERIMENTS=1; no-op in production.
2405        apply_perf_experiment_writer_pragmas(&connection);
2406        connection
2407            .pragma_update(None, "journal_mode", "WAL")
2408            .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
2409
2410        reject_legacy_shape(&connection)?;
2411        let migration = migrate_with_event_sink(&connection, migrations, emit_migration_event)
2412            .map_err(map_migration_error)?;
2413        // 0.8.0 Slice 5 (G1) — global FTS5 tokenizer-default upgrade. Step 11
2414        // drops + recreates `search_index` with the new tokenizer, leaving it
2415        // EMPTY on a migrated DB. The projection scheduler will NOT
2416        // repopulate it (`database_has_pending_projection_work` keys "pending"
2417        // off `_fathomdb_projection_terminal`, which the migration does not
2418        // clear). Re-tokenize from the canonical source rows here, on the
2419        // writer connection, single-threaded, before readers spawn —
2420        // projection-only, no source-record migration.
2421        //
2422        // Crash-retryable (fix-1): step 11 commits `user_version = 11` with an
2423        // empty index in its OWN transaction; this reproject commits in a
2424        // LATER transaction. A crash in that window leaves a durable v11 + empty
2425        // index, on which a boundary-crossing guard (`before < 11`) is FALSE,
2426        // skipping repair forever. So gate on the completion marker's ABSENCE
2427        // (written atomically with the reindex) instead: idempotent, and a
2428        // crash before the reindex commit simply re-runs on the next open.
2429        if migration.schema_version_after >= SEARCH_INDEX_TOKENIZER_SCHEMA_VERSION
2430            && !search_index_tokenizer_reproject_complete(&connection).map_err(|_| {
2431                EngineOpenError::Io {
2432                    message: "could not read search_index tokenizer reproject marker".to_string(),
2433                }
2434            })?
2435        {
2436            reproject_search_index_after_tokenizer_upgrade(&connection).map_err(|_| {
2437                EngineOpenError::Io {
2438                    message: "could not re-tokenize search_index after tokenizer upgrade"
2439                        .to_string(),
2440                }
2441            })?;
2442        }
2443        let mut embedder_mean_vec_pinned = check_embedder_profile(&connection, embedder_identity)?;
2444        ensure_vector_partition(&mut connection, embedder_identity.dimension).map_err(|_| {
2445            EngineOpenError::Io { message: "could not initialize vector partition".to_string() }
2446        })?;
2447
2448        // EU-5f — recovery pin (`dev/design/embedder.md` §0.3, Hazard 4). If
2449        // the identity is MC-required, no mean is pinned, yet the workspace
2450        // already holds >= MEAN_VEC_PIN_THRESHOLD vector rows (e.g. a crash
2451        // between the threshold-crossing write and its pin commit), derive
2452        // the mean from the existing un-centered rows and pin+re-quantize
2453        // now, single-threaded, before the projection workers spawn. The
2454        // NULL guard makes this idempotent on subsequent opens.
2455        if identity_requires_mean_centering(embedder_identity) && !embedder_mean_vec_pinned {
2456            let row_count: u64 = connection
2457                .query_row("SELECT COUNT(*) FROM vector_default", [], |row| row.get(0))
2458                .unwrap_or(0);
2459            if row_count >= MEAN_VEC_PIN_THRESHOLD {
2460                recover_mean_vec_pin(&mut connection, embedder_identity).map_err(|_| {
2461                    EngineOpenError::Io {
2462                        message: "could not recover mean-centering pin".to_string(),
2463                    }
2464                })?;
2465                embedder_mean_vec_pinned = true;
2466            }
2467        }
2468
2469        let warmup_started = Instant::now();
2470        // Static identity capability — see `dev/design/embedder.md`
2471        // §0.6. Today only the bge-small identity reports `true`; the
2472        // noop scaffolding identity is `false`. EU-5b's identity flip
2473        // makes the Default path return `true` here automatically.
2474        let embedder_mean_centering_required = embedder_identity.name == BGE_SMALL_EMBEDDER_NAME;
2475        // EU-5a2 — populated from `_fathomdb_embedder_profiles.mean_vec`
2476        // by `check_embedder_profile` above (was hard-coded `false` in
2477        // EU-5a1). Dimension invariant (§0.2) enforced by that check.
2478        let report = OpenReport {
2479            schema_version_before: migration.schema_version_before,
2480            schema_version_after: migration.schema_version_after,
2481            migration_steps: migration.migration_steps,
2482            embedder_warmup_ms: u64::try_from(warmup_started.elapsed().as_millis())
2483                .unwrap_or(u64::MAX),
2484            query_backend: "fathomdb-query + sqlite-vec",
2485            default_embedder: embedder_identity.clone(),
2486            // TODO(EU-5b): surface `LoadedWeights.download_ms` from the
2487            // loader once the Default path materializes through it.
2488            embedder_download_ms: None,
2489            // TODO(EU-5b): surface `LoadedWeights.events` from the loader.
2490            embedder_events: Vec::new(),
2491            embedder_mean_centering_required,
2492            embedder_mean_vec_pinned,
2493        };
2494
2495        let mut readers = Vec::with_capacity(READER_POOL_SIZE);
2496        let mut lookaside_rcs: Vec<i32> = Vec::with_capacity(READER_POOL_SIZE);
2497        for _ in 0..READER_POOL_SIZE {
2498            let reader = Connection::open(&path)
2499                .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))?;
2500            // Pack 6.G G.1: configure per-connection lookaside BEFORE
2501            // any PRAGMA / prepare runs on this reader. Reordering this
2502            // after the journal-mode / query_only PRAGMAs would let
2503            // SQLite silently ignore the lookaside setting.
2504            let rc: i32 = configure_reader_lookaside(&reader);
2505            debug_assert_eq!(
2506                rc,
2507                rusqlite::ffi::SQLITE_OK,
2508                "sqlite3_db_config(LOOKASIDE) must return SQLITE_OK on a freshly opened reader",
2509            );
2510            lookaside_rcs.push(rc);
2511            reader
2512                .pragma_update(None, "journal_mode", "WAL")
2513                .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
2514            reader
2515                .pragma_update(None, "query_only", "ON")
2516                .map_err(|err| map_open_sqlite_error(err, OpenStage::SchemaProbe))?;
2517            apply_perf_experiment_reader_pragmas(&reader);
2518            readers.push(reader);
2519        }
2520
2521        Ok((connection, readers, report, lookaside_rcs))
2522    }
2523
2524    #[must_use]
2525    pub fn path(&self) -> &Path {
2526        &self.path
2527    }
2528
2529    pub fn write(&self, batch: &[PreparedWrite]) -> Result<WriteReceipt, EngineError> {
2530        let category = if batch_is_admin(batch) {
2531            lifecycle::EventCategory::Admin
2532        } else {
2533            lifecycle::EventCategory::Writer
2534        };
2535        self.emit_event(lifecycle::Phase::Started, category, None);
2536        let started = Instant::now();
2537        let outcome = self.write_inner(batch);
2538        self.detect_slow(started, category);
2539        match outcome {
2540            Ok(receipt) => {
2541                let rows = u64::try_from(batch.len()).unwrap_or(u64::MAX);
2542                if batch_is_admin(batch) {
2543                    self.counters.record_admin();
2544                } else {
2545                    self.counters.record_write(rows);
2546                }
2547                self.emit_event(lifecycle::Phase::Finished, category, None);
2548                Ok(receipt)
2549            }
2550            Err(err) => {
2551                let code = err.stable_code();
2552                self.counters.record_error(code);
2553                // AC-003d: capture-ordinal < raise-ordinal — Failed and Error
2554                // events both fire before the EngineError returns to the caller.
2555                self.emit_event(lifecycle::Phase::Failed, category, Some(code));
2556                self.emit_event(
2557                    lifecycle::Phase::Failed,
2558                    lifecycle::EventCategory::Error,
2559                    Some(code),
2560                );
2561                Err(err)
2562            }
2563        }
2564    }
2565
2566    fn write_inner(&self, batch: &[PreparedWrite]) -> Result<WriteReceipt, EngineError> {
2567        self.ensure_open()?;
2568
2569        if batch.is_empty() {
2570            return Err(EngineError::WriteValidation);
2571        }
2572
2573        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2574        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
2575        let plans = validate_batch(connection, batch)?;
2576        let projection_jobs = collect_projection_jobs(connection, batch)?;
2577        #[cfg(debug_assertions)]
2578        if self.force_next_commit_failure.swap(false, Ordering::SeqCst) {
2579            return Err(EngineError::Storage);
2580        }
2581        // One cursor per row. `base_cursor` is the last committed cursor;
2582        // row i in the batch gets cursor `base_cursor + i + 1`, and the
2583        // batch's final cursor (returned in WriteReceipt and stored as
2584        // the new `next_cursor`) is `base_cursor + batch.len()`. Sharing
2585        // one cursor across the batch previously collapsed every vec0
2586        // INSERT onto the same rowid via `INSERT OR IGNORE` — see
2587        // `dev/notes/0.7.0-engine-batch-vec0-collapse.md`.
2588        let base_cursor = self.next_cursor.load(Ordering::SeqCst);
2589        let increment = u64::try_from(batch.len()).unwrap_or(u64::MAX);
2590        let last_cursor = base_cursor.saturating_add(increment);
2591        // G11 (Slice 15) — edge bodies also need projection-runtime notification.
2592        // `collect_projection_jobs` only tracks Node items (pre-fetched for
2593        // cursor assignment); edge bodies update `_fathomdb_projection_state` in
2594        // `commit_batch` but need the scanner to wake up via `notify_new_work`.
2595        let has_edge_body_work =
2596            batch.iter().any(|w| matches!(w, PreparedWrite::Edge { body: Some(_), .. }));
2597        let pending_projection = !projection_jobs.is_empty() || has_edge_body_work;
2598
2599        let dangling_edge_endpoints = match commit_batch(
2600            connection,
2601            batch,
2602            &plans,
2603            base_cursor,
2604            self.provenance_row_cap.load(Ordering::Relaxed),
2605        ) {
2606            Ok(count) => count,
2607            Err(err) => {
2608                self.emit_sqlite_internal_error(&err);
2609                return Err(EngineError::Storage);
2610            }
2611        };
2612        self.next_cursor.store(last_cursor, Ordering::SeqCst);
2613        if pending_projection {
2614            self.projection_runtime.notify_new_work();
2615        }
2616
2617        // G0 — surface the per-row cursors (1:1 with input order). Row i got
2618        // `base_cursor + i + 1`, matching the allocation in `commit_batch`.
2619        let row_cursors = (0..batch.len())
2620            .map(|i| base_cursor.saturating_add((i as u64).saturating_add(1)))
2621            .collect();
2622        Ok(WriteReceipt { cursor: last_cursor, row_cursors, dangling_edge_endpoints })
2623    }
2624
2625    /// G11 (Slice 15) — BYO-LLM ingest: spawn an external extraction harness
2626    /// speaking the `fathomdb.extract.v1` NDJSON-over-stdio protocol, send
2627    /// documents for extraction, and write the resulting entities
2628    /// (→ `canonical_nodes`) and fact-edges (→ `canonical_edges` with G11
2629    /// enrichment columns) to the store.
2630    ///
2631    /// `cmd` is argv (first element = program, rest = args). Documents are
2632    /// batched per the harness's `max_docs_per_request`. Entity `logical_id`
2633    /// is derived as `sha256("<type>:<name>")` (lowercase, hex-encoded) for
2634    /// stable cross-re-ingestion identity. Edge `logical_id` is derived as
2635    /// `sha256("<from_lid>:<to_lid>:<relation>")`. Both are consistent with
2636    /// G0 supersession: re-ingesting the same document yields the same ids,
2637    /// triggering tombstone-then-insert rather than accumulation.
2638    ///
2639    /// Returns [`EngineError::Extractor`] on protocol errors (bad handshake,
2640    /// subprocess spawn failure, JSON decode error). `no_facts` warnings from
2641    /// the harness are not errors and do not affect the receipt counts.
2642    pub fn ingest_with_extractor(
2643        &self,
2644        cmd: &[&str],
2645        documents: &[ExtractDocument],
2646    ) -> Result<IngestWithExtractorReceipt, EngineError> {
2647        // 0.8.6 Slice 5 (ADR-0.8.6): the spawn + hello/ready handshake +
2648        // request_id framing + error mapping now live in the reusable
2649        // `provider_session` transport seam, parameterized by `ProviderTask`.
2650        // `ingest_with_extractor` is the thin extract caller: it opens a session
2651        // for `ProviderTask::Extract` then runs the extract-specific payload
2652        // build + DB writes. The session owns child reaping via Drop.
2653        let mut session = self.provider_session(ProviderTask::Extract, cmd)?;
2654        self.run_extract_session(&mut session, documents)
2655    }
2656
2657    /// 0.8.6 Slice 5 (ADR-0.8.6) — open a provider session: spawn the caller
2658    /// subprocess, run the `hello`/`ready` handshake for `task`, and negotiate
2659    /// `supported_tasks`. The transport (NDJSON over stdio, the detached stdout
2660    /// drainer, the bounded-recv timeout, the `request_id` framing, and the
2661    /// catch-all `EngineError::Extractor` mapping) is identical across tasks;
2662    /// only the protocol string (`fathomdb.<task>.v1`) and the negotiated task
2663    /// name differ. For `ProviderTask::Extract` the wire is byte-identical to the
2664    /// pre-0.8.6 `fathomdb.extract.v1` path.
2665    fn provider_session(
2666        &self,
2667        task: ProviderTask,
2668        cmd: &[&str],
2669    ) -> Result<ProviderSession, EngineError> {
2670        let (program, args) = cmd.split_first().ok_or(EngineError::Extractor)?;
2671        let mut child = Command::new(program)
2672            .args(args)
2673            .stdin(Stdio::piped())
2674            .stdout(Stdio::piped())
2675            .stderr(Stdio::inherit())
2676            .spawn()
2677            .map_err(|_| EngineError::Extractor)?;
2678
2679        let child_stdin = match child.stdin.take() {
2680            Some(s) => s,
2681            None => {
2682                let _ = child.kill();
2683                let _ = child.wait();
2684                return Err(EngineError::Extractor);
2685            }
2686        };
2687        let child_stdout = match child.stdout.take() {
2688            Some(s) => s,
2689            None => {
2690                let _ = child.kill();
2691                let _ = child.wait();
2692                return Err(EngineError::Extractor);
2693            }
2694        };
2695
2696        // fix-35 [P1/P2]: drain stdout on a dedicated thread so (a) every read can
2697        // be bounded with a timeout — a hung harness can no longer block ingest
2698        // forever — and (b) the child's stdout pipe is drained continuously,
2699        // preventing a large-request deadlock (parent blocked writing stdin while
2700        // the child blocks writing a full stdout pipe). The handle is detached:
2701        // joining could hang if a misbehaving child holds stdout open past its
2702        // stdin EOF, so the session's `Drop` (child.kill()) is what guarantees
2703        // thread exit.
2704        let io_timeout = extractor_io_timeout();
2705        let (line_tx, line_rx) = mpsc::channel::<std::io::Result<String>>();
2706        thread::spawn(move || {
2707            let mut reader = BufReader::new(child_stdout);
2708            loop {
2709                let mut buf = String::new();
2710                match reader.read_line(&mut buf) {
2711                    Ok(0) => break,
2712                    Ok(_) => {
2713                        if line_tx.send(Ok(buf)).is_err() {
2714                            break;
2715                        }
2716                    }
2717                    Err(e) => {
2718                        let _ = line_tx.send(Err(e));
2719                        break;
2720                    }
2721                }
2722            }
2723        });
2724
2725        let mut session = ProviderSession {
2726            task,
2727            child,
2728            writer: std::io::BufWriter::new(child_stdin),
2729            line_rx,
2730            io_timeout,
2731            model: None,
2732            max_docs_per_request: 8,
2733        };
2734        // On any handshake/negotiation error the session is dropped here, which
2735        // reaps the child (Drop) — matching the prior outer kill/wait semantics.
2736        session.handshake()?;
2737        Ok(session)
2738    }
2739
2740    /// 0.8.6 Slice 5 — extract-specific driver over a `ProviderSession`. The
2741    /// payload build (documents → entities/edges) and DB writes are byte-identical
2742    /// to the pre-0.8.6 inner loop; only the spawn/handshake/framing moved into
2743    /// the shared session.
2744    fn run_extract_session(
2745        &self,
2746        session: &mut ProviderSession,
2747        documents: &[ExtractDocument],
2748    ) -> Result<IngestWithExtractorReceipt, EngineError> {
2749        let extractor_model_id = session.model.clone();
2750        let max_docs = session.max_docs_per_request;
2751
2752        // --- per-batch extract → write loop ---
2753        let mut nodes_written: u64 = 0;
2754        let mut edges_written: u64 = 0;
2755        let docs_processed = documents.len() as u64;
2756
2757        for (batch_idx, batch) in documents.chunks(max_docs).enumerate() {
2758            let request_id = format!("req-{batch_idx}");
2759            let docs_json: Vec<Value> = batch
2760                .iter()
2761                .map(|d| {
2762                    serde_json::json!({
2763                        "source_doc_id": d.source_doc_id,
2764                        "body": d.body,
2765                    })
2766                })
2767                .collect();
2768
2769            // Send the framed extract request and receive its matching `result`.
2770            // The session adds protocol/type/request_id and validates the
2771            // type=="result" + matching request_id envelope (fix-24 [P2]).
2772            let result = session
2773                .request(&request_id, vec![("documents".to_string(), Value::Array(docs_json))])?;
2774
2775            // --- map entities → PreparedWrite::Node with stable logical_id ---
2776            let entities =
2777                result.get("entities").and_then(|v| v.as_array()).cloned().unwrap_or_default();
2778            let raw_edges =
2779                result.get("edges").and_then(|v| v.as_array()).cloned().unwrap_or_default();
2780
2781            // R3 (SCHEMA-GATE-1): collect substituted_t_valid values from
2782            // temporal_fallback warnings. An edge whose t_valid matches one of
2783            // these values had its event time defaulted to created_at (not
2784            // text-grounded) and must be flagged so BFS can exclude it.
2785            let fallback_dates: std::collections::HashSet<String> = result
2786                .get("warnings")
2787                .and_then(|v| v.as_array())
2788                .map(|ws| {
2789                    ws.iter()
2790                        .filter(|w| {
2791                            w.get("kind").and_then(|k| k.as_str()) == Some("temporal_fallback")
2792                        })
2793                        .filter_map(|w| {
2794                            w.get("substituted_t_valid")
2795                                .and_then(|v| v.as_str())
2796                                .map(str::to_string)
2797                        })
2798                        .collect()
2799                })
2800                .unwrap_or_default();
2801
2802            if !entities.is_empty() {
2803                let node_batch: Vec<PreparedWrite> = entities
2804                    .iter()
2805                    .map(|entity| -> Result<PreparedWrite, EngineError> {
2806                        let name = entity.get("name").and_then(|v| v.as_str()).unwrap_or("");
2807                        let kind = entity.get("type").and_then(|v| v.as_str()).unwrap_or("entity");
2808                        let source_doc_id = entity
2809                            .get("source_doc_id")
2810                            .and_then(|v| v.as_str())
2811                            .map(str::to_string);
2812                        // fix-34 [P1]: derive_logical_id now rejects an empty name
2813                        // or a ':' in kind — inputs that would collide distinct
2814                        // entities onto one identity and silently drop one.
2815                        let logical_id = derive_logical_id(kind, name)?;
2816                        Ok(PreparedWrite::Node {
2817                            kind: kind.to_string(),
2818                            body: name.to_string(),
2819                            source_id: source_doc_id,
2820                            logical_id: Some(logical_id),
2821                        })
2822                    })
2823                    .collect::<Result<Vec<_>, _>>()?;
2824
2825                // fix-29/fix-34 [P2]: deduplicate within the batch by logical_id so
2826                // a harness that returns the same entity twice does not write a row
2827                // that immediately supersedes its sibling (shared with the edge arm).
2828                let node_batch = dedup_prepared_by_logical_id(node_batch);
2829
2830                // fix-23 [P2]: skip entities whose logical_id is already active
2831                // to avoid needless supersede churn on re-ingest.
2832                let ids: Vec<String> = node_batch
2833                    .iter()
2834                    .filter_map(|w| {
2835                        if let PreparedWrite::Node { logical_id: Some(id), .. } = w {
2836                            Some(id.clone())
2837                        } else {
2838                            None
2839                        }
2840                    })
2841                    .collect();
2842                let existing: std::collections::HashSet<String> = self
2843                    .read_get_many(&ids)?
2844                    .into_iter()
2845                    .zip(ids)
2846                    .filter_map(|(opt, id)| opt.map(|_| id))
2847                    .collect();
2848                let new_nodes: Vec<PreparedWrite> = node_batch
2849                    .into_iter()
2850                    .filter(|w| {
2851                        if let PreparedWrite::Node { logical_id: Some(id), .. } = w {
2852                            !existing.contains(id)
2853                        } else {
2854                            true
2855                        }
2856                    })
2857                    .collect();
2858                if !new_nodes.is_empty() {
2859                    let n = new_nodes.len() as u64;
2860                    self.write(&new_nodes)?;
2861                    nodes_written = nodes_written.saturating_add(n);
2862                }
2863            }
2864
2865            // --- map edges → PreparedWrite::Edge with G11 columns ---
2866            if !raw_edges.is_empty() {
2867                // fix-33 [P1]: the protocol gives edges NO endpoint types —
2868                // `from_entity`/`to_entity` reference entities BY NAME (or alias).
2869                // Build a name+alias → (canonical name, type) index from the same
2870                // result's `entities[]` so each endpoint's logical_id matches the
2871                // node's. (Nodes derive id from the entity's real type; defaulting
2872                // the edge endpoint kind to "entity" orphaned every contract-faithful
2873                // edge from its nodes and tripped the G8 dangling probe.)
2874                //
2875                // Two passes so a canonical NAME always wins over a (different
2876                // entity's) ALIAS regardless of `entities[]` order: pass 1 inserts
2877                // all canonical names, pass 2 fills aliases only where no name
2878                // already claims that key. (Name↔name clashes remain first-wins —
2879                // contradictory input; no principled resolution exists.)
2880                let mut entity_index: std::collections::HashMap<String, (String, String)> =
2881                    std::collections::HashMap::new();
2882                for entity in &entities {
2883                    let name = entity.get("name").and_then(|v| v.as_str()).unwrap_or("");
2884                    if name.is_empty() {
2885                        continue;
2886                    }
2887                    let kind =
2888                        entity.get("type").and_then(|v| v.as_str()).unwrap_or("entity").to_string();
2889                    entity_index
2890                        .entry(name.to_lowercase())
2891                        .or_insert_with(|| (name.to_string(), kind));
2892                }
2893                for entity in &entities {
2894                    let name = entity.get("name").and_then(|v| v.as_str()).unwrap_or("");
2895                    if name.is_empty() {
2896                        continue;
2897                    }
2898                    let kind =
2899                        entity.get("type").and_then(|v| v.as_str()).unwrap_or("entity").to_string();
2900                    if let Some(aliases) = entity.get("aliases").and_then(|v| v.as_array()) {
2901                        for alias in aliases.iter().filter_map(|a| a.as_str()) {
2902                            if !alias.is_empty() {
2903                                entity_index
2904                                    .entry(alias.to_lowercase())
2905                                    .or_insert_with(|| (name.to_string(), kind.clone()));
2906                            }
2907                        }
2908                    }
2909                }
2910
2911                let edge_batch: Vec<PreparedWrite> = raw_edges
2912                    .iter()
2913                    .map(|edge| -> Result<PreparedWrite, EngineError> {
2914                        let from_entity =
2915                            edge.get("from_entity").and_then(|v| v.as_str()).unwrap_or("");
2916                        let to_entity =
2917                            edge.get("to_entity").and_then(|v| v.as_str()).unwrap_or("");
2918                        let relation =
2919                            edge.get("relation").and_then(|v| v.as_str()).unwrap_or("related_to");
2920                        let body = edge.get("body").and_then(|v| v.as_str()).map(str::to_string);
2921                        let t_valid =
2922                            edge.get("t_valid").and_then(|v| v.as_str()).map(str::to_string);
2923                        let t_invalid =
2924                            edge.get("t_invalid").and_then(|v| v.as_str()).map(str::to_string);
2925                        // fix-26 [P2]: validate confidence is in [0.0, 1.0] at the
2926                        // protocol boundary; reject out-of-range values.
2927                        let confidence = match edge.get("confidence").and_then(|v| v.as_f64()) {
2928                            Some(c) if !(0.0..=1.0).contains(&c) => {
2929                                return Err(EngineError::Extractor);
2930                            }
2931                            c => c,
2932                        };
2933                        let source_doc_id =
2934                            edge.get("source_doc_id").and_then(|v| v.as_str()).map(str::to_string);
2935
2936                        // fix-33 [P1]: resolve each endpoint via the entities[]
2937                        // index (by name or alias) → the entity's canonical
2938                        // (name, type); fall back to kind "entity" only for a truly
2939                        // unlisted name (synthesized dangling endpoints ARE listed,
2940                        // so this is the defensive path). derive_logical_id (fix-34)
2941                        // still rejects an empty name / ':' in kind.
2942                        let (from_name, from_kind) = entity_index
2943                            .get(&from_entity.to_lowercase())
2944                            .cloned()
2945                            .unwrap_or_else(|| (from_entity.to_string(), "entity".to_string()));
2946                        let (to_name, to_kind) = entity_index
2947                            .get(&to_entity.to_lowercase())
2948                            .cloned()
2949                            .unwrap_or_else(|| (to_entity.to_string(), "entity".to_string()));
2950                        let from_lid = derive_logical_id(&from_kind, &from_name)?;
2951                        let to_lid = derive_logical_id(&to_kind, &to_name)?;
2952                        let edge_key = format!("{from_lid}:{to_lid}:{relation}");
2953                        let edge_lid = derive_logical_id("edge", &edge_key)?;
2954
2955                        let is_temporal_fallback = t_valid
2956                            .as_deref()
2957                            .map(|tv| fallback_dates.contains(tv))
2958                            .unwrap_or(false);
2959                        Ok(PreparedWrite::Edge {
2960                            kind: relation.to_string(),
2961                            from: from_lid,
2962                            to: to_lid,
2963                            source_id: source_doc_id,
2964                            logical_id: Some(edge_lid),
2965                            body,
2966                            t_valid,
2967                            t_invalid,
2968                            confidence,
2969                            extractor_model_id: extractor_model_id.clone(),
2970                            temporal_fallback: if is_temporal_fallback { Some(true) } else { None },
2971                        })
2972                    })
2973                    .collect::<Result<Vec<_>, _>>()?;
2974                // fix-34 [P2]: dedup edges by logical_id, mirroring the node arm
2975                // (fix-29) — a duplicate edge in one harness response would
2976                // otherwise write a row that immediately supersedes its sibling.
2977                let edge_batch = dedup_prepared_by_logical_id(edge_batch);
2978                let n = edge_batch.len() as u64;
2979                self.write(&edge_batch)?;
2980                edges_written = edges_written.saturating_add(n);
2981            }
2982        }
2983
2984        // The `ProviderSession` (and its writer/child) is dropped by the caller
2985        // when `ingest_with_extractor` returns: Drop sends stdin EOF and reaps
2986        // the child, matching the prior explicit drop(writer)+kill/wait.
2987        Ok(IngestWithExtractorReceipt { nodes_written, edges_written, docs_processed })
2988    }
2989
2990    pub fn search(&self, query: &str) -> Result<SearchResult, EngineError> {
2991        self.search_filtered(query, None)
2992    }
2993
2994    /// G10 — hybrid `search` with an optional closed [`SearchFilter`]. `None`
2995    /// (or an all-`None` filter) is the unfiltered path whose phase-1 SQL is
2996    /// byte-identical to 0.7.2. The filter prunes the vector branch in the
2997    /// single phase-1 candidates statement and constrains the text branch by the
2998    /// same metadata. Ranking is the unconditional G9 RRF fusion.
2999    pub fn search_filtered(
3000        &self,
3001        query: &str,
3002        filter: Option<SearchFilter>,
3003    ) -> Result<SearchResult, EngineError> {
3004        // FIX-6: delegate to search_reranked(depth=0, use_graph_arm=false) to eliminate the
3005        // ~26-line duplicate body that would otherwise drift with search_reranked.
3006        // 0.8.5: depth=0 is inert, so the α/pool_n defaults (0.3, 0) never reach the blend.
3007        self.search_reranked(query, filter, 0, false, 0.3, 0)
3008    }
3009
3010    /// 0.8.1 Slice 10 (R1) / Slice 30 (R3) — `search_reranked`: hybrid search
3011    /// with optional CE reranking and optional graph-BFS third arm. `rerank_depth
3012    /// = 0` is the identity (soft-fallback) path, byte-identical to
3013    /// [`search_filtered`][Engine::search_filtered]. `rerank_depth = N > 0`
3014    /// applies the cross-encoder over the top-N fused hits (when the
3015    /// `default-reranker` feature is enabled and the model is loaded); without the
3016    /// model, the call falls back to the fused order.
3017    ///
3018    /// `use_graph_arm = false` (the default) produces byte-identical results to
3019    /// the pre-Slice-30 two-arm pipeline. `use_graph_arm = true` seeds a BFS over
3020    /// temporal fact-edges from the top-10 fused hits and fuses the reachable
3021    /// nodes as a third RRF arm.
3022    ///
3023    /// Governed surface: re-exported from `fathomdb` facade.
3024    pub fn search_reranked(
3025        &self,
3026        query: &str,
3027        filter: Option<SearchFilter>,
3028        rerank_depth: usize,
3029        use_graph_arm: bool,
3030        alpha: f64,
3031        pool_n: usize,
3032    ) -> Result<SearchResult, EngineError> {
3033        // explain=false → `SearchResult.explanation == None`, byte-identical results.
3034        self.search_reranked_with_explain(
3035            query,
3036            filter,
3037            rerank_depth,
3038            use_graph_arm,
3039            alpha,
3040            pool_n,
3041            false,
3042        )
3043    }
3044
3045    /// 0.8.8 EXP-OBS (Slice 5) — `search_explained`: the opt-in `explain=true`
3046    /// surface. Identical retrieval to [`search_reranked`][Engine::search_reranked]
3047    /// (same fused/CE ranking, same `results`), additionally returning a
3048    /// [`Explanation`] sidecar on `SearchResult.explanation` with per-hit arm
3049    /// provenance + score breakdown + a query-level [`QueryTrace`]. The default
3050    /// `search`/`search_filtered`/`search_reranked` paths are unaffected and stay
3051    /// byte-identical (R-OBS-2).
3052    ///
3053    /// Governed surface: re-exported from `fathomdb` facade.
3054    pub fn search_explained(
3055        &self,
3056        query: &str,
3057        filter: Option<SearchFilter>,
3058        rerank_depth: usize,
3059        use_graph_arm: bool,
3060        alpha: f64,
3061        pool_n: usize,
3062    ) -> Result<SearchResult, EngineError> {
3063        self.search_reranked_with_explain(
3064            query,
3065            filter,
3066            rerank_depth,
3067            use_graph_arm,
3068            alpha,
3069            pool_n,
3070            true,
3071        )
3072    }
3073
3074    /// Shared event-wrapped body for [`search_reranked`][Engine::search_reranked]
3075    /// (`explain=false`) and [`search_explained`][Engine::search_explained]
3076    /// (`explain=true`). Keeps the Started/Finished/Failed lifecycle emissions +
3077    /// slow detection in one place.
3078    #[allow(clippy::too_many_arguments)] // mirrors search_reranked + the explain flag
3079    fn search_reranked_with_explain(
3080        &self,
3081        query: &str,
3082        filter: Option<SearchFilter>,
3083        rerank_depth: usize,
3084        use_graph_arm: bool,
3085        alpha: f64,
3086        pool_n: usize,
3087        explain: bool,
3088    ) -> Result<SearchResult, EngineError> {
3089        self.emit_event(lifecycle::Phase::Started, lifecycle::EventCategory::Search, None);
3090        let started = Instant::now();
3091        let outcome =
3092            self.search_inner(query, filter, rerank_depth, use_graph_arm, alpha, pool_n, explain);
3093        self.detect_slow(started, lifecycle::EventCategory::Search);
3094        match outcome {
3095            Ok(result) => {
3096                self.counters.record_query();
3097                // 0.8.8 Slice 15 (OPP-9) — opt-in telemetry capture. No-op + no
3098                // allocation when telemetry is OFF (the default).
3099                self.capture_telemetry(query, &result);
3100                self.emit_event(lifecycle::Phase::Finished, lifecycle::EventCategory::Search, None);
3101                Ok(result)
3102            }
3103            Err(err) => {
3104                let code = err.stable_code();
3105                self.counters.record_error(code);
3106                self.emit_event(
3107                    lifecycle::Phase::Failed,
3108                    lifecycle::EventCategory::Search,
3109                    Some(code),
3110                );
3111                self.emit_event(
3112                    lifecycle::Phase::Failed,
3113                    lifecycle::EventCategory::Error,
3114                    Some(code),
3115                );
3116                Err(err)
3117            }
3118        }
3119    }
3120
3121    /// 0.8.8 Slice 15 (OPP-9) — enable opt-in telemetry capture to a local JSONL
3122    /// `sink_path` (append-only). Off by default; once enabled, each `search`
3123    /// records a query→result event and `record_feedback` appends agent labels.
3124    /// Local file only — no network/egress. `query_id` + `ts_monotonic_ms` are
3125    /// reset deterministically on enable. Idempotent re-enable resets the seq.
3126    pub fn enable_telemetry(&self, sink_path: &str) -> Result<(), EngineError> {
3127        // Touch the sink (create + validate writable) before arming capture, so a
3128        // bad path fails loudly here rather than silently dropping events.
3129        std::fs::OpenOptions::new()
3130            .create(true)
3131            .append(true)
3132            .open(sink_path)
3133            .map_err(|_| EngineError::Storage)?;
3134        let mut guard = self.telemetry.lock().map_err(|_| EngineError::Storage)?;
3135        *guard = Some(TelemetrySink {
3136            path: PathBuf::from(sink_path),
3137            base: Instant::now(),
3138            nonce: 0,
3139            seq: 0,
3140            last_query_id: None,
3141        });
3142        // Arm the fast OFF-path guard LAST (after the sink is installed) so a
3143        // concurrent search either sees telemetry fully off or fully on.
3144        self.telemetry_enabled.store(true, Ordering::Release);
3145        Ok(())
3146    }
3147
3148    /// 0.8.8 Slice 15 — the most-recent captured `query_id` (for `record_feedback`).
3149    /// `None` when telemetry is off or no query has been captured yet.
3150    pub fn last_telemetry_query_id(&self) -> Option<String> {
3151        self.telemetry.lock().ok()?.as_ref().and_then(|s| s.last_query_id.clone())
3152    }
3153
3154    /// 0.8.8 Slice 15 — capture a query→result telemetry event. No-op (no alloc,
3155    /// no I/O) when telemetry is off (the default). Best-effort: a sink write error
3156    /// never fails the search. Captures ONLY ids (stable `logical_id`), arms, and
3157    /// the query LENGTH — never the query text or `source_id` (privacy, ADR §C).
3158    fn capture_telemetry(&self, query: &str, result: &SearchResult) {
3159        // Fast OFF path (codex §9 P2): a single atomic load when telemetry has
3160        // never been enabled — NO mutex acquisition, NO contention with the search
3161        // hot path.
3162        if !self.telemetry_enabled.load(Ordering::Acquire) {
3163            return;
3164        }
3165        let Ok(mut guard) = self.telemetry.lock() else { return };
3166        let Some(sink) = guard.as_mut() else { return };
3167        let query_id = format!("q{}-{}", sink.nonce, sink.seq);
3168        let ts_monotonic_ms = sink.base.elapsed().as_millis() as u64;
3169        let mut arm_of = serde_json::Map::new();
3170        for h in &result.results {
3171            arm_of.insert(h.id.to_string(), serde_json::Value::from(branch_str(h.branch)));
3172        }
3173        let event = serde_json::json!({
3174            "type": "event",
3175            "schema_version": 1,
3176            "ts_monotonic_ms": ts_monotonic_ms,
3177            "query_id": query_id,
3178            "query_chars": query.chars().count() as u64,
3179            "result_ids": result.results.iter().map(|h| h.id).collect::<Vec<u64>>(),
3180            "arm_of": arm_of,
3181        });
3182        let _ = append_jsonl(&sink.path, &event);
3183        sink.seq += 1;
3184        sink.last_query_id = Some(query_id);
3185    }
3186
3187    /// 0.8.8 Slice 15 — append an agent-supplied relevance-label record for a
3188    /// previously-captured `query_id`. `label_source` is the only exogenous string
3189    /// (caller-declared, e.g. `"agent:hermes"`). Ids are the stable `logical_id`.
3190    /// Errors if telemetry is off.
3191    pub fn record_feedback(
3192        &self,
3193        query_id: &str,
3194        relevant_ids: &[u64],
3195        irrelevant_ids: &[u64],
3196        label_source: &str,
3197    ) -> Result<(), EngineError> {
3198        let guard = self.telemetry.lock().map_err(|_| EngineError::Storage)?;
3199        let sink = guard
3200            .as_ref()
3201            .ok_or(EngineError::InvalidArgument { msg: "telemetry is not enabled".to_string() })?;
3202        // codex §9 [P1] (privacy): `query_id` is an exogenous caller string. Only a
3203        // deterministic id that `capture_telemetry` has ALREADY emitted may be
3204        // persisted — otherwise a caller could smuggle query text / a `source_id`
3205        // into the sink under the `query_id` key. Require the canonical
3206        // `q{nonce}-{seq}` form with `nonce == sink.nonce` AND `seq < sink.seq`
3207        // (a seq the capture path has issued). Reject (writing nothing) otherwise.
3208        let is_issued_id = query_id
3209            .strip_prefix('q')
3210            .and_then(|rest| rest.split_once('-'))
3211            .and_then(|(nonce, seq)| Some((nonce.parse::<u64>().ok()?, seq.parse::<u64>().ok()?)))
3212            .is_some_and(|(nonce, seq)| nonce == sink.nonce && seq < sink.seq);
3213        if !is_issued_id {
3214            return Err(EngineError::InvalidArgument { msg: "unknown query_id".to_string() });
3215        }
3216        let record = serde_json::json!({
3217            "type": "feedback",
3218            "schema_version": 1,
3219            "query_id": query_id,
3220            "relevant_ids": relevant_ids,
3221            "irrelevant_ids": irrelevant_ids,
3222            "label_source": label_source,
3223        });
3224        append_jsonl(&sink.path, &record).map_err(|_| EngineError::Storage)
3225    }
3226
3227    fn detect_slow(&self, started: Instant, category: lifecycle::EventCategory) {
3228        let elapsed = started.elapsed();
3229        let threshold = self.slow_threshold_ms.load(Ordering::Relaxed);
3230        let threshold_duration = std::time::Duration::from_millis(threshold);
3231        if elapsed > threshold_duration {
3232            // `dev/design/lifecycle.md` § Slow and heartbeat policy: a slow
3233            // operation produces TWO correlated facts. The
3234            // statement-level slow-statement signal is dispatched by the
3235            // sqlite3_profile callback (`profile_callback_trampoline`).
3236            // This site emits the lifecycle `Phase::Slow` event for the
3237            // outer operation envelope (AC-008).
3238            self.emit_event(lifecycle::Phase::Slow, category, None);
3239        }
3240    }
3241
3242    fn emit_event(
3243        &self,
3244        phase: lifecycle::Phase,
3245        category: lifecycle::EventCategory,
3246        code: Option<&'static str>,
3247    ) {
3248        let event =
3249            lifecycle::Event { phase, source: lifecycle::EventSource::Engine, category, code };
3250        self.subscribers.dispatch(&event);
3251    }
3252
3253    /// Emit a `(SqliteInternal, Error, code: <SQLITE_*>)` lifecycle
3254    /// event for a rusqlite error. Per `dev/design/lifecycle.md`
3255    /// § Diagnostic source and category, SQLite-originated diagnostics
3256    /// route through the same host subscriber as engine-originated
3257    /// events with `source` preserved. AC-021 dispatches on
3258    /// `code == "SQLITE_SCHEMA"`.
3259    fn emit_sqlite_internal_error(&self, err: &rusqlite::Error) {
3260        if let Some(code) = sqlite_extended_code_name(err) {
3261            let event = lifecycle::Event {
3262                phase: lifecycle::Phase::Failed,
3263                source: lifecycle::EventSource::SqliteInternal,
3264                category: lifecycle::EventCategory::Error,
3265                code: Some(code),
3266            };
3267            self.subscribers.dispatch(&event);
3268        }
3269    }
3270
3271    /// Thin wrapper: the production search path that discards the G0 Phase-2
3272    /// frontier meter (it never reaches `SearchResult` / the governed surface).
3273    #[allow(clippy::too_many_arguments)] // mirrors search_reranked + the explain flag
3274    fn search_inner(
3275        &self,
3276        query: &str,
3277        filter: Option<SearchFilter>,
3278        rerank_depth: usize,
3279        use_graph_arm: bool,
3280        alpha: f64,
3281        pool_n: usize,
3282        explain: bool,
3283    ) -> Result<SearchResult, EngineError> {
3284        self.search_inner_with_stats(
3285            query,
3286            filter,
3287            rerank_depth,
3288            use_graph_arm,
3289            alpha,
3290            pool_n,
3291            explain,
3292        )
3293        .map(|(result, _stats)| result)
3294    }
3295
3296    /// G0 Phase-2: the search body, additionally returning the graph-arm frontier
3297    /// meter. Only the `_graph_frontier_stats_for_test` seam consumes the stats;
3298    /// `search_inner` (and thus `search_reranked` / `search`) drops them.
3299    #[allow(clippy::too_many_arguments)] // mirrors search_reranked + the explain flag
3300    fn search_inner_with_stats(
3301        &self,
3302        query: &str,
3303        filter: Option<SearchFilter>,
3304        rerank_depth: usize,
3305        use_graph_arm: bool,
3306        alpha: f64,
3307        pool_n: usize,
3308        explain: bool,
3309    ) -> Result<(SearchResult, GraphFrontierStats), EngineError> {
3310        self.ensure_open()?;
3311        if query.trim().is_empty() {
3312            return Err(EngineError::WriteValidation);
3313        }
3314
3315        let compiled = compile_text_query(query);
3316        // REQ-013 / AC-059b / REQ-055: the cursor returned with a search
3317        // MUST be derived from the same WAL snapshot the data was read
3318        // from. Loading `next_cursor` from the writer-side atomic before
3319        // the reader transaction acquires its snapshot races against
3320        // concurrent writers — see `dev/design/engine.md` § Cursor
3321        // contract. Run cursor probe + body query inside one read tx
3322        // (BEGIN DEFERRED on a `query_only=ON` connection in WAL mode is
3323        // a snapshot-stable read).
3324        // EU-5a2 mean-centering apply path (query side). `query_vector`
3325        // is ALWAYS un-centered (used by the f32 vec_distance_l2 rerank
3326        // in phase 2). `query_vector_bin` is the (possibly centered) f32
3327        // fed to `vec_quantize_binary` in phase 1. The centering decision
3328        // mirrors the write path: identity must be MC-required AND a
3329        // mean_vec must be pinned. NoopEmbedder collapses to
3330        // `query_vector_bin == query_vector` until EU-5b.
3331        let raw_query_vector =
3332            self.runtime_embedder.as_ref().and_then(|embedder| embedder.embed(query).ok());
3333        let query_vector_bin = match raw_query_vector.as_ref() {
3334            Some(vector) if identity_requires_mean_centering(&self.runtime_embedder_identity) => {
3335                let pinned = {
3336                    let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
3337                    let connection = connection.as_ref().ok_or(EngineError::Closing)?;
3338                    read_pinned_mean_vec(connection, self.runtime_embedder_identity.dimension)?
3339                };
3340                match pinned {
3341                    Some(mean) => serde_json::to_string(&subtract_mean(vector, &mean)).ok(),
3342                    None => serde_json::to_string(vector).ok(),
3343                }
3344            }
3345            Some(vector) => serde_json::to_string(vector).ok(),
3346            None => None,
3347        };
3348        let query_vector = raw_query_vector.and_then(|vector| serde_json::to_string(&vector).ok());
3349        // 0.7.2 PR-2bc S1 fix-1 — phase-2 rerank LIMIT. Production default is
3350        // `SEARCH_RERANK_LIMIT` (10); the test seam may RAISE it, clamped to
3351        // the production floor so a test can never shrink search semantics.
3352        let search_limit = self
3353            .projection_runtime
3354            .shared
3355            .search_limit_override
3356            .load(Ordering::SeqCst)
3357            .max(SEARCH_RERANK_LIMIT);
3358        let recency_enabled =
3359            self.projection_runtime.shared.recency_reweight_enabled.load(Ordering::SeqCst);
3360        let vector_stage_only =
3361            self.projection_runtime.shared.vector_stage_only_for_test.load(Ordering::SeqCst);
3362        let (response_tx, response_rx) = mpsc::sync_channel::<ReaderResponse>(1);
3363        let request = ReaderRequest::Search {
3364            compiled,
3365            query_vector,
3366            query_vector_bin,
3367            search_limit,
3368            filter: filter.map(Box::new),
3369            recency_enabled,
3370            vector_stage_only,
3371            raw_query: Box::from(query), // FIX-4: Box<str> (16B) not String (24B)
3372            rerank_depth,
3373            use_graph_arm,
3374            alpha,
3375            pool_n,
3376            explain,
3377            respond: response_tx,
3378        };
3379        if self.reader_pool.dispatch(request).is_err() {
3380            return Err(EngineError::Closing);
3381        }
3382        let search_result = response_rx.recv().map_err(|_| EngineError::Storage)?;
3383        let (cursor, soft_fallback, results, graph_stats, explanation) = match search_result {
3384            Ok(result) => result,
3385            Err(err) => {
3386                self.emit_sqlite_internal_error(&err);
3387                return Err(EngineError::Storage);
3388            }
3389        };
3390
3391        // The worker (`read_search_in_tx`) has no embedder identity; fill the
3392        // trace's `embedder_id` here, where `self.runtime_embedder_identity` is in
3393        // scope. Only on the explain path (`explanation` is `Some`).
3394        let explanation = explanation.map(|mut exp| {
3395            let id = &self.runtime_embedder_identity;
3396            exp.trace.embedder_id = format!("{}@{} (dim={})", id.name, id.revision, id.dimension);
3397            exp
3398        });
3399
3400        Ok((
3401            SearchResult { projection_cursor: cursor, soft_fallback, results, explanation },
3402            graph_stats,
3403        ))
3404    }
3405
3406    /// G0 Phase-2 (BLOCK-1) test seam — runs the graph-arm retrieval path and
3407    /// returns the frontier meter (`GraphFrontierStats`) for `query`. Mirrors the
3408    /// sanctioned `set_vector_stage_only_for_test` / `_configure_vector_kind_for_test`
3409    /// pattern: kept OFF the governed surface (test/eval-only), so the meter never
3410    /// appears on `SearchResult`. Used by the recall harness to prove the
3411    /// doc-seeded frontier is empty (`resolved_seed_rate == 0.0`) and, post-C1, the
3412    /// 0→>0 flip.
3413    pub fn _graph_frontier_stats_for_test(
3414        &self,
3415        query: &str,
3416    ) -> Result<GraphFrontierStats, EngineError> {
3417        self.search_inner_with_stats(query, None, 0, true, 0.3, 0, false)
3418            .map(|(_result, stats)| stats)
3419    }
3420
3421    /// Slice 30 (G2) — `read.get`: active-only point lookup by `logical_id`.
3422    /// Delegates to [`Engine::read_get_many`]; returns the single slot. A
3423    /// missing/superseded id is `None` (a normal absence, not an error). Reads
3424    /// ride the ReaderWorkerPool DEFERRED-tx path (never the writer lock).
3425    pub fn read_get(&self, logical_id: &str) -> Result<Option<NodeRecord>, EngineError> {
3426        let ids = [logical_id.to_string()];
3427        let rows = self.read_get_many(&ids)?;
3428        Ok(rows.into_iter().next().flatten())
3429    }
3430
3431    /// Slice 30 (G2) — `read.get_many`: active-only point lookup over many
3432    /// `logical_id`s. Returns one slot per requested id in REQUEST ORDER, `None`
3433    /// where no active row carries that id (partial, never all-or-nothing).
3434    pub fn read_get_many(
3435        &self,
3436        logical_ids: &[String],
3437    ) -> Result<Vec<Option<NodeRecord>>, EngineError> {
3438        self.ensure_open()?;
3439        if logical_ids.is_empty() {
3440            return Ok(Vec::new());
3441        }
3442        let (response_tx, response_rx) = mpsc::sync_channel(1);
3443        let request =
3444            ReaderRequest::GetById { logical_ids: logical_ids.to_vec(), respond: response_tx };
3445        if self.reader_pool.dispatch(request).is_err() {
3446            return Err(EngineError::Closing);
3447        }
3448        match response_rx.recv().map_err(|_| EngineError::Storage)? {
3449            Ok(rows) => Ok(rows),
3450            Err(err) => {
3451                self.emit_sqlite_internal_error(&err);
3452                Err(EngineError::Storage)
3453            }
3454        }
3455    }
3456
3457    /// Slice 20 (G5) — `read.neighbors`: bounded BFS from `root_logical_id`
3458    /// over `canonical_edges`. Returns nodes reachable within `depth` hops
3459    /// (`1..=3`) in the given `direction`, excluding the root itself.
3460    ///
3461    /// Hard cap: 50 results (engine-enforced `LIMIT 50`).
3462    /// Traversal filter: `superseded_at IS NULL AND (t_invalid IS NULL OR t_invalid > now)`.
3463    ///
3464    /// Returns `Err(EngineError::InvalidArgument)` for `depth > 3`.
3465    /// Returns `Ok(vec![])` for an unknown/superseded root.
3466    /// Reads ride the `ReaderWorkerPool` DEFERRED-tx path.
3467    pub fn graph_neighbors(
3468        &self,
3469        root_logical_id: &str,
3470        depth: u32,
3471        direction: TraversalDirection,
3472    ) -> Result<Vec<NodeRecord>, EngineError> {
3473        self.ensure_open()?;
3474        if depth == 0 || depth > 3 {
3475            return Err(EngineError::InvalidArgument {
3476                msg: format!("traversal depth {depth} is out of range; must be 1, 2, or 3"),
3477            });
3478        }
3479        let (response_tx, response_rx) = mpsc::sync_channel(1);
3480        let request = ReaderRequest::GraphNeighbors {
3481            root_logical_id: root_logical_id.to_string(),
3482            depth,
3483            direction,
3484            respond: response_tx,
3485        };
3486        if self.reader_pool.dispatch(request).is_err() {
3487            return Err(EngineError::Closing);
3488        }
3489        match response_rx.recv().map_err(|_| EngineError::Storage)? {
3490            Ok(nodes) => Ok(nodes),
3491            Err(err) => {
3492                self.emit_sqlite_internal_error(&err);
3493                Err(EngineError::Storage)
3494            }
3495        }
3496    }
3497
3498    /// Slice 20 (G6) — `search_expand`: hybrid search (`G1+G9`) followed by
3499    /// bounded BFS expansion (`G5`) of each search hit. Returns the original
3500    /// search hits (with RRF scores) plus nodes reachable from any hit via
3501    /// up to `depth` hops that are NOT already in the search hit set.
3502    ///
3503    /// Returns `Err(EngineError::InvalidArgument)` for `depth > 3`.
3504    /// A `depth = 0` call returns search hits with their logical_ids resolved
3505    /// but no BFS expansion. Reads ride the `ReaderWorkerPool` DEFERRED-tx path.
3506    ///
3507    /// **Snapshot note:** the search phase (`search_inner`) and the expansion
3508    /// phase (`SearchExpand` reader request) run in separate DEFERRED reader
3509    /// transactions; a write that lands between them is visible to expansion
3510    /// but not search (or vice-versa). In practice the window is negligible for
3511    /// single-process embedded use. The expansion phase mitigates drift by
3512    /// filtering `search_hits` to only include hits whose `write_cursor` is
3513    /// still active in the expansion snapshot (superseded hits are dropped from
3514    /// the result rather than surfaced with stale data).
3515    pub fn search_expand(
3516        &self,
3517        query: &str,
3518        filter: Option<SearchFilter>,
3519        depth: u32,
3520    ) -> Result<SearchExpandResult, EngineError> {
3521        self.ensure_open()?;
3522        if depth > 3 {
3523            return Err(EngineError::InvalidArgument {
3524                msg: format!("traversal depth {depth} exceeds the SDK ceiling of 3"),
3525            });
3526        }
3527        // Step 1: run the hybrid search to get initial hits (no CE reranking in expand).
3528        // 0.8.5: depth=0 → no rerank, so α/pool_n (0.3, 0) are inert here.
3529        let search_result = self.search_inner(query, filter, 0, false, 0.3, 0, false)?;
3530        if search_result.results.is_empty() {
3531            return Ok(SearchExpandResult {
3532                search_hits: Vec::new(),
3533                expanded: Vec::new(),
3534                all_logical_ids: Vec::new(),
3535            });
3536        }
3537        // Step 2: dispatch to the reader pool to resolve logical_ids and run BFS.
3538        // depth=0 is forwarded to the reader so it can populate all_logical_ids
3539        // (the union of search-hit logical_ids), even with no expansion.
3540        let (response_tx, response_rx) = mpsc::sync_channel(1);
3541        let request = ReaderRequest::SearchExpand {
3542            search_hits: search_result.results,
3543            depth,
3544            respond: response_tx,
3545        };
3546        if self.reader_pool.dispatch(request).is_err() {
3547            return Err(EngineError::Closing);
3548        }
3549        match response_rx.recv().map_err(|_| EngineError::Storage)? {
3550            Ok(result) => Ok(result),
3551            Err(err) => {
3552                self.emit_sqlite_internal_error(&err);
3553                Err(EngineError::Storage)
3554            }
3555        }
3556    }
3557
3558    /// Slice 20 test seam — run `EXPLAIN QUERY PLAN` on the BFS CTE SQL and
3559    /// return the plan detail lines. Used by `explain_plan_uses_indexes`.
3560    #[doc(hidden)]
3561    pub fn explain_graph_neighbors_for_test(
3562        &self,
3563        root_logical_id: &str,
3564        depth: u32,
3565        direction: TraversalDirection,
3566    ) -> Result<Vec<String>, EngineError> {
3567        self.ensure_open()?;
3568        let (response_tx, response_rx) = mpsc::sync_channel(1);
3569        let request = ReaderRequest::ExplainGraphNeighbors {
3570            root_logical_id: root_logical_id.to_string(),
3571            depth,
3572            direction,
3573            respond: response_tx,
3574        };
3575        if self.reader_pool.dispatch(request).is_err() {
3576            return Err(EngineError::Closing);
3577        }
3578        match response_rx.recv().map_err(|_| EngineError::Storage)? {
3579            Ok(plan) => Ok(plan),
3580            Err(err) => {
3581                self.emit_sqlite_internal_error(&err);
3582                Err(EngineError::Storage)
3583            }
3584        }
3585    }
3586
3587    /// Slice 30 (G3) — `read.collection`: paginated op-store read-back over
3588    /// `operational_mutations` for `collection`, `ORDER BY id`. `limit` is
3589    /// MANDATORY (clamped to the ~1M cap); `after_id` is the exclusive cursor.
3590    /// Reads ride the ReaderWorkerPool DEFERRED-tx path.
3591    pub fn read_collection(
3592        &self,
3593        collection: &str,
3594        after_id: Option<i64>,
3595        limit: usize,
3596    ) -> Result<Vec<OpStoreRow>, EngineError> {
3597        self.read_collection_dispatch(collection, after_id, limit)
3598    }
3599
3600    /// Slice 30 (G3) — `read.mutations`: the mutation-log-oriented alias surface
3601    /// over the SAME op-store read-back as [`Engine::read_collection`].
3602    pub fn read_mutations(
3603        &self,
3604        collection: &str,
3605        after_id: Option<i64>,
3606        limit: usize,
3607    ) -> Result<Vec<OpStoreRow>, EngineError> {
3608        self.read_collection_dispatch(collection, after_id, limit)
3609    }
3610
3611    fn read_collection_dispatch(
3612        &self,
3613        collection: &str,
3614        after_id: Option<i64>,
3615        limit: usize,
3616    ) -> Result<Vec<OpStoreRow>, EngineError> {
3617        self.ensure_open()?;
3618        let (response_tx, response_rx) = mpsc::sync_channel(1);
3619        let request = ReaderRequest::ReadCollection {
3620            collection: collection.to_string(),
3621            after_id,
3622            limit,
3623            respond: response_tx,
3624        };
3625        if self.reader_pool.dispatch(request).is_err() {
3626            return Err(EngineError::Closing);
3627        }
3628        match response_rx.recv().map_err(|_| EngineError::Storage)? {
3629            Ok(rows) => Ok(rows),
3630            Err(err) => {
3631                self.emit_sqlite_internal_error(&err);
3632                Err(EngineError::Storage)
3633            }
3634        }
3635    }
3636
3637    /// Slice 35 (G4) — `read.list`: list active `canonical_nodes` of a given
3638    /// `kind`, optionally filtered by a closed [`Predicate`] set, up to `limit`
3639    /// rows. Returns `Vec<NodeRecord>` (active only; `superseded_at IS NULL`).
3640    ///
3641    /// Multiple predicates are combined as AND (D-F5). An empty predicate slice
3642    /// returns all active nodes of the given kind up to `limit` (unfiltered path).
3643    /// Compilation target: `json_extract(body, '$.field') <op> ?` with bound
3644    /// parameters (injection-safe per D-F4). See `dev/adr/ADR-0.8.0-filter-grammar.md`.
3645    ///
3646    /// Path validation happens at [`Predicate`] construction time; `read_list`
3647    /// revalidates as defense-in-depth (enum variants are `pub`, so direct
3648    /// struct-literal construction could bypass the constructors).
3649    pub fn read_list(
3650        &self,
3651        kind: &str,
3652        predicates: &[Predicate],
3653        limit: usize,
3654    ) -> Result<Vec<NodeRecord>, EngineError> {
3655        self.ensure_open()?;
3656        // Defense-in-depth: revalidate paths even if the caller bypassed the
3657        // validated constructors by constructing enum variants directly.
3658        for pred in predicates {
3659            let path = pred.path();
3660            if !PREDICATE_PATH_ALLOWLIST.contains(&path) {
3661                return Err(EngineError::InvalidFilter {
3662                    reason: format!("path '{path}' is not in the predicate path allowlist"),
3663                });
3664            }
3665        }
3666        let (response_tx, response_rx) = mpsc::sync_channel(1);
3667        let request = ReaderRequest::ReadList {
3668            kind: kind.to_string(),
3669            predicates: predicates.to_vec(),
3670            limit,
3671            respond: response_tx,
3672        };
3673        if self.reader_pool.dispatch(request).is_err() {
3674            return Err(EngineError::Closing);
3675        }
3676        match response_rx.recv().map_err(|_| EngineError::Storage)? {
3677            Ok(rows) => Ok(rows),
3678            Err(err) => {
3679                self.emit_sqlite_internal_error(&err);
3680                Err(EngineError::Storage)
3681            }
3682        }
3683    }
3684
3685    pub fn close(&self) -> Result<(), EngineError> {
3686        self.closed.store(true, Ordering::SeqCst);
3687        self.projection_runtime.stop();
3688        // Uninstall profile callbacks before dropping the connections so
3689        // SQLite cannot fire one last callback against a profile context
3690        // whose Box is about to free. Per `dev/design/engine.md` § Close
3691        // path step 6, readers drain before the writer connection so
3692        // SQLite's last-handle checkpointer runs on the writer. Each
3693        // reader worker uninstalls its own callback inside
3694        // `reader_worker_loop` before dropping its connection, then
3695        // exits — `shutdown` joins those threads here.
3696        self.reader_pool.shutdown();
3697        if let Ok(mut connection) = self.connection.lock() {
3698            if let Some(conn) = connection.as_ref() {
3699                uninstall_profile_callback(conn);
3700            }
3701            connection.take();
3702        }
3703        if let Ok(mut contexts) = self.profile_contexts.lock() {
3704            contexts.clear();
3705        }
3706        if let Ok(mut lock) = self.lock.lock() {
3707            lock.take();
3708        }
3709        Ok(())
3710    }
3711
3712    /// Block until in-flight writes drain or `timeout_ms` elapses.
3713    ///
3714    /// Surface owned by `dev/interfaces/rust.md` § Engine-attached
3715    /// instrumentation; semantics are owned by `dev/design/lifecycle.md`.
3716    pub fn drain(&self, timeout_ms: u64) -> Result<(), EngineError> {
3717        self.ensure_open()?;
3718        if self.projection_runtime.wait_for_idle(timeout_ms) {
3719            Ok(())
3720        } else {
3721            Err(EngineError::Scheduler)
3722        }
3723    }
3724
3725    /// Snapshot of engine-internal counters.
3726    ///
3727    /// Field set owned by `dev/design/lifecycle.md`.
3728    #[must_use]
3729    pub fn counters(&self) -> CounterSnapshot {
3730        self.counters.snapshot()
3731    }
3732
3733    /// Toggle response-cycle profiling.
3734    ///
3735    /// Per `dev/design/lifecycle.md` § Per-statement profiling, profiling
3736    /// is an opt-in surface that is independently toggleable on a running
3737    /// engine without restart. AC-005a locks runtime toggleability.
3738    pub fn set_profiling(&self, enabled: bool) -> Result<(), EngineError> {
3739        self.profiling_enabled.store(enabled, Ordering::Relaxed);
3740        Ok(())
3741    }
3742
3743    /// Set the threshold above which an operation is reported as slow.
3744    ///
3745    /// Per `dev/design/lifecycle.md` § Slow and heartbeat policy, the
3746    /// threshold is runtime-configurable; mutating it changes detection
3747    /// behavior on subsequent statements without restart (AC-007b).
3748    pub fn set_slow_threshold_ms(&self, value: u64) -> Result<(), EngineError> {
3749        self.slow_threshold_ms.store(value, Ordering::Relaxed);
3750        Ok(())
3751    }
3752
3753    /// Attach a host subscriber to engine events.
3754    ///
3755    /// Dropping the returned [`Subscription`] detaches the subscriber.
3756    /// Payload shape owned by `dev/design/lifecycle.md` and
3757    /// `dev/design/migrations.md`.
3758    #[must_use]
3759    pub fn subscribe(&self, subscriber: Arc<dyn lifecycle::Subscriber>) -> Subscription {
3760        self.subscribers.attach(subscriber)
3761    }
3762
3763    #[cfg(debug_assertions)]
3764    #[doc(hidden)]
3765    pub fn reader_worker_count_for_test(&self) -> usize {
3766        self.reader_pool.worker_count()
3767    }
3768
3769    #[cfg(debug_assertions)]
3770    #[doc(hidden)]
3771    pub fn live_reader_worker_count_for_test(&self) -> usize {
3772        self.reader_pool.live_count()
3773    }
3774
3775    /// Pack 6.G G.1 — return the `sqlite3_db_config(LOOKASIDE)` rc
3776    /// captured for each reader worker at open time, in worker index
3777    /// order. SQLITE_OK (= 0) means the lookaside was configured
3778    /// before any allocation happened on the connection.
3779    #[cfg(debug_assertions)]
3780    #[doc(hidden)]
3781    pub fn reader_lookaside_config_rcs_for_test(&self) -> Vec<i32> {
3782        self.reader_lookaside_rcs.clone()
3783    }
3784
3785    /// Pack 6.G G.1 — query each reader worker's
3786    /// `SQLITE_DBSTATUS_LOOKASIDE_USED` counter. A value > 0 means at
3787    /// least one allocation was satisfied from the per-connection
3788    /// lookaside arena (proof the configuration was honored before the
3789    /// first prepare).
3790    #[cfg(debug_assertions)]
3791    #[doc(hidden)]
3792    pub fn reader_lookaside_used_per_worker_for_test(&self) -> Vec<i32> {
3793        self.reader_pool.lookaside_used_per_worker()
3794    }
3795
3796    /// Pack 6.G G.3.5 — broadcast a debug-only `CacheStatus` request to
3797    /// every reader worker and collect per-worker
3798    /// `SQLITE_DBSTATUS_CACHE_HIT` / `_CACHE_MISS` / `_CACHE_USED`
3799    /// values. Counters are monotonic (reset flag = 0); callers compute
3800    /// pre/post deltas explicitly.
3801    #[cfg(debug_assertions)]
3802    #[doc(hidden)]
3803    pub fn cache_status_per_worker_for_test(&self, label: &str) -> Vec<CacheStatusReply> {
3804        self.reader_pool.cache_status_per_worker(label)
3805    }
3806
3807    #[cfg(debug_assertions)]
3808    #[doc(hidden)]
3809    pub fn force_next_commit_failure_for_test(&self) {
3810        self.force_next_commit_failure.store(true, Ordering::SeqCst);
3811    }
3812
3813    /// Execute an arbitrary SQL statement on the writer connection through
3814    /// the same wall-clock + slow-detect path as `write` / `search`.
3815    ///
3816    /// Test-only helper for the deterministic-slow-cte fixture used by
3817    /// AC-007a / AC-007b. Not part of the public 0.6.0 surface; gated on
3818    /// `debug_assertions` so release builds do not expose it.
3819    #[cfg(debug_assertions)]
3820    #[doc(hidden)]
3821    pub fn execute_for_test(&self, sql: &str) -> Result<(), EngineError> {
3822        self.ensure_open()?;
3823        let started = Instant::now();
3824        {
3825            let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
3826            let connection = connection.as_mut().ok_or(EngineError::Closing)?;
3827            connection.execute_batch(sql).map_err(|_| EngineError::Storage)?;
3828        }
3829        self.detect_slow(started, lifecycle::EventCategory::Search);
3830        Ok(())
3831    }
3832
3833    /// One-thread-poison robustness fixture (AC-009).
3834    ///
3835    /// Spawns four reader threads + one writer thread that all make
3836    /// forward progress (single canonical write + repeated searches),
3837    /// plus one designated poison thread that runs an empty-batch write
3838    /// — a deterministic `EngineError::WriteValidation`. The captured
3839    /// poison failure is dispatched as a `StressFailureContext` whose
3840    /// `last_error_chain` is `[EngineError::stable_code(),
3841    /// engine_error.to_string()]` per the lifecycle § Stress-failure
3842    /// context payload contract.
3843    #[doc(hidden)]
3844    #[cfg(debug_assertions)]
3845    pub fn run_one_thread_poison_for_test(&self) -> Result<(), EngineError> {
3846        self.ensure_open()?;
3847
3848        // Forward-progress writer seeds a row so readers + the poison
3849        // thread share a non-trivial canonical state.
3850        self.write(&[PreparedWrite::Node {
3851            kind: "doc".to_string(),
3852            body: "poison-fixture-seed".to_string(),
3853            source_id: None,
3854            logical_id: None,
3855        }])?;
3856
3857        let poison_outcome: Mutex<Option<EngineError>> = Mutex::new(None);
3858        let poison_thread_id: AtomicU64 = AtomicU64::new(0);
3859
3860        thread::scope(|scope| {
3861            // N=4 reader threads make forward progress.
3862            for _ in 0..4 {
3863                scope.spawn(|| {
3864                    for _ in 0..4 {
3865                        let _ = self.search("poison-fixture-seed");
3866                    }
3867                });
3868            }
3869            // One forward-progress writer thread.
3870            scope.spawn(|| {
3871                let _ = self.write(&[PreparedWrite::Node {
3872                    kind: "doc".to_string(),
3873                    body: "writer-progress".to_string(),
3874                    source_id: None,
3875                    logical_id: None,
3876                }]);
3877            });
3878            // One poison thread — empty batch is a deterministic
3879            // WriteValidation failure.
3880            scope.spawn(|| {
3881                // Use a non-zero, deterministic group id so subscribers
3882                // see a stable identifier across runs of the fixture.
3883                poison_thread_id.store(1, Ordering::SeqCst);
3884                if let Err(err) = self.write(&[]) {
3885                    *poison_outcome.lock().expect("poison_outcome lock") = Some(err);
3886                }
3887            });
3888        });
3889
3890        let err = poison_outcome
3891            .into_inner()
3892            .expect("poison_outcome lock")
3893            .expect("poison thread must produce a deterministic error");
3894
3895        let projection_state = match self.projection_status_for_test("doc") {
3896            Ok(lifecycle::ProjectionStatus::Pending) => "Pending",
3897            Ok(lifecycle::ProjectionStatus::Failed) => "Failed",
3898            Ok(lifecycle::ProjectionStatus::UpToDate) => "UpToDate",
3899            // Default to UpToDate when projection status is unobservable
3900            // (e.g. embedder not configured for the seed kind). The
3901            // value is still one of the documented enum stringifications
3902            // per AC-010.
3903            Err(_) => "UpToDate",
3904        };
3905
3906        let context = lifecycle::StressFailureContext {
3907            thread_group_id: poison_thread_id.load(Ordering::SeqCst),
3908            op_kind: "write".to_string(),
3909            last_error_chain: vec![err.stable_code().to_string(), err.to_string()],
3910            projection_state: projection_state.to_string(),
3911        };
3912        self.subscribers.dispatch_stress_failure(&context);
3913        Ok(())
3914    }
3915
3916    #[doc(hidden)]
3917    pub fn set_projection_scheduler_frozen_for_test(&self, frozen: bool) {
3918        self.projection_runtime.set_frozen(frozen);
3919    }
3920
3921    #[doc(hidden)]
3922    pub fn set_projection_retry_delays_for_test(&self, delays_ms: &[u64]) {
3923        self.projection_runtime.set_retry_delays_for_test(delays_ms);
3924    }
3925
3926    /// PR-9 — lower the ADR-0.6.0 Invariant 5 per-`embed()` watchdog deadline
3927    /// for tests (production default is `DEFAULT_EMBED_TIMEOUT_MS` = 30s).
3928    #[doc(hidden)]
3929    pub fn set_embed_timeout_ms_for_test(&self, timeout_ms: u64) {
3930        self.projection_runtime.set_embed_timeout_ms_for_test(timeout_ms);
3931    }
3932
3933    /// PR-9 — lower the embed circuit-breaker threshold for tests (production
3934    /// default `DEFAULT_EMBED_CIRCUIT_THRESHOLD`); 0 disables the breaker.
3935    #[doc(hidden)]
3936    pub fn set_embed_circuit_threshold_for_test(&self, threshold: u64) {
3937        self.projection_runtime.set_embed_circuit_threshold_for_test(threshold);
3938    }
3939
3940    /// PR-9 — whether the embed circuit breaker has latched open.
3941    #[doc(hidden)]
3942    pub fn embed_circuit_open_for_test(&self) -> bool {
3943        self.projection_runtime.embed_circuit_open_for_test()
3944    }
3945
3946    #[doc(hidden)]
3947    pub fn projection_status_for_test(
3948        &self,
3949        kind: &str,
3950    ) -> Result<lifecycle::ProjectionStatus, EngineError> {
3951        self.ensure_open()?;
3952        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
3953        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
3954        projection_status(connection, kind)
3955    }
3956
3957    #[doc(hidden)]
3958    pub fn has_vector_for_cursor_for_test(&self, cursor: u64) -> Result<bool, EngineError> {
3959        self.ensure_open()?;
3960        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
3961        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
3962        terminal_state_for_cursor(connection, cursor)
3963            .map(|state| matches!(state.as_deref(), Some("up_to_date")))
3964            .map_err(|_| EngineError::Storage)
3965    }
3966
3967    #[doc(hidden)]
3968    pub fn projection_failure_count_for_test(&self, cursor: u64) -> Result<u64, EngineError> {
3969        self.ensure_open()?;
3970        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
3971        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
3972        connection
3973            .query_row(
3974                "SELECT COUNT(*) FROM operational_mutations
3975                 WHERE collection_name = 'projection_failures'
3976                   AND record_key = ?1",
3977                [cursor.to_string()],
3978                |row| row.get::<_, u64>(0),
3979            )
3980            .map_err(|_| EngineError::Storage)
3981    }
3982
3983    #[doc(hidden)]
3984    pub fn set_provenance_row_cap_for_test(&self, cap: Option<u64>) {
3985        self.provenance_row_cap.store(cap.unwrap_or(0), Ordering::Relaxed);
3986    }
3987
3988    #[doc(hidden)]
3989    pub fn provenance_row_count_for_test(&self) -> Result<u64, EngineError> {
3990        self.ensure_open()?;
3991        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
3992        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
3993        connection
3994            .query_row("SELECT COUNT(*) FROM operational_mutations", [], |row| row.get::<_, u64>(0))
3995            .map_err(|_| EngineError::Storage)
3996    }
3997
3998    #[doc(hidden)]
3999    pub fn oldest_provenance_record_key_for_test(
4000        &self,
4001        collection: &str,
4002    ) -> Result<Option<String>, EngineError> {
4003        self.ensure_open()?;
4004        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
4005        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
4006        connection
4007            .query_row(
4008                "SELECT record_key FROM operational_mutations
4009                 WHERE collection_name = ?1
4010                 ORDER BY id
4011                 LIMIT 1",
4012                [collection],
4013                |row| row.get::<_, String>(0),
4014            )
4015            .map(Some)
4016            .or_else(|err| match err {
4017                rusqlite::Error::QueryReturnedNoRows => Ok(None),
4018                _ => Err(EngineError::Storage),
4019            })
4020    }
4021
4022    #[doc(hidden)]
4023    pub fn configure_vector_kind_for_test(&self, kind: &str) -> Result<(), EngineError> {
4024        self.ensure_open()?;
4025        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
4026        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
4027        connection
4028            .execute(
4029                "INSERT OR REPLACE INTO _fathomdb_vector_kinds(kind, profile, created_at)
4030                 VALUES(?1, ?2, 0)",
4031                params![kind, DEFAULT_VECTOR_PROFILE],
4032            )
4033            .map_err(|_| EngineError::Storage)?;
4034        Ok(())
4035    }
4036
4037    /// Embed arbitrary text with the engine's configured runtime embedder,
4038    /// returning the raw (un-centered) vector.
4039    ///
4040    /// This is the read-path embed primitive: it mirrors the search
4041    /// query-embedding path — a single, direct [`Embedder::embed`] call. The
4042    /// per-`embed()` watchdog/circuit-breaker guards only the bulk
4043    /// projection/write path (many embeds, fault isolation), not single
4044    /// read-side embeds, so a direct call is consistent with how a query is
4045    /// embedded. Callers get vectors under the engine's *pinned* embedder
4046    /// identity (`fathomdb-bge-small-en-v1.5` by default) rather than a
4047    /// parallel, possibly-divergent embedder.
4048    ///
4049    /// Returns [`EngineError::EmbedderNotConfigured`] if the engine was opened
4050    /// without an embedder (`use_default_embedder = false`).
4051    pub fn embed_text(&self, text: &str) -> Result<Vec<f32>, EngineError> {
4052        self.ensure_open()?;
4053        let embedder =
4054            self.runtime_embedder.as_ref().cloned().ok_or(EngineError::EmbedderNotConfigured)?;
4055        embedder.embed(text).map_err(map_runtime_embedder_error)
4056    }
4057
4058    #[doc(hidden)]
4059    pub fn write_vector_for_test(
4060        &self,
4061        kind: &str,
4062        text: &str,
4063    ) -> Result<WriteReceipt, EngineError> {
4064        self.ensure_open()?;
4065        let embedder =
4066            self.runtime_embedder.as_ref().cloned().ok_or(EngineError::EmbedderNotConfigured)?;
4067
4068        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
4069        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
4070        if !kind_is_vector_indexed(connection, kind)? {
4071            return Err(EngineError::KindNotVectorIndexed);
4072        }
4073
4074        let expected = default_profile_dimension(connection)?;
4075        ensure_vector_partition(connection, expected).map_err(|_| EngineError::Storage)?;
4076        let vector = embedder.embed(text).map_err(map_runtime_embedder_error)?;
4077        let actual = u32::try_from(vector.len()).unwrap_or(u32::MAX);
4078        if actual != expected {
4079            return Err(EngineError::EmbedderDimensionMismatch { expected, actual });
4080        }
4081
4082        let cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
4083        // EU-5a2 mean-centering apply path (write side). f32 BLOB stored
4084        // is ALWAYS un-centered; the sign-quant input is the centered
4085        // vector iff the identity is MC-required AND a `mean_vec` is
4086        // pinned. NoopEmbedder identity (the only EU-5a2 live one) is
4087        // NOT MC-required, so this is a no-op until EU-5b's flip.
4088        let blob = encode_vector_blob(&vector);
4089        let bin_blob = if identity_requires_mean_centering(&self.runtime_embedder_identity) {
4090            match read_pinned_mean_vec(connection, self.runtime_embedder_identity.dimension)? {
4091                Some(mean) => encode_vector_blob(&subtract_mean(&vector, &mean)),
4092                None => blob.clone(),
4093            }
4094        } else {
4095            blob.clone()
4096        };
4097        let source_type = resolve_source_type(kind)?;
4098        let now_unix =
4099            SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
4100
4101        // EU-5b — feed the streaming mean accumulator (if live) and detect
4102        // a threshold-crossing pin. The mean materialization, pre-pin
4103        // re-quantize, and `MeanVecPinned` event emission all happen in
4104        // the SAME SQLite transaction as the row INSERT.
4105        let pin_event = {
4106            let runtime = &self.projection_runtime.shared;
4107            let mut accumulator =
4108                runtime.mean_accumulator.lock().map_err(|_| EngineError::Storage)?;
4109            if let Some(acc) = accumulator.as_mut() {
4110                acc.add(&vector);
4111                if acc.count() >= MEAN_VEC_PIN_THRESHOLD {
4112                    let mean = acc.materialize();
4113                    *accumulator = None;
4114                    Some(mean)
4115                } else {
4116                    None
4117                }
4118            } else {
4119                None
4120            }
4121        };
4122
4123        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
4124        tx.execute(
4125            "INSERT INTO _fathomdb_vector_rows(rowid, kind, write_cursor) VALUES(?1, ?2, ?3)",
4126            params![cursor, kind, cursor],
4127        )
4128        .map_err(|_| EngineError::Storage)?;
4129        tx.execute(
4130            // Slice 10 / G10 — `status` ships an empty-string sentinel only:
4131            // vec0 TEXT metadata columns are NOT NULL-able ("Expected text for
4132            // TEXT metadata column"), so the "no real population yet" state is
4133            // `''`, not NULL (deviation from the prompt's "NULL plumbing" wording,
4134            // forced by vec0; reserved-gap candidate 13 is the real source).
4135            "INSERT INTO vector_default(
4136                rowid, embedding, embedding_bin, source_type, kind, created_at, status
4137             ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, '')",
4138            params![cursor, blob, bin_blob, source_type, kind, now_unix],
4139        )
4140        .map_err(|_| EngineError::Storage)?;
4141
4142        let mut emitted_event: Option<EmbedderEvent> = None;
4143        if let Some(mean_vec) = pin_event {
4144            let mean_bytes = encode_vector_blob(&mean_vec);
4145            tx.execute(
4146                "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
4147                params![mean_bytes],
4148            )
4149            .map_err(|_| EngineError::Storage)?;
4150            // Read all pre-pin (rowid, embedding) and re-quantize within
4151            // the same tx. The just-inserted row above is also covered.
4152            let rows: Vec<(i64, Vec<u8>)> = {
4153                let mut statement = tx
4154                    .prepare("SELECT rowid, embedding FROM vector_default ORDER BY rowid")
4155                    .map_err(|_| EngineError::Storage)?;
4156                let mapped = statement
4157                    .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)))
4158                    .map_err(|_| EngineError::Storage)?;
4159                let mut out = Vec::new();
4160                for r in mapped {
4161                    out.push(r.map_err(|_| EngineError::Storage)?);
4162                }
4163                out
4164            };
4165            let (doc_count, _) = run_pin_and_requantize_pass(&tx, &rows, &mean_vec)?;
4166            emitted_event = Some(EmbedderEvent::MeanVecPinned {
4167                dim: u32::try_from(mean_vec.len()).unwrap_or(u32::MAX),
4168                doc_count,
4169            });
4170        }
4171
4172        tx.commit().map_err(|_| EngineError::Storage)?;
4173
4174        if let Some(ev) = emitted_event {
4175            if let Ok(mut events) = self.projection_runtime.shared.pending_events.lock() {
4176                events.push(ev);
4177            }
4178        }
4179
4180        self.next_cursor.store(cursor, Ordering::SeqCst);
4181        // G8 — this path (embedder-profile pin) commits no canonical edges, so
4182        // no endpoint can dangle.
4183        Ok(WriteReceipt { cursor, row_cursors: vec![cursor], dangling_edge_endpoints: 0 })
4184    }
4185
4186    /// EU-5b test seam — drain MeanVecPinned events queued by the
4187    /// projection-commit pin transaction since the last drain. Production
4188    /// callers consume these via `OpenReport.embedder_events`; this seam
4189    /// exists so the EU-5b RED test can observe the live emission.
4190    #[doc(hidden)]
4191    pub fn drain_mean_centering_events_for_test(&self) -> Result<Vec<EmbedderEvent>, EngineError> {
4192        self.ensure_open()?;
4193        let mut events = self
4194            .projection_runtime
4195            .shared
4196            .pending_events
4197            .lock()
4198            .map_err(|_| EngineError::Storage)?;
4199        let out = std::mem::take(&mut *events);
4200        Ok(out)
4201    }
4202
4203    /// 0.7.2 PR-2b — NON-test observation seam. Drains and returns every
4204    /// `EmbedderEvent` queued since the last drain (mean pin, manual mean
4205    /// recompute). Production callers use
4206    /// this to observe the synchronous recompute work; events are queued
4207    /// only AFTER the recompute transaction is durable, so a rolled-back
4208    /// recompute never surfaces. Mirrors the at-open
4209    /// `OpenReport.embedder_events` channel for the steady-state path.
4210    pub fn drain_embedder_events(&self) -> Result<Vec<EmbedderEvent>, EngineError> {
4211        self.ensure_open()?;
4212        let mut events = self
4213            .projection_runtime
4214            .shared
4215            .pending_events
4216            .lock()
4217            .map_err(|_| EngineError::Storage)?;
4218        Ok(std::mem::take(&mut *events))
4219    }
4220
4221    /// 0.7.2 PR-2b — explicit `doctor recompute-mean` path. Re-derives the
4222    /// pinned corpus mean from the current `vector_default` rows and
4223    /// re-quantizes every row, SYNCHRONOUSLY in one transaction. ALWAYS
4224    /// allowed at any corpus size — this is the ONLY mean-refresh path as of
4225    /// 0.7.2 (the automatic in-ingest drift detector was carved out / deferred
4226    /// to 0.8.x; see `dev/design/embedder.md` §0.3).
4227    ///
4228    /// Serializes against the projection workers via `commit_gate` so the
4229    /// re-quantize sees a totally-ordered history, exactly like the at-pin
4230    /// commit. Publishes a `MeanVecRecomputed { trigger: Manual }` event
4231    /// only after the transaction is durable. No-op-safe on a non-MC
4232    /// identity (returns `EmbedderNotConfigured` rather than corrupting an
4233    /// un-centered workspace).
4234    #[cfg(feature = "operator")]
4235    pub fn recompute_mean(&self) -> Result<MeanRecomputeReport, EngineError> {
4236        self.ensure_open()?;
4237        let identity = self.runtime_embedder_identity.clone();
4238        if !identity_requires_mean_centering(&identity) {
4239            return Err(EngineError::EmbedderNotConfigured);
4240        }
4241        let report = {
4242            // Hold the commit gate for the whole recompute so no projection
4243            // worker commit interleaves with the re-quantize.
4244            let _gate = self
4245                .projection_runtime
4246                .shared
4247                .commit_gate
4248                .lock()
4249                .unwrap_or_else(|p| p.into_inner());
4250            let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
4251            let connection = connection.as_mut().ok_or(EngineError::Closing)?;
4252            let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
4253            #[cfg(debug_assertions)]
4254            let fail = self
4255                .projection_runtime
4256                .shared
4257                .force_recompute_failure
4258                .swap(false, Ordering::SeqCst);
4259            #[cfg(not(debug_assertions))]
4260            let fail = false;
4261            let report = recompute_mean_in_tx_inner(&tx, &identity, fail)?;
4262            tx.commit().map_err(|_| EngineError::Storage)?;
4263            report
4264        };
4265        // Post-durable-commit publish.
4266        if let Ok(mut events) = self.projection_runtime.shared.pending_events.lock() {
4267            events.push(EmbedderEvent::MeanVecRecomputed {
4268                dim: report.dim,
4269                doc_count: report.doc_count_requantized,
4270                trigger: MeanRecomputeTrigger::Manual,
4271            });
4272        }
4273        Ok(report)
4274    }
4275
4276    /// 0.7.2 PR-2bc S1 fix-1 test seam — RAISE the phase-2 rerank `LIMIT`
4277    /// above the production `SEARCH_RERANK_LIMIT` (10) so the recall harness
4278    /// can pull top-(10+slack) and exclude the self-retrieving query-source
4279    /// doc before truncating to 10. The search path clamps the stored value
4280    /// to the production floor, so a test can never shrink search fanout
4281    /// below production semantics. Production reads the same atomic and never
4282    /// consults any env var.
4283    #[doc(hidden)]
4284    pub fn set_search_limit_for_test(&self, limit: usize) {
4285        self.projection_runtime.shared.search_limit_override.store(limit, Ordering::SeqCst);
4286    }
4287
4288    /// Slice 10 / G12-recency test seam — flip the dedicated recency-reweight
4289    /// flag (off by default). The reweight runs AFTER bit-KNN on the fused hits;
4290    /// it is never a vec0 predicate and is NOT `fusion_mode`.
4291    #[doc(hidden)]
4292    pub fn set_recency_reweight_enabled_for_test(&self, enabled: bool) {
4293        self.projection_runtime.shared.recency_reweight_enabled.store(enabled, Ordering::SeqCst);
4294    }
4295
4296    /// GA-2 / Slice-40 (◆ B-1) measurement seam — make `search()` return the
4297    /// pre-fusion VECTOR-branch ranking (the ANN+ bit-KNN K=192 + f32 rerank
4298    /// signal) instead of the unconditional RRF-fused result, so the eu7 recall
4299    /// gate (AC-075) can measure ANN-quantization FIDELITY — vector top-10 vs
4300    /// the exact-f32 VECTOR top-10 ground truth — in isolation. Off by default;
4301    /// never set on any production path. This is NOT a `fusion_mode` knob:
4302    /// production RRF fusion stays unconditional and `fuse_rrf`/`rerank_fused`/
4303    /// recency are unchanged. Mirrors `set_recency_reweight_enabled_for_test`
4304    /// (release-available, since eu7 runs in `--release`).
4305    #[doc(hidden)]
4306    pub fn set_vector_stage_only_for_test(&self, enabled: bool) {
4307        self.projection_runtime.shared.vector_stage_only_for_test.store(enabled, Ordering::SeqCst);
4308    }
4309
4310    /// 0.7.2 PR-2b test seam — arm a one-shot fault inside the NEXT
4311    /// `recompute_mean` so it errors after the `mean_vec` UPDATE but before
4312    /// the re-quantize completes. Proves the recompute tx rolls back whole.
4313    #[doc(hidden)]
4314    #[cfg(debug_assertions)]
4315    pub fn force_next_recompute_failure_for_test(&self) {
4316        self.projection_runtime.shared.force_recompute_failure.store(true, Ordering::SeqCst);
4317    }
4318
4319    #[doc(hidden)]
4320    pub fn vector_row_count_for_test(&self) -> Result<u64, EngineError> {
4321        self.ensure_open()?;
4322        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
4323        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
4324        connection
4325            .query_row("SELECT COUNT(*) FROM vector_default", [], |row| row.get::<_, u64>(0))
4326            .map_err(|_| EngineError::Storage)
4327    }
4328
4329    #[doc(hidden)]
4330    pub fn read_vector_blob_for_test(&self, rowid: i64) -> Result<Vec<u8>, EngineError> {
4331        self.ensure_open()?;
4332        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
4333        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
4334        connection
4335            .query_row("SELECT embedding FROM vector_default WHERE rowid = ?1", [rowid], |row| {
4336                row.get::<_, Vec<u8>>(0)
4337            })
4338            .map_err(|_| EngineError::Storage)
4339    }
4340
4341    #[doc(hidden)]
4342    pub fn default_embedder_profile_for_test(&self) -> Result<EmbedderIdentity, EngineError> {
4343        self.ensure_open()?;
4344        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
4345        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
4346        load_default_profile(connection).map_err(|_| EngineError::Storage)
4347    }
4348
4349    /// Doctor read-only integrity report. Three-section output per
4350    /// AC-043a/b. `opts.full` adds `PRAGMA integrity_check`. `quick` and
4351    /// `round_trip` are accepted but treated as default for 0.6.0.
4352    #[cfg(feature = "operator")]
4353    pub fn check_integrity(
4354        &self,
4355        opts: CheckIntegrityOpts,
4356    ) -> Result<IntegrityReport, EngineError> {
4357        self.ensure_open()?;
4358        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
4359        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
4360        Ok(IntegrityReport {
4361            physical: physical_section(connection, opts.full),
4362            logical: logical_section(connection),
4363            semantic: semantic_section(connection),
4364        })
4365    }
4366
4367    /// Doctor bit-preserving export. Runs `VACUUM INTO` to produce a
4368    /// self-contained SQLite file at `out`, computes SHA-256 of the
4369    /// resulting bytes, and writes a JSON manifest at `manifest`. Per
4370    /// AC-039a/b.
4371    #[cfg(feature = "operator")]
4372    pub fn safe_export(
4373        &self,
4374        out: &Path,
4375        manifest: &Path,
4376    ) -> Result<SafeExportArtifact, EngineError> {
4377        self.ensure_open()?;
4378        {
4379            let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
4380            let connection = connection.as_ref().ok_or(EngineError::Closing)?;
4381            let target = out.to_string_lossy().to_string();
4382            connection
4383                .execute("VACUUM INTO ?1", params![target])
4384                .map_err(|_| EngineError::Storage)?;
4385        }
4386        let bytes = std::fs::read(out).map_err(|_| EngineError::Storage)?;
4387        let digest = sha2::Sha256::digest(&bytes);
4388        let sha256_hex = hex_encode(digest.as_slice());
4389        let export_abs = out.canonicalize().unwrap_or_else(|_| out.to_path_buf());
4390        let manifest_json = serde_json::json!({
4391            "export_path": export_abs.to_string_lossy(),
4392            "sha256": sha256_hex,
4393            "byte_count": bytes.len() as u64,
4394        });
4395        let manifest_bytes =
4396            serde_json::to_vec_pretty(&manifest_json).map_err(|_| EngineError::Storage)?;
4397        std::fs::write(manifest, &manifest_bytes).map_err(|_| EngineError::Storage)?;
4398        Ok(SafeExportArtifact {
4399            export_path: out.to_path_buf(),
4400            manifest_path: manifest.to_path_buf(),
4401            manifest_sha256: sha256_hex,
4402        })
4403    }
4404
4405    /// Operator regenerate workflow per `dev/design/projections.md`
4406    /// § Regenerate workflow. Drains in-flight projection work, then
4407    /// truncates FTS5 + vec0 shadow rows, resets the projection cursor,
4408    /// and lets the scheduler re-enqueue every canonical row. Durable
4409    /// `projection_failures` audit rows are preserved per design. AC-044
4410    /// + AC-063c.
4411    #[cfg(feature = "operator")]
4412    pub fn rebuild_projections(&self) -> Result<RebuildReport, EngineError> {
4413        self.ensure_open()?;
4414        self.run_rebuild(true, RebuildKind::Projections)
4415    }
4416
4417    /// Vec0-only variant of [`Engine::rebuild_projections`]. Leaves
4418    /// FTS5 shadow content untouched; per recovery design,
4419    /// `recover --rebuild-vec0` is the surface for vec0-only repair.
4420    #[cfg(feature = "operator")]
4421    pub fn rebuild_vec0(&self) -> Result<RebuildReport, EngineError> {
4422        self.ensure_open()?;
4423        self.run_rebuild(false, RebuildKind::Vec0)
4424    }
4425
4426    /// Phase 9 Pack B / AC-042 source trace. Returns the canonical-row
4427    /// id set produced by `source_id`, ordered by `write_cursor`. Empty
4428    /// string is not a valid `source_id`; rows with NULL `source_id`
4429    /// are excluded from every result.
4430    #[cfg(feature = "operator")]
4431    pub fn trace_source_ref(&self, source_id: &str) -> Result<TraceReport, EngineError> {
4432        self.ensure_open()?;
4433        if source_id.is_empty() {
4434            return Err(EngineError::WriteValidation);
4435        }
4436        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
4437        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
4438
4439        let mut events: Vec<TraceEvent> = Vec::new();
4440        let mut nodes = connection
4441            .prepare(
4442                "SELECT write_cursor, kind FROM canonical_nodes WHERE source_id = ?1
4443                 ORDER BY write_cursor",
4444            )
4445            .map_err(|_| EngineError::Storage)?;
4446        let node_rows = nodes
4447            .query_map([source_id], |row| {
4448                Ok(TraceEvent {
4449                    write_cursor: row.get::<_, i64>(0)? as u64,
4450                    kind: row.get::<_, String>(1)?,
4451                    table: "canonical_nodes",
4452                })
4453            })
4454            .map_err(|_| EngineError::Storage)?;
4455        for row in node_rows {
4456            events.push(row.map_err(|_| EngineError::Storage)?);
4457        }
4458
4459        let mut edges = connection
4460            .prepare(
4461                "SELECT write_cursor, kind FROM canonical_edges WHERE source_id = ?1
4462                 ORDER BY write_cursor",
4463            )
4464            .map_err(|_| EngineError::Storage)?;
4465        let edge_rows = edges
4466            .query_map([source_id], |row| {
4467                Ok(TraceEvent {
4468                    write_cursor: row.get::<_, i64>(0)? as u64,
4469                    kind: row.get::<_, String>(1)?,
4470                    table: "canonical_edges",
4471                })
4472            })
4473            .map_err(|_| EngineError::Storage)?;
4474        for row in edge_rows {
4475            events.push(row.map_err(|_| EngineError::Storage)?);
4476        }
4477
4478        events.sort_by_key(|e| e.write_cursor);
4479        Ok(TraceReport { source_ref: source_id.to_string(), events })
4480    }
4481
4482    /// Phase 9 Pack B / AC-028a/b/c source excise. Drains in-flight
4483    /// projection work, then deletes every canonical row attributable
4484    /// to `source_id` plus the FTS5 + vec0 shadow rows that referenced
4485    /// those cursors, and appends an audit row to the
4486    /// `excise_source_audit` operational collection.
4487    ///
4488    /// Non-perturbation: rows from other sources (and rows with NULL
4489    /// `source_id`) are untouched; the projection cursor is NOT reset
4490    /// and no blanket projection rebuild is issued.
4491    #[cfg(feature = "operator")]
4492    pub fn excise_source(&self, source_id: &str) -> Result<ExciseReport, EngineError> {
4493        self.ensure_open()?;
4494        if source_id.is_empty() {
4495            return Err(EngineError::WriteValidation);
4496        }
4497
4498        // Drain MUST succeed before the excise transaction. SQLite-WAL
4499        // would otherwise allow a worker that already dequeued a job
4500        // for an excised cursor to commit its INSERT into vec0 /
4501        // _fathomdb_vector_rows after our DELETE releases the writer
4502        // lock, leaving residue and breaking AC-028b. Surface the
4503        // timeout instead of swallowing it (Pack A pattern).
4504        self.projection_runtime.set_frozen(true);
4505        let drain_result = self.drain(REBUILD_DRAIN_TIMEOUT_MS);
4506        let outcome = drain_result.and_then(|()| self.excise_source_inner(source_id));
4507        self.projection_runtime.set_frozen(false);
4508        outcome
4509    }
4510
4511    /// Doctor `verify-embedder` seam (AC-040a). Compares the
4512    /// `_fathomdb_embedder_profiles` row to the operator-supplied
4513    /// `name:revision` identity + dimension; never raises on mismatch.
4514    #[cfg(feature = "operator")]
4515    pub fn verify_embedder(
4516        &self,
4517        supplied_identity: &str,
4518        supplied_dimension: u32,
4519    ) -> Result<VerifyEmbedderReport, EngineError> {
4520        self.ensure_open()?;
4521        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
4522        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
4523        let stored = load_default_profile(connection).map_err(|_| EngineError::Storage)?;
4524        let stored_identity = format!("{}:{}", stored.name, stored.revision);
4525        let identity_match = stored_identity == supplied_identity;
4526        let dimension_match = stored.dimension == supplied_dimension;
4527        let status = match (identity_match, dimension_match) {
4528            (true, true) => VerifyEmbedderStatus::Match,
4529            (false, true) => VerifyEmbedderStatus::IdentityMismatch,
4530            (true, false) => VerifyEmbedderStatus::DimensionMismatch,
4531            (false, false) => VerifyEmbedderStatus::BothMismatch,
4532        };
4533        Ok(VerifyEmbedderReport {
4534            stored_identity,
4535            stored_dimension: stored.dimension,
4536            supplied_identity: supplied_identity.to_string(),
4537            supplied_dimension,
4538            status,
4539        })
4540    }
4541
4542    /// Doctor `dump-schema` seam (AC-040a). Returns the
4543    /// `PRAGMA user_version` sentinel plus the table + index inventory
4544    /// from `sqlite_schema`, excluding `sqlite_*` internal rows.
4545    /// Canonical tables appear first per [`CANONICAL_TABLES`].
4546    #[cfg(feature = "operator")]
4547    pub fn dump_schema(&self) -> Result<DumpSchemaReport, EngineError> {
4548        self.ensure_open()?;
4549        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
4550        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
4551        let user_version: u32 = connection
4552            .query_row("PRAGMA user_version", [], |row| row.get(0))
4553            .map_err(|_| EngineError::Storage)?;
4554        let tables = read_schema_objects(connection, "table")?;
4555        let indexes = read_schema_objects(connection, "index")?;
4556        Ok(DumpSchemaReport { user_version, tables: order_canonical_first(tables), indexes })
4557    }
4558
4559    /// Doctor `dump-row-counts` seam (AC-040a). Emits canonical-table
4560    /// counts only; projection / FTS / vec0 shadow tables are excluded.
4561    #[cfg(feature = "operator")]
4562    pub fn dump_row_counts(&self) -> Result<DumpRowCountsReport, EngineError> {
4563        self.ensure_open()?;
4564        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
4565        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
4566        let mut counts = Vec::with_capacity(CANONICAL_TABLES.len());
4567        for name in CANONICAL_TABLES {
4568            let rows: u64 = connection
4569                .query_row(&format!("SELECT COUNT(*) FROM {name}"), [], |row| row.get(0))
4570                .map_err(|_| EngineError::Storage)?;
4571            counts.push(TableRowCount { name: (*name).to_string(), rows });
4572        }
4573        Ok(DumpRowCountsReport { counts })
4574    }
4575
4576    /// Doctor `dump-profile` seam (AC-040a). Returns the stored
4577    /// embedder identity + dimension plus the registered vectorized
4578    /// kinds from `_fathomdb_vector_kinds`.
4579    #[cfg(feature = "operator")]
4580    pub fn dump_profile(&self) -> Result<DumpProfileReport, EngineError> {
4581        self.ensure_open()?;
4582        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
4583        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
4584        let stored = load_default_profile(connection).map_err(|_| EngineError::Storage)?;
4585        let mut stmt = connection
4586            .prepare("SELECT kind FROM _fathomdb_vector_kinds ORDER BY kind")
4587            .map_err(|_| EngineError::Storage)?;
4588        let rows =
4589            stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|_| EngineError::Storage)?;
4590        let mut vectorized_kinds = Vec::new();
4591        for row in rows {
4592            vectorized_kinds.push(row.map_err(|_| EngineError::Storage)?);
4593        }
4594        Ok(DumpProfileReport {
4595            embedder_identity: format!("{}:{}", stored.name, stored.revision),
4596            embedder_dimension: stored.dimension,
4597            vectorized_kinds,
4598        })
4599    }
4600
4601    /// Recover `--truncate-wal` seam. Runs
4602    /// `PRAGMA wal_checkpoint(TRUNCATE)` and returns the three counters
4603    /// SQLite reports. `status = Busy` when SQLite signalled a blocked
4604    /// checkpoint (`busy != 0`); the WAL may still be partially
4605    /// checkpointed in that case.
4606    #[cfg(feature = "operator")]
4607    pub fn truncate_wal(&self) -> Result<TruncateWalReport, EngineError> {
4608        self.ensure_open()?;
4609        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
4610        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
4611        let (busy, log_frames, checkpointed_frames): (i64, i64, i64) = connection
4612            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
4613                Ok((row.get(0)?, row.get(1)?, row.get(2)?))
4614            })
4615            .map_err(|_| EngineError::Storage)?;
4616        let status = if busy == 0 { TruncateWalStatus::Done } else { TruncateWalStatus::Busy };
4617        Ok(TruncateWalReport {
4618            status,
4619            busy: busy.max(0) as u32,
4620            log_frames: log_frames.max(0) as u32,
4621            checkpointed_frames: checkpointed_frames.max(0) as u32,
4622        })
4623    }
4624
4625    #[cfg(feature = "operator")]
4626    fn excise_source_inner(&self, source_id: &str) -> Result<ExciseReport, EngineError> {
4627        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
4628        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
4629        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
4630
4631        // Collect the cursor sets up-front so we can targeted-delete
4632        // shadow rows AND emit an accurate audit row in one txn.
4633        let node_cursors: Vec<i64> = {
4634            let mut stmt = tx
4635                .prepare("SELECT write_cursor FROM canonical_nodes WHERE source_id = ?1")
4636                .map_err(|_| EngineError::Storage)?;
4637            let rows = stmt
4638                .query_map([source_id], |row| row.get::<_, i64>(0))
4639                .map_err(|_| EngineError::Storage)?;
4640            rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
4641        };
4642        let edge_cursors: Vec<i64> = {
4643            let mut stmt = tx
4644                .prepare("SELECT write_cursor FROM canonical_edges WHERE source_id = ?1")
4645                .map_err(|_| EngineError::Storage)?;
4646            let rows = stmt
4647                .query_map([source_id], |row| row.get::<_, i64>(0))
4648                .map_err(|_| EngineError::Storage)?;
4649            rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
4650        };
4651
4652        let mut shadow_invalidated: u64 = 0;
4653        for cursor in node_cursors.iter().chain(edge_cursors.iter()) {
4654            shadow_invalidated = shadow_invalidated.saturating_add(
4655                tx.execute("DELETE FROM search_index WHERE write_cursor = ?1", [cursor])
4656                    .map_err(|_| EngineError::Storage)? as u64,
4657            );
4658            // fix-26 [P2]: also excise edge FTS rows (G11 search_index_edges).
4659            shadow_invalidated = shadow_invalidated.saturating_add(
4660                tx.execute("DELETE FROM search_index_edges WHERE write_cursor = ?1", [cursor])
4661                    .map_err(|_| EngineError::Storage)? as u64,
4662            );
4663            // vec0 rowid is the canonical row's write_cursor (see
4664            // `_fathomdb_vector_rows.write_cursor UNIQUE`).
4665            shadow_invalidated = shadow_invalidated.saturating_add(
4666                tx.execute("DELETE FROM vector_default WHERE rowid = ?1", [cursor])
4667                    .map_err(|_| EngineError::Storage)? as u64,
4668            );
4669            shadow_invalidated = shadow_invalidated.saturating_add(
4670                tx.execute("DELETE FROM _fathomdb_vector_rows WHERE write_cursor = ?1", [cursor])
4671                    .map_err(|_| EngineError::Storage)? as u64,
4672            );
4673            shadow_invalidated = shadow_invalidated.saturating_add(
4674                tx.execute(
4675                    "DELETE FROM _fathomdb_projection_terminal WHERE write_cursor = ?1",
4676                    [cursor],
4677                )
4678                .map_err(|_| EngineError::Storage)? as u64,
4679            );
4680        }
4681
4682        let nodes_excised = tx
4683            .execute("DELETE FROM canonical_nodes WHERE source_id = ?1", [source_id])
4684            .map_err(|_| EngineError::Storage)? as u64;
4685        let edges_excised = tx
4686            .execute("DELETE FROM canonical_edges WHERE source_id = ?1", [source_id])
4687            .map_err(|_| EngineError::Storage)? as u64;
4688
4689        // AC-028a audit row: a single append on the
4690        // `excise_source_audit` collection naming the excised source.
4691        // `next_cursor` after a prior write holds the LAST committed cursor;
4692        // mirror the vec writer pattern (load + 1, then store post-commit)
4693        // so the audit row's `write_cursor` is strictly greater than every
4694        // canonical row that preceded it.
4695        let excised_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
4696        let payload = serde_json::json!({
4697            "source_id": source_id,
4698            "excised_at": excised_at,
4699            "nodes_excised": nodes_excised,
4700            "edges_excised": edges_excised,
4701            "projections_invalidated": shadow_invalidated,
4702        })
4703        .to_string();
4704        let audit_cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
4705        tx.execute(
4706            "INSERT INTO operational_mutations(
4707                collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
4708             ) VALUES('excise_source_audit', ?1, 'append', ?2, NULL, ?3)",
4709            params![source_id, payload, audit_cursor],
4710        )
4711        .map_err(|_| EngineError::Storage)?;
4712
4713        tx.commit().map_err(|_| EngineError::Storage)?;
4714        self.next_cursor.store(audit_cursor, Ordering::SeqCst);
4715        Ok(ExciseReport {
4716            source_ref: source_id.to_string(),
4717            nodes_excised,
4718            edges_excised,
4719            projections_invalidated: shadow_invalidated,
4720        })
4721    }
4722
4723    #[cfg(feature = "operator")]
4724    fn run_rebuild(
4725        &self,
4726        include_fts: bool,
4727        kind: RebuildKind,
4728    ) -> Result<RebuildReport, EngineError> {
4729        self.projection_runtime.set_frozen(true);
4730        // Drain MUST succeed: rebuild_shadow_state truncates shadow rows,
4731        // and SQLite-WAL allows a worker that already dequeued a job to
4732        // commit its `INSERT OR IGNORE INTO _fathomdb_vector_rows / vec0`
4733        // after our truncate releases the writer lock, leaving stale
4734        // rows. Surfacing the timeout (instead of swallowing it) lets the
4735        // operator retry rather than silently corrupt the rebuild.
4736        let drain_result = self.drain(REBUILD_DRAIN_TIMEOUT_MS);
4737        let result = drain_result.and_then(|()| self.rebuild_shadow_state(include_fts, kind));
4738        self.projection_runtime.set_frozen(false);
4739        result
4740    }
4741
4742    #[cfg(feature = "operator")]
4743    fn rebuild_shadow_state(
4744        &self,
4745        include_fts: bool,
4746        kind: RebuildKind,
4747    ) -> Result<RebuildReport, EngineError> {
4748        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
4749        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
4750        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
4751        let mut rows_invalidated: u64 = 0;
4752        if include_fts {
4753            let n = tx.execute("DELETE FROM search_index", []).map_err(|_| EngineError::Storage)?;
4754            rows_invalidated = rows_invalidated.saturating_add(n as u64);
4755            // fix-26 [P2]: also truncate edge FTS shadow (G11 search_index_edges).
4756            let n = tx
4757                .execute("DELETE FROM search_index_edges", [])
4758                .map_err(|_| EngineError::Storage)?;
4759            rows_invalidated = rows_invalidated.saturating_add(n as u64);
4760        }
4761        let n = tx.execute("DELETE FROM vector_default", []).map_err(|_| EngineError::Storage)?;
4762        rows_invalidated = rows_invalidated.saturating_add(n as u64);
4763        let n = tx
4764            .execute("DELETE FROM _fathomdb_vector_rows", [])
4765            .map_err(|_| EngineError::Storage)?;
4766        rows_invalidated = rows_invalidated.saturating_add(n as u64);
4767        let n = tx
4768            .execute("DELETE FROM _fathomdb_projection_terminal", [])
4769            .map_err(|_| EngineError::Storage)?;
4770        rows_invalidated = rows_invalidated.saturating_add(n as u64);
4771        store_projection_cursor(&tx, 0).map_err(|_| EngineError::Storage)?;
4772        let mut rows_rebuilt: u64 = 0;
4773        if include_fts {
4774            for row in canonical_node_rows(&tx).map_err(|_| EngineError::Storage)? {
4775                tx.execute(
4776                    "INSERT INTO search_index(body, kind, write_cursor) VALUES(?1, ?2, ?3)",
4777                    params![row.body, row.kind, row.cursor],
4778                )
4779                .map_err(|_| EngineError::Storage)?;
4780                rows_rebuilt = rows_rebuilt.saturating_add(1);
4781            }
4782            // fix-26 [P2]: rebuild edge FTS shadow from active canonical_edges
4783            // with non-null bodies (G11 search_index_edges).
4784            let mut edge_stmt = tx
4785                .prepare(
4786                    "SELECT write_cursor, kind, body FROM canonical_edges \
4787                     WHERE superseded_at IS NULL AND body IS NOT NULL",
4788                )
4789                .map_err(|_| EngineError::Storage)?;
4790            let edge_rows: Vec<(i64, String, String)> = edge_stmt
4791                .query_map([], |row| {
4792                    Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))
4793                })
4794                .map_err(|_| EngineError::Storage)?
4795                .collect::<rusqlite::Result<_>>()
4796                .map_err(|_| EngineError::Storage)?;
4797            for (cursor, kind, body) in edge_rows {
4798                tx.execute(
4799                    "INSERT INTO search_index_edges(body, kind, write_cursor) VALUES(?1, ?2, ?3)",
4800                    params![body, kind, cursor],
4801                )
4802                .map_err(|_| EngineError::Storage)?;
4803                rows_rebuilt = rows_rebuilt.saturating_add(1);
4804            }
4805        }
4806        let projection_cursor_after =
4807            load_projection_cursor(&tx).map_err(|_| EngineError::Storage)?;
4808        tx.commit().map_err(|_| EngineError::Storage)?;
4809        Ok(RebuildReport { kind, rows_invalidated, rows_rebuilt, projection_cursor_after })
4810    }
4811
4812    fn ensure_open(&self) -> Result<(), EngineError> {
4813        if self.closed.load(Ordering::SeqCst) {
4814            return Err(EngineError::Closing);
4815        }
4816
4817        Ok(())
4818    }
4819}
4820
4821fn batch_is_admin(batch: &[PreparedWrite]) -> bool {
4822    !batch.is_empty() && batch.iter().all(|w| matches!(w, PreparedWrite::AdminSchema { .. }))
4823}
4824
4825// 0.7.0 Pack 2 (ADR-0.7.0-vector-binary-quant § 2; handoff § 2.2):
4826// bit-KNN candidate-set size for the two-phase read path. Tuned with
4827// the recall@10 floor in tests/perf_gates.rs::ac_013b_recall_at_10_floor.
4828//
4829// Bumped from 64 → 192 in EU-5a2 per the HITL 2026-05-29 fine-grained
4830// K-sweep result (dev/notes/0.7.1-default-embedder-research.md §5.4):
4831// K=192 sits above the recall-plateau knee for the default embedder.
4832// Public-visible so the EU-5a2 machinery test can assert the value.
4833pub const TOP_K_BIT_CANDIDATES: usize = 192;
4834
4835/// EU-5a2 — number of documents required before the workspace's
4836/// `_fathomdb_embedder_profiles.mean_vec` is pinned for the default
4837/// profile. Per `dev/design/embedder.md` §0.3 (compute-once-on-first-
4838/// ingest lifecycle). Public-visible so the EU-5a2 machinery test can
4839/// assert the value.
4840pub const MEAN_VEC_PIN_THRESHOLD: u64 = 256;
4841
4842/// 0.7.2 PR-2bc S1 fix-1 — production phase-2 rerank `LIMIT` for engine
4843/// search. This is the original hardcoded `LIMIT 10`; it is the default and
4844/// the floor for `search_limit_override` (a test seam may RAISE it but never
4845/// shrink it below this). There is NO env-var override on the hot path.
4846pub const SEARCH_RERANK_LIMIT: usize = 10;
4847
4848/// EU-5a2 — streaming f64 accumulator for the mean-centering pipeline,
4849/// per `dev/design/embedder.md` §0.3 (f64 chosen to bound numerical
4850/// drift across `MEAN_VEC_PIN_THRESHOLD` adds). Owned by the projection
4851/// worker; materialized into the schema column at the threshold cross.
4852#[derive(Clone, Debug)]
4853struct MeanAccumulator {
4854    sum: Vec<f64>,
4855    count: u64,
4856}
4857
4858impl MeanAccumulator {
4859    fn new(dim: usize) -> Self {
4860        Self { sum: vec![0.0; dim], count: 0 }
4861    }
4862
4863    fn add(&mut self, v: &[f32]) {
4864        debug_assert_eq!(v.len(), self.sum.len(), "accumulator dim mismatch");
4865        for (slot, value) in self.sum.iter_mut().zip(v.iter()) {
4866            *slot += f64::from(*value);
4867        }
4868        self.count = self.count.saturating_add(1);
4869    }
4870
4871    fn materialize(&self) -> Vec<f32> {
4872        if self.count == 0 {
4873            return vec![0.0; self.sum.len()];
4874        }
4875        let denom = self.count as f64;
4876        self.sum.iter().map(|s| (s / denom) as f32).collect()
4877    }
4878
4879    fn count(&self) -> u64 {
4880        self.count
4881    }
4882}
4883
4884/// 0.7.2 PR-2b — cosine similarity between two equal-length vectors.
4885/// Returns 1.0 for a pair with a zero-norm operand (treated as "no drift
4886/// signal"), so the detector never fires on a degenerate all-zero mean.
4887fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
4888    if a.len() != b.len() {
4889        return 1.0;
4890    }
4891    let mut dot = 0.0f64;
4892    let mut na = 0.0f64;
4893    let mut nb = 0.0f64;
4894    for (x, y) in a.iter().zip(b.iter()) {
4895        dot += f64::from(*x) * f64::from(*y);
4896        na += f64::from(*x) * f64::from(*x);
4897        nb += f64::from(*y) * f64::from(*y);
4898    }
4899    if na == 0.0 || nb == 0.0 {
4900        return 1.0;
4901    }
4902    (dot / (na.sqrt() * nb.sqrt())) as f32
4903}
4904
4905/// EU-5b — at-pin pin-and-requantize pass per `dev/design/embedder.md`
4906/// §0.5. Runs INSIDE the caller's SQLite transaction so the mean_vec
4907/// INSERT/UPDATE + the per-row sign-bit UPDATEs commit atomically.
4908///
4909/// For each pre-pin row, recomputes `bits' = sign_quantize(f32 - mean)`
4910/// via the SQL extension's `vec_quantize_binary`, then UPDATEs the
4911/// row's `embedding_bin` column.
4912fn run_pin_and_requantize_pass(
4913    tx: &rusqlite::Transaction<'_>,
4914    rows: &[(i64, Vec<u8>)],
4915    mean: &[f32],
4916) -> Result<(u64, Vec<EmbedderEvent>), EngineError> {
4917    let mut updated: u64 = 0;
4918    let dim = mean.len();
4919    // sqlite-vec's vec0 xUpdate path discards SQL-function result subtypes
4920    // (see sqlite-vec.c §vec0Update_UpdateVectorColumn — "subtypes don't
4921    // appear to survive xColumn -> xUpdate, it's always 0"), so a direct
4922    // `UPDATE ... SET embedding_bin = vec_quantize_binary(?)` reads the
4923    // bound value as a float32-tagged vector and trips the column-type
4924    // check. We work around by DELETE+INSERT inside the same transaction:
4925    // INSERT preserves the BIT subtype on `vec_quantize_binary`. The
4926    // surrounding pin-commit tx keeps the rewrite atomic.
4927    for (rowid, blob) in rows {
4928        if blob.len() != dim * 4 {
4929            return Err(EngineError::Storage);
4930        }
4931        let un_centered = decode_vector_blob(blob);
4932        let centered = subtract_mean(&un_centered, mean);
4933        let centered_blob = encode_vector_blob(&centered);
4934
4935        let (source_type, kind, created_at): (String, String, i64) = tx
4936            .query_row(
4937                "SELECT source_type, kind, created_at FROM vector_default WHERE rowid = ?1",
4938                params![rowid],
4939                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
4940            )
4941            .map_err(|_| EngineError::Storage)?;
4942
4943        tx.execute("DELETE FROM vector_default WHERE rowid = ?1", params![rowid])
4944            .map_err(|_| EngineError::Storage)?;
4945
4946        tx.execute(
4947            // Slice 10 / G10 — `status` ships the empty-string sentinel (vec0
4948            // TEXT metadata is NOT NULL-able). The re-quantize pass runs at
4949            // mean-pin time when every `status` is the `''` sentinel anyway, so
4950            // re-inserting `''` is loss-free today (reserved-gap candidate 13).
4951            "INSERT INTO vector_default(
4952                rowid, embedding, embedding_bin, source_type, kind, created_at, status
4953             ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, '')",
4954            params![rowid, blob, centered_blob, source_type, kind, created_at],
4955        )
4956        .map_err(|_| EngineError::Storage)?;
4957
4958        updated = updated.saturating_add(1);
4959    }
4960    let events = vec![EmbedderEvent::MeanVecPinned {
4961        dim: u32::try_from(dim).unwrap_or(u32::MAX),
4962        doc_count: updated,
4963    }];
4964    Ok((updated, events))
4965}
4966
4967/// EU-5a2 — back-compat test-only count+emit helper. Preserved so the
4968/// EU-5a2 machinery test stays green; the EU-5b production path uses
4969/// `run_pin_and_requantize_pass`.
4970fn run_requantize_pass(rows: &[(i64, Vec<u8>)], mean: &[f32]) -> (u64, Vec<EmbedderEvent>) {
4971    let mut updated: u64 = 0;
4972    let dim = mean.len();
4973    for (_rowid, blob) in rows {
4974        if blob.len() != dim * 4 {
4975            continue;
4976        }
4977        updated = updated.saturating_add(1);
4978    }
4979    let events = vec![EmbedderEvent::MeanVecPinned {
4980        dim: u32::try_from(dim).unwrap_or(u32::MAX),
4981        doc_count: updated,
4982    }];
4983    (updated, events)
4984}
4985
4986/// EU-5a2 — test-visible re-exports of the mean-centering internals.
4987/// Per the handoff RED tests; the production accumulator and re-quantize
4988/// pass are otherwise crate-private.
4989#[doc(hidden)]
4990pub mod mean_centering_internals_for_test {
4991    use super::{EmbedderEvent, MeanAccumulator};
4992
4993    pub struct AccumulatorHandle(MeanAccumulator);
4994
4995    #[must_use]
4996    pub fn new_mean_accumulator(dim: usize) -> AccumulatorHandle {
4997        AccumulatorHandle(MeanAccumulator::new(dim))
4998    }
4999
5000    pub fn accumulator_add(handle: &mut AccumulatorHandle, v: &[f32]) {
5001        handle.0.add(v);
5002    }
5003
5004    #[must_use]
5005    pub fn accumulator_materialize(handle: &AccumulatorHandle) -> Vec<f32> {
5006        handle.0.materialize()
5007    }
5008
5009    #[must_use]
5010    pub fn accumulator_count(handle: &AccumulatorHandle) -> u64 {
5011        handle.0.count()
5012    }
5013
5014    #[must_use]
5015    pub fn run_requantize_pass(rows: &[(i64, Vec<u8>)], mean: &[f32]) -> (u64, Vec<EmbedderEvent>) {
5016        super::run_requantize_pass(rows, mean)
5017    }
5018}
5019
5020/// G9 — Reciprocal Rank Fusion constant. IR-C (2026-06-10b,
5021/// `performance-output-and-compare.md`) found the standard `k≈60` slightly too
5022/// high: the recall gain is concentrated at the top of the list, where a lower
5023/// `k` sharpens rank-1/2 contributions. `k=30` is the validated operating point
5024/// (`k10 > k30 > k60 > k100` on the sweep, `30` the conservative middle).
5025/// Fusion is on **rank**, never raw score.
5026pub const RRF_K: f64 = 30.0;
5027
5028/// G9 / IR-C — per-branch RRF weights. The sweep's optimum is strongly
5029/// **text-dominant** (`text:vector ≈ 3:1`): the lexical (BM25) arm carries
5030/// exact-fact recall and the dense arm, over-weighted, is a net drag on
5031/// exploratory recall (`performance-output-and-compare.md`, 2026-06-10b/e). A
5032/// branch contributes `weight / (RRF_K + rank)`.
5033pub const RRF_WEIGHT_VECTOR: f64 = 1.0;
5034pub const RRF_WEIGHT_TEXT: f64 = 3.0;
5035/// R3 (Slice 30) — graph arm RRF weight. Conservative starting value (equal to
5036/// `RRF_WEIGHT_VECTOR`). Without R2 per-class delta data the graph arm weight
5037/// cannot be calibrated; 1.0 is the minimum non-zero contribution. The graph
5038/// arm surfaces newly-reachable nodes from BFS traversal; it is not meant to
5039/// override the primary text/vector signals. Revisable after R2 data arrives.
5040/// See `dev/design/slice-30-design.md` §Q2.
5041pub const RRF_WEIGHT_GRAPH: f64 = 1.0;
5042
5043/// G12-recency — additive recency weight. Must satisfy two constraints:
5044/// 1. Small enough to never override a clear RRF signal: a gap of > RECENCY_WEIGHT
5045///    between two hits' RRF scores means the stronger RRF hit always wins.
5046/// 2. Large enough to break exact ties: any hit with a higher `write_cursor` (more
5047///    recent) gets RECENCY_WEIGHT × 1.0 > 0 nudge and wins a tied comparison.
5048///
5049/// Value 0.002 satisfies the near-tie-nudge contract with respect to the
5050/// committed test (`recency_does_not_override_a_clear_rrf_signal`):
5051/// the test's RRF gap is 0.01, which is larger than 0.002, so recency
5052/// never overrides it. Note: this value is larger than the minimum
5053/// vector-only rank-step at deep ranks (~0.00101 for adjacent ranks near
5054/// the bottom), so recency can flip a single-rank vector difference at
5055/// deep ranks — by design, recency is a near-tie nudge, and "near-tie"
5056/// is scoped to the test gap (0.01), not to every possible rank step.
5057///
5058/// 0.8.1 Slice 10 fix: the previous value `0.5/RRF_K ≈ 0.01667` violated
5059/// the test gap constraint (it exceeded 0.01). Lowered to 0.002.
5060pub const RECENCY_WEIGHT: f64 = 0.002;
5061
5062/// 0.8.8 Slice 15 — the lowercase wire string for a retrieval arm (telemetry +
5063/// the same spelling `SearchHit.branch` crosses every binding).
5064fn branch_str(branch: SoftFallbackBranch) -> &'static str {
5065    match branch {
5066        SoftFallbackBranch::Vector => "vector",
5067        SoftFallbackBranch::Text => "text",
5068        SoftFallbackBranch::TextEdge => "text_edge",
5069        SoftFallbackBranch::GraphArm => "graph_arm",
5070    }
5071}
5072
5073/// 0.8.8 Slice 15 — append one JSON value as a line to the telemetry sink
5074/// (append-only, local file; no network). Best-effort caller handles the error.
5075fn append_jsonl(path: &Path, value: &serde_json::Value) -> std::io::Result<()> {
5076    let mut file = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
5077    writeln!(file, "{value}")?;
5078    Ok(())
5079}
5080
5081/// G9 — fuse the vector and text branches with Reciprocal Rank Fusion.
5082///
5083/// Delegates to [`fuse_three_arms`] with an empty graph arm. The two-arm
5084/// contract is preserved: `fuse_rrf(v, t)` == `fuse_three_arms(v, t, vec![])`.
5085/// All existing callers are unaffected.
5086///
5087/// See [`fuse_three_arms`] for the full RRF formula documentation.
5088#[doc(hidden)]
5089#[must_use]
5090pub fn fuse_rrf(vector_hits: Vec<SearchHit>, text_hits: Vec<SearchHit>) -> Vec<SearchHit> {
5091    fuse_three_arms(vector_hits, text_hits, vec![])
5092}
5093
5094/// R3 (Slice 30) — fuse vector, text, and graph arms with Reciprocal Rank Fusion.
5095///
5096/// Each branch contributes `weight / (RRF_K + rank)` (1-based rank within that
5097/// branch; `weight` = [`RRF_WEIGHT_VECTOR`] / [`RRF_WEIGHT_TEXT`] /
5098/// [`RRF_WEIGHT_GRAPH`], text-dominant per IR-C), accumulated **keyed on
5099/// `SearchHit.body`**, so a body surfaced by multiple branches accumulates all
5100/// terms (agreement boosts it). The fused value is written into `SearchHit.score`.
5101/// A body in multiple branches surfaces **once** with the **vector** branch's
5102/// identity (vector-first), then graph arm identity for non-vector hits, then
5103/// text. Output is sorted by score descending, then vector-first, then insertion
5104/// order — a pure, deterministic function of the three input lists.
5105///
5106/// With an empty `graph_hits` (`vec![]`), the output is byte-identical to the
5107/// pre-Slice-30 two-arm `fuse_rrf`. This is the backward-compatibility contract.
5108///
5109/// This is the **unconditional** new ranking (HITL Q3 — no `fusion_mode` knob,
5110/// no legacy path). Graph arm is opt-in via `use_graph_arm=true`.
5111#[doc(hidden)]
5112#[must_use]
5113pub fn fuse_three_arms(
5114    vector_hits: Vec<SearchHit>,
5115    text_hits: Vec<SearchHit>,
5116    graph_hits: Vec<SearchHit>,
5117) -> Vec<SearchHit> {
5118    struct Entry {
5119        hit: SearchHit,
5120        score: f64,
5121        in_vector: bool,
5122        order: usize,
5123    }
5124    let mut entries: Vec<Entry> = Vec::new();
5125    let mut accumulate = |hit: SearchHit, rank0: usize, in_vector: bool, weight: f64| {
5126        let contrib = weight / (RRF_K + (rank0 as f64 + 1.0));
5127        if let Some(existing) = entries.iter_mut().find(|e| e.hit.body == hit.body) {
5128            // Dedup on body; the representative hit (vector-first) is retained.
5129            existing.score += contrib;
5130        } else {
5131            let order = entries.len();
5132            entries.push(Entry { hit, score: contrib, in_vector, order });
5133        }
5134    };
5135    for (rank0, hit) in vector_hits.into_iter().enumerate() {
5136        accumulate(hit, rank0, true, RRF_WEIGHT_VECTOR);
5137    }
5138    for (rank0, hit) in text_hits.into_iter().enumerate() {
5139        accumulate(hit, rank0, false, RRF_WEIGHT_TEXT);
5140    }
5141    for (rank0, hit) in graph_hits.into_iter().enumerate() {
5142        // Graph arm: vector-first=false (never overrides an existing vector hit's
5143        // representative identity; only new bodies from the graph arm get GraphArm
5144        // as their branch identity). The in_vector=false ensures graph arm hits
5145        // never sort ahead of vector hits on exact score ties.
5146        accumulate(hit, rank0, false, RRF_WEIGHT_GRAPH);
5147    }
5148    entries.sort_by(|a, b| {
5149        b.score
5150            .partial_cmp(&a.score)
5151            .unwrap_or(std::cmp::Ordering::Equal)
5152            // vector-first on equal score (true sorts before false).
5153            .then_with(|| b.in_vector.cmp(&a.in_vector))
5154            .then_with(|| a.order.cmp(&b.order))
5155    });
5156    entries
5157        .into_iter()
5158        .map(|mut e| {
5159            e.hit.score = e.score;
5160            e.hit
5161        })
5162        .collect()
5163}
5164
5165/// G12-recency — reweight fused hits toward the more recent (higher
5166/// `write_cursor`/`id`) AFTER bit-KNN (never a vec0 predicate). Gated by the
5167/// caller's dedicated recency flag; `enabled=false` is a no-op (pure RRF).
5168#[doc(hidden)]
5169#[must_use]
5170pub fn apply_recency_reweight(hits: Vec<SearchHit>, enabled: bool) -> Vec<SearchHit> {
5171    if !enabled || hits.len() < 2 {
5172        return hits;
5173    }
5174    let min_id = hits.iter().map(|h| h.id).min().unwrap_or(0);
5175    let max_id = hits.iter().map(|h| h.id).max().unwrap_or(0);
5176    if max_id == min_id {
5177        return hits;
5178    }
5179    let span = (max_id - min_id) as f64;
5180    let mut reweighted: Vec<SearchHit> = hits
5181        .into_iter()
5182        .map(|mut h| {
5183            let norm = (h.id - min_id) as f64 / span;
5184            h.score += RECENCY_WEIGHT * norm;
5185            h
5186        })
5187        .collect();
5188    // Stable sort preserves the fused order on exact ties.
5189    reweighted.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
5190    reweighted
5191}
5192
5193/// 0.8.1 Slice 10 (R1) — CE rerank seam.
5194///
5195/// `rerank_depth = 0` (or model absent / `default-reranker` feature off): returns
5196/// `hits` **unchanged** — byte-identical to the old identity stub. This is the
5197/// soft-fallback contract.
5198///
5199/// `rerank_depth > 0` with the `default-reranker` feature on and the model
5200/// loaded: scores the top-`rerank_depth` (query, passage) pairs with the
5201/// TinyBERT-L-2 cross-encoder, blends CE score with the RRF score using the
5202/// formula from the design memo (Decision 5), re-sorts the top-N, and appends
5203/// the remainder in their original RRF order.
5204///
5205/// Score-blend (Decision 5): `α × sigmoid(ce_logit) + (1−α) × rrf_score_normalized`
5206/// where both CE and RRF scores are normalized to [0,1] over the reranked pool.
5207///
5208/// 0.8.5 (EXP-0): `alpha` (clamped to `[0,1]`) and `pool_n` (the reranked-pool
5209/// size, clamped to `hits.len()`) are caller-supplied. The defaults
5210/// `alpha = 0.3, pool_n = rerank_depth` reproduce the pre-slice blend exactly.
5211/// `rerank_depth == 0` remains the identity gate regardless of `pool_n`.
5212///
5213/// This is the rerank hook, **not** the dropped `fusion_mode` knob.
5214#[doc(hidden)]
5215#[must_use]
5216pub fn rerank_fused(
5217    _query: &str,
5218    hits: Vec<SearchHit>,
5219    rerank_depth: usize,
5220    alpha: f64,
5221    pool_n: usize,
5222) -> Vec<SearchHit> {
5223    // Soft-fallback: depth=0 → identity (byte-identical to old stub). NOTE this
5224    // early gate is independent of `pool_n`: `rerank_depth == 0, pool_n = 10`
5225    // does NOT rerank (0.8.5 D4).
5226    if rerank_depth == 0 {
5227        return hits;
5228    }
5229
5230    // Feature-gated CE inference. In the default build (no feature) this block
5231    // compiles away and `hits` is returned unchanged regardless of `rerank_depth`.
5232    // FIX-1: pass `&hits` (borrow) so `hits` remains owned for the soft-fallback path.
5233    #[cfg(feature = "default-reranker")]
5234    {
5235        if let Some(reranked) = ce_rerank(_query, &hits, rerank_depth, alpha, pool_n) {
5236            return reranked;
5237        }
5238    }
5239
5240    // 0.8.5: the bindings/default callers pass `alpha = 0.3, pool_n = rerank_depth`;
5241    // referenced here so the no-feature build does not warn on unused params.
5242    #[cfg(not(feature = "default-reranker"))]
5243    let _ = (alpha, pool_n);
5244
5245    // Model absent (feature off, weights not loaded, or CE returned None) →
5246    // soft-fallback: return input unchanged.
5247    hits
5248}
5249
5250/// 0.8.2 Slice E2 — standalone CE rerank of a caller-supplied passage list.
5251///
5252/// The pure, testable core that the `fathomdb.rerank` pyo3 binding is a thin
5253/// wrapper over. Slice 5's `fused_rerank` comparator must CE-rerank its OWN
5254/// in-harness fused(bm25+dense) pool — a pool the engine's `search()` never
5255/// constructs — so the CE has to be reachable over an arbitrary passage list,
5256/// not just the engine's capped text-only pool. This adapts `(id, body, score)`
5257/// passages into `SearchHit`s (`kind = "passage"`, `branch = Vector`,
5258/// `source_id = None`; only `body` and `score` feed the blend), runs them
5259/// through [`rerank_fused`], and projects back to `(id, score, ce_score)` in the reranked
5260/// order.
5261///
5262/// Contract (inherited verbatim from `rerank_fused`): `rerank_depth == 0` OR an
5263/// empty list returns the input order WITH the input scores, byte-identical — no
5264/// model load, no network. With `--features default-reranker` and
5265/// `rerank_depth > 0` the CE blends the top-`depth` and may reorder; with the
5266/// feature off the CE path compiles away and this is always identity.
5267///
5268/// 0.8.2 Slice E2 fix-1 [P2]: returns `Err` when any passage carries a non-finite
5269/// score (NaN / ±inf), mirroring the malformed-passage loud-fail contract.
5270/// Callers (pyo3 `rerank` binding, tests) must handle `Result`.
5271/// (`#[must_use]` removed: `Result` is already `#[must_use]`.)
5272pub fn rerank_passages(
5273    query: &str,
5274    passages: Vec<(u64, String, f64)>,
5275    rerank_depth: usize,
5276    alpha: f64,
5277    pool_n: usize,
5278) -> Result<Vec<(u64, f64, Option<f64>)>, String> {
5279    // [P2] guard: reject non-finite scores before they reach normalization/sort.
5280    // A NaN or ±inf score would produce NaN blended scores and an unstable sort
5281    // order — surface the error early as the typed WriteValidationError at the
5282    // pyo3 boundary (mirroring the malformed-passage loud-fail contract).
5283    for (id, _, score) in &passages {
5284        if !score.is_finite() {
5285            return Err(format!(
5286                "rerank: non-finite score for passage id={id}: {score} \
5287                 (NaN/\u{00b1}inf must not reach the normalization/sort step)"
5288            ));
5289        }
5290    }
5291    let hits: Vec<SearchHit> = passages
5292        .into_iter()
5293        .map(|(id, body, score)| SearchHit {
5294            id,
5295            kind: "passage".to_string(),
5296            body,
5297            score,
5298            branch: SoftFallbackBranch::Vector,
5299            source_id: None,
5300            ce_score: None,
5301        })
5302        .collect();
5303    // 0.8.5 — project `(id, score, ce_score)` so the binding can surface the CE
5304    // score per candidate; `ce_score` is `None` for the identity / out-of-pool path.
5305    Ok(rerank_fused(query, hits, rerank_depth, alpha, pool_n)
5306        .into_iter()
5307        .map(|h| (h.id, h.score, h.ce_score))
5308        .collect())
5309}
5310
5311/// 0.8.1 Slice 10 — score-blend reranking when CE model is loaded.
5312///
5313/// Returns `Some(reranked)` if the model is available, `None` otherwise
5314/// (caller then applies the soft-fallback).
5315///
5316/// Design memo Decision 5:
5317/// - CE normalized = sigmoid(raw_logit) ∈ [0,1]
5318/// - RRF normalized = min-max of `hit.score` over the top-K pool
5319/// - `final_score = 0.3 × ce_norm + 0.7 × rrf_norm`
5320/// - Hits beyond `rerank_depth` keep their original RRF scores and order.
5321#[cfg(feature = "default-reranker")]
5322fn ce_rerank(
5323    _query: &str,
5324    hits: &[SearchHit], // FIX-1: borrow, not move — caller retains ownership for soft-fallback
5325    _rerank_depth: usize, // 0.8.5: pool sizing moved to `pool_n`; depth gate stays in `rerank_fused`.
5326    alpha: f64,
5327    pool_n: usize,
5328) -> Option<Vec<SearchHit>> {
5329    // 0.8.5 (D3) — clamp α to [0,1] silently here so EVERY path (engine search,
5330    // `rerank_passages`, the bindings) is covered by one clamp, matching the
5331    // existing `pool_n.min(len)` clamp idiom.
5332    // codex §9 P2-1: `f64::clamp(NaN)` returns NaN (clamp does NOT map NaN into
5333    // range) — a non-finite α would then make every blended score NaN and destroy
5334    // the ranking. The high-level SDKs reject non-finite α, but the low-level
5335    // `rerank()` / direct-Rust callers don't, so fall back to the documented
5336    // default α=0.3 here for any non-finite input.
5337    let alpha = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 0.3 };
5338    // fix-1 [P2]: short-circuit before touching the singleton when there is
5339    // nothing to rerank — avoids loading/downloading the ~17 MB model for an
5340    // empty result set and prevents memoizing a transient load failure.
5341    if hits.is_empty() {
5342        return Some(vec![]);
5343    }
5344
5345    // Try to get the loaded model. Returns None when weights are absent.
5346    let model = CandleCrossEncoder::try_get_loaded()?;
5347
5348    // 0.8.5 (D4) — the reranked pool is the top `pool_n` (caller resolves the
5349    // `unwrap_or(rerank_depth)` default at the binding), clamped to the hit count.
5350    let n = pool_n.min(hits.len());
5351    let top = &hits[..n]; // no split_at_mut needed; borrow slices directly
5352    let rest = &hits[n..];
5353
5354    // --- RRF min-max normalization over the top-N pool ---
5355    let rrf_min = top.iter().map(|h| h.score).fold(f64::INFINITY, f64::min);
5356    let rrf_max = top.iter().map(|h| h.score).fold(f64::NEG_INFINITY, f64::max);
5357    let rrf_span = rrf_max - rrf_min;
5358
5359    // Batched CE scoring: ONE forward over the whole top-N pool instead of N
5360    // per-pair forwards. The ranking math below (RRF min-max norm, sigmoid,
5361    // ALPHA blend, sort) is byte-unchanged — only the scoring is batched.
5362    let bodies: Vec<&str> = top.iter().map(|h| h.body.as_str()).collect();
5363    let raw_logits = model.score_batch(_query, &bodies);
5364
5365    let mut scored: Vec<(f64, SearchHit)> = top
5366        .iter()
5367        .zip(raw_logits)
5368        .map(|(h, raw_logit)| {
5369            let rrf_norm = if rrf_span > 0.0 { (h.score - rrf_min) / rrf_span } else { 1.0 };
5370            // Sigmoid for CE normalization: 1/(1+exp(-x)).
5371            let ce_norm = 1.0 / (1.0 + (-raw_logit).exp());
5372            // 0.8.5 — α is the caller-supplied (clamped) blend weight; default 0.3
5373            // reproduces the pre-slice `const ALPHA = 0.3` blend exactly.
5374            let blended = alpha * ce_norm + (1.0 - alpha) * rrf_norm;
5375            // 0.8.5 (D1) — expose the per-candidate CE score on in-pool hits.
5376            let mut hit = h.clone();
5377            hit.ce_score = Some(ce_norm);
5378            (blended, hit)
5379        })
5380        .collect();
5381
5382    // Sort top-N by blended score descending (stable within ties by original order).
5383    scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
5384
5385    let mut result: Vec<SearchHit> = scored
5386        .into_iter()
5387        .map(|(score, mut h)| {
5388            h.score = score;
5389            h
5390        })
5391        .collect();
5392
5393    // Append hits beyond rerank_depth in their original RRF order.
5394    result.extend_from_slice(rest);
5395    Some(result)
5396}
5397
5398/// 0.8.1 Slice 10 (R1) / 0.8.2 Slice E1 — CPU TinyBERT-L-2 cross-encoder.
5399///
5400/// Thin engine-side handle over the embedder crate's `CandleTinyBertReranker`
5401/// (Candle BERT stack + `tokenizers`, pinned `cross-encoder/ms-marco-TinyBERT-
5402/// L2-v2`). The model is loaded once, process-wide, the first time
5403/// `rerank_depth > 0` reaches the CE path (lazy init via the `OnceLock` below);
5404/// on cache miss that first load fetches the ~17 MB weights over the network
5405/// (sha256-verified). When the weights are absent and the network is
5406/// unavailable, the load fails and `try_get_loaded()` returns `None` so the
5407/// caller soft-falls-back to RRF order — it never panics.
5408///
5409/// Footprint: this whole type compiles ONLY under `default-reranker`. With the
5410/// feature off the CE path compiles away and `rerank_fused` is always identity.
5411/// With the feature on, `rerank_depth == 0` short-circuits in `rerank_fused`
5412/// BEFORE this is ever touched, so depth-0 stays byte-identical and no-network.
5413/// Similarly, an empty hit set short-circuits in `ce_rerank` before the singleton
5414/// is consulted (fix-1 [P2]).
5415#[cfg(feature = "default-reranker")]
5416struct CandleCrossEncoder {
5417    inner: &'static fathomdb_embedder::CandleTinyBertReranker,
5418}
5419
5420/// Process-wide lazily-initialized reranker. `None` once initialization has
5421/// been attempted and failed (no weights + no network) — memoized so a failed
5422/// load is not retried on every query.
5423#[cfg(feature = "default-reranker")]
5424fn reranker_singleton() -> Option<&'static fathomdb_embedder::CandleTinyBertReranker> {
5425    static CELL: std::sync::OnceLock<Option<fathomdb_embedder::CandleTinyBertReranker>> =
5426        std::sync::OnceLock::new();
5427    CELL.get_or_init(|| fathomdb_embedder::CandleTinyBertReranker::try_load().ok()).as_ref()
5428}
5429
5430#[cfg(feature = "default-reranker")]
5431impl CandleCrossEncoder {
5432    /// Returns a model handle if the reranker is (or can be) loaded, `None`
5433    /// otherwise. The first call drives the lazy load (cache probe → gated
5434    /// download); subsequent calls reuse the memoized result.
5435    fn try_get_loaded() -> Option<Self> {
5436        Some(Self { inner: reranker_singleton()? })
5437    }
5438
5439    /// Score a (query, passage) pair. Returns the raw cross-encoder logit, or
5440    /// `0.0` (a neutral logit → sigmoid 0.5) if the forward pass errors, so a
5441    /// single bad pair degrades to a neutral CE contribution rather than
5442    /// panicking in the reader thread.
5443    fn score(&self, query: &str, passage: &str) -> f64 {
5444        self.inner.score(query, passage).map(f64::from).unwrap_or(0.0)
5445    }
5446
5447    /// Batched [`score`](Self::score): score every `(query, passage_i)` pair in a
5448    /// single forward pass. Returns one logit per passage in input order, each
5449    /// honoring the same neutral-`0.0`-on-error contract as [`score`](Self::score).
5450    ///
5451    /// Fallback: if the batched forward errors as a whole (e.g. an OOM or a
5452    /// tokenize failure on one pair surfaces as a batch `Err`), we DO NOT
5453    /// neutralize the entire pool — we fall back to per-pair [`score`](Self::score),
5454    /// so a single bad pair degrades only its own element to a neutral logit while
5455    /// the rest keep their real scores. Empty input → empty output (no forward).
5456    fn score_batch(&self, query: &str, passages: &[&str]) -> Vec<f64> {
5457        match self.inner.score_batch(query, passages) {
5458            Ok(logits) => logits.into_iter().map(f64::from).collect(),
5459            Err(_) => passages.iter().map(|p| self.score(query, p)).collect(),
5460        }
5461    }
5462}
5463
5464/// G10 — the `AND col=?n` predicate fragment appended to the phase-1 candidates
5465/// `WHERE` for the present filter fields. Placeholders are numbered from `?3`
5466/// (`?1` = sign-quant query, `?2` = f32 rerank query). Field order is canonical
5467/// (`source_type`, `kind`, `created_after`, `status`) and is mirrored exactly by
5468/// [`vector_filter_values`]. Empty for `None`/all-`None` (byte-identity path).
5469fn vector_filter_clause(filter: Option<&SearchFilter>) -> String {
5470    let Some(filter) = filter else {
5471        return String::new();
5472    };
5473    if filter.is_unfiltered() {
5474        return String::new();
5475    }
5476    let mut cols: Vec<(&str, &str)> = Vec::new();
5477    if filter.source_type.is_some() {
5478        cols.push(("source_type", "="));
5479    }
5480    if filter.kind.is_some() {
5481        cols.push(("kind", "="));
5482    }
5483    if filter.created_after.is_some() {
5484        cols.push(("created_at", ">="));
5485    }
5486    if filter.status.is_some() {
5487        cols.push(("status", "="));
5488    }
5489    let mut clause = String::new();
5490    for (i, (col, op)) in cols.iter().enumerate() {
5491        clause.push_str(&format!(" AND {col}{op}?{}", i + 3));
5492    }
5493    clause
5494}
5495
5496/// G10 — the bound values for the present filter fields, in the SAME canonical
5497/// order as [`vector_filter_clause`] so placeholder `?{n}` lines up with value
5498/// `n-3`.
5499fn vector_filter_values(filter: Option<&SearchFilter>) -> Vec<rusqlite::types::Value> {
5500    use rusqlite::types::Value;
5501    let mut out = Vec::new();
5502    let Some(filter) = filter else {
5503        return out;
5504    };
5505    if filter.is_unfiltered() {
5506        return out;
5507    }
5508    if let Some(s) = &filter.source_type {
5509        out.push(Value::Text(s.clone()));
5510    }
5511    if let Some(s) = &filter.kind {
5512        out.push(Value::Text(s.clone()));
5513    }
5514    if let Some(c) = filter.created_after {
5515        out.push(Value::Integer(c));
5516    }
5517    if let Some(s) = &filter.status {
5518        out.push(Value::Text(s.clone()));
5519    }
5520    out
5521}
5522
5523/// G10 — build the single phase-1 candidates statement. With `filter=None` (or
5524/// all-`None`) the `{filter_clause}` is empty and the SQL is **byte-identical to
5525/// 0.7.2** (the documented behavior-compat invariant; pinned by
5526/// `pr_g10_filtered_knn.rs`). The KNN form (`ORDER BY distance LIMIT top_k`, no
5527/// `k=`) is preserved.
5528fn build_vector_phase1_sql(filter: Option<&SearchFilter>, final_limit: usize) -> String {
5529    let filter_clause = vector_filter_clause(filter);
5530    format!(
5531        "WITH candidates AS (
5532                     SELECT rowid
5533                     FROM vector_default
5534                     WHERE embedding_bin MATCH vec_quantize_binary(vec_f32(?1)){filter_clause}
5535                     ORDER BY distance
5536                     LIMIT {top_k}
5537                 )
5538                 SELECT c.rowid, vec_distance_l2(v.embedding, vec_f32(?2)) AS l2
5539                 FROM candidates c
5540                 JOIN vector_default v ON v.rowid = c.rowid
5541                 ORDER BY l2
5542                 LIMIT {final_limit}",
5543        top_k = TOP_K_BIT_CANDIDATES,
5544    )
5545}
5546
5547/// Test seam — exposes [`build_vector_phase1_sql`] at the production
5548/// `SEARCH_RERANK_LIMIT` so `pr_g10_filtered_knn.rs` can pin the `filter=None`
5549/// byte-identity and the appended predicates.
5550#[doc(hidden)]
5551#[must_use]
5552pub fn vector_phase1_sql_for_test(filter: Option<&SearchFilter>) -> String {
5553    build_vector_phase1_sql(filter, SEARCH_RERANK_LIMIT)
5554}
5555
5556/// G10 — does a text-branch hit satisfy the filter? The vector branch is
5557/// pruned in-SQL; the text branch is constrained here against the same metadata:
5558/// `kind` directly, `source_type` via [`resolve_source_type`], and
5559/// `created_after`/`status` from `vector_default` by `rowid == write_cursor`. A
5560/// text-only row absent from the vector partition cannot satisfy a
5561/// `created_after`/`status` predicate, so it is excluded — filtered semantic
5562/// search is a vector-metadata capability.
5563fn text_hit_passes_filter(
5564    tx: &rusqlite::Transaction<'_>,
5565    id: u64,
5566    kind: &str,
5567    filter: Option<&SearchFilter>,
5568) -> rusqlite::Result<bool> {
5569    let Some(filter) = filter else {
5570        return Ok(true);
5571    };
5572    if filter.is_unfiltered() {
5573        return Ok(true);
5574    }
5575    if let Some(k) = &filter.kind {
5576        if kind != k {
5577            return Ok(false);
5578        }
5579    }
5580    if let Some(st) = &filter.source_type {
5581        match resolve_source_type(kind) {
5582            Ok(resolved) if resolved == st.as_str() => {}
5583            _ => return Ok(false),
5584        }
5585    }
5586    if filter.created_after.is_some() || filter.status.is_some() {
5587        let meta: Option<(i64, Option<String>)> = tx
5588            .query_row(
5589                "SELECT created_at, status FROM vector_default WHERE rowid = ?1 LIMIT 1",
5590                [id as i64],
5591                |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?)),
5592            )
5593            .optional()?;
5594        let Some((created_at, status)) = meta else {
5595            // No vector-partition row: cannot satisfy a vec-metadata predicate.
5596            return Ok(false);
5597        };
5598        if let Some(bound) = filter.created_after {
5599            if created_at < bound {
5600                return Ok(false);
5601            }
5602        }
5603        if let Some(want) = &filter.status {
5604            if status.as_deref() != Some(want.as_str()) {
5605                return Ok(false);
5606            }
5607        }
5608    }
5609    Ok(true)
5610}
5611
5612/// G11 (Slice 15) — does an edge FTS hit satisfy the filter?
5613///
5614/// Edge FTS hits always have `source_type = "edge_fact"` (the partition
5615/// discriminant). Their `row.kind` is the **relation** kind (e.g. `"owns"`,
5616/// `"works_for"`), not a node kind, so [`text_hit_passes_filter`] MUST NOT be
5617/// used for edge hits: `resolve_source_type(relation_kind)` returns `Err` for
5618/// unknown kinds, causing every edge hit to be silently rejected when a
5619/// `source_type` filter is set — the exact inverse of correct behaviour.
5620///
5621/// Edge bodies ARE projected into `vector_default` (rowid = `write_cursor`),
5622/// so `created_after` / `status` are satisfied by querying `vector_default`
5623/// exactly as [`text_hit_passes_filter`] does for node hits.
5624///
5625/// Rules:
5626/// - `source_type`: pass iff `None` **or** `== "edge_fact"`.
5627/// - `kind`: filter on the relation kind (`row.kind`) if specified.
5628/// - `created_after` / `status`: query `vector_default WHERE rowid = write_cursor`;
5629///   if absent from the vector partition the hit cannot satisfy a vec-metadata
5630///   predicate and is excluded.
5631fn edge_fts_hit_passes_filter(
5632    tx: &rusqlite::Transaction<'_>,
5633    write_cursor: u64,
5634    row_kind: &str,
5635    filter: Option<&SearchFilter>,
5636) -> rusqlite::Result<bool> {
5637    let Some(filter) = filter else {
5638        return Ok(true);
5639    };
5640    if filter.is_unfiltered() {
5641        return Ok(true);
5642    }
5643    if let Some(ref st) = filter.source_type {
5644        if st != "edge_fact" {
5645            return Ok(false); // filter targets a specific non-edge source_type
5646        }
5647    }
5648    if let Some(ref k) = filter.kind {
5649        if k != row_kind {
5650            return Ok(false); // kind filter applies to the relation kind
5651        }
5652    }
5653    // Edge bodies are projected into vector_default; check created_after/status
5654    // there, the same way text_hit_passes_filter does for node hits.
5655    if filter.created_after.is_some() || filter.status.is_some() {
5656        let meta: Option<(i64, Option<String>)> = tx
5657            .query_row(
5658                "SELECT created_at, status FROM vector_default WHERE rowid = ?1 LIMIT 1",
5659                [write_cursor as i64],
5660                |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?)),
5661            )
5662            .optional()?;
5663        let Some((created_at, status)) = meta else {
5664            // No vector-partition row: cannot satisfy a vec-metadata predicate.
5665            return Ok(false);
5666        };
5667        if let Some(bound) = filter.created_after {
5668            if created_at < bound {
5669                return Ok(false);
5670            }
5671        }
5672        if let Some(want) = &filter.status {
5673            if status.as_deref() != Some(want.as_str()) {
5674                return Ok(false);
5675            }
5676        }
5677    }
5678    Ok(true)
5679}
5680
5681/// Read projection cursor and matching body rows inside one read tx.
5682// The 8th parameter (`vector_stage_only`) is the additive GA-2 / ◆ B-1
5683// measurement seam; the reader-worker call site threads each field through
5684// explicitly (mirroring the existing `recency_enabled` plumbing), so a wrapper
5685// struct would only obscure that 1:1 mapping for a test-only flag.
5686#[allow(clippy::too_many_arguments)]
5687fn read_search_in_tx(
5688    reader: &mut Connection,
5689    compiled: &fathomdb_query::CompiledQuery,
5690    query_vector: Option<&str>,
5691    query_vector_bin: Option<&str>,
5692    final_limit: usize,
5693    filter: Option<&SearchFilter>,
5694    recency_enabled: bool,
5695    vector_stage_only: bool,
5696    raw_query: &str,
5697    rerank_depth: usize,
5698    use_graph_arm: bool,
5699    alpha: f64,
5700    pool_n: usize,
5701    explain: bool,
5702) -> ReaderResponse {
5703    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
5704    let cursor = load_projection_cursor(&tx)?;
5705    let vector_results = if let Some(query_vector) = query_vector {
5706        let mut rowids = Vec::new();
5707        let bin_vector = query_vector_bin.unwrap_or(query_vector);
5708        {
5709            // Phase 1: bit-KNN over `embedding_bin` to a top-K candidate
5710            // set; Phase 2: f32 rerank on the candidate set via
5711            // vec_distance_l2 against the retained `embedding` column.
5712            // EU-5a2: ?1 is the (possibly centered) sign-quant input,
5713            // ?2 is the un-centered f32 for vec_distance_l2 — both sides
5714            // of the f32 cosine use un-centered vectors.
5715            // PR-2bc S1 fix-1: the phase-2 rerank LIMIT is `SEARCH_RERANK_LIMIT`
5716            // (10) in production. `final_limit` is supplied by the caller from
5717            // `ProjectionRuntimeShared::search_limit_override` (default 10,
5718            // clamped >=10) — there is NO env-var read on this hot path. A test
5719            // seam (`set_search_limit_for_test`) may RAISE it so the recall
5720            // harness can pull top-(10+slack) and exclude the self-retrieving
5721            // query-source doc BEFORE truncating to 10 (standard ANN-recall
5722            // practice); it can never shrink below production semantics.
5723            // G10: the metadata filter is appended to this single phase-1
5724            // statement (`AND col=?n` from ?3); `filter=None` keeps the SQL
5725            // byte-identical to 0.7.2. `?1`/`?2` are the sign-quant + f32 query
5726            // vectors; filter values bind at ?3.. in `vector_filter_clause`
5727            // order.
5728            let sql = build_vector_phase1_sql(filter, final_limit);
5729            let mut params: Vec<rusqlite::types::Value> = vec![
5730                rusqlite::types::Value::Text(bin_vector.to_string()),
5731                rusqlite::types::Value::Text(query_vector.to_string()),
5732            ];
5733            params.extend(vector_filter_values(filter));
5734            let mut statement = tx.prepare(&sql)?;
5735            let rows = statement.query_map(rusqlite::params_from_iter(params.iter()), |row| {
5736                Ok((row.get::<_, i64>(0)?, row.get::<_, f64>(1)?))
5737            })?;
5738            for row in rows.flatten() {
5739                rowids.push(row);
5740            }
5741        }
5742        // G1: carry the canonical row's `write_cursor` (interim id), `kind`,
5743        // `body`, and the `vec_distance_l2` rerank score per hit. The
5744        // `_fathomdb_vector_rows.rowid` equals the canonical `write_cursor`,
5745        // so the candidate rowid IS the hit id.
5746        //
5747        // G11 (Slice 15) fix: edge bodies are projected into vector_default under
5748        // kind = "edge_fact"; their write_cursor is in canonical_edges, not
5749        // canonical_nodes. Try canonical_nodes first; fall back to canonical_edges
5750        // for edge-fact hits so they are not silently dropped.
5751        let mut results = Vec::new();
5752        let mut node_stmt =
5753            tx.prepare("SELECT kind, body FROM canonical_nodes WHERE write_cursor = ?1 LIMIT 1")?;
5754        let mut edge_stmt = tx.prepare(
5755            "SELECT body FROM canonical_edges \
5756             WHERE write_cursor = ?1 AND superseded_at IS NULL AND body IS NOT NULL LIMIT 1",
5757        )?;
5758        for (rowid, score) in rowids {
5759            if let Ok((kind, body)) = node_stmt
5760                .query_row([rowid], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
5761            {
5762                results.push(SearchHit {
5763                    id: rowid as u64,
5764                    kind,
5765                    body,
5766                    score,
5767                    branch: SoftFallbackBranch::Vector,
5768                    source_id: None,
5769                    ce_score: None,
5770                });
5771            } else if let Ok(body) = edge_stmt.query_row([rowid], |row| row.get::<_, String>(0)) {
5772                results.push(SearchHit {
5773                    id: rowid as u64,
5774                    kind: "edge_fact".to_string(),
5775                    body,
5776                    score,
5777                    branch: SoftFallbackBranch::TextEdge,
5778                    source_id: None,
5779                    ce_score: None,
5780                });
5781            }
5782        }
5783        results
5784    } else {
5785        Vec::new()
5786    };
5787    let vector_rows_visible = !vector_results.is_empty();
5788    let soft_fallback = if query_vector.is_some() && !vector_rows_visible {
5789        tx.query_row(
5790            "SELECT 1
5791             FROM search_index
5792             JOIN _fathomdb_vector_kinds ON _fathomdb_vector_kinds.kind = search_index.kind
5793             LEFT JOIN _fathomdb_projection_terminal
5794               ON _fathomdb_projection_terminal.write_cursor = search_index.write_cursor
5795             WHERE search_index MATCH ?1
5796              AND _fathomdb_projection_terminal.write_cursor IS NULL
5797             LIMIT 1",
5798            [compiled.match_expression.as_str()],
5799            |_row| Ok(SoftFallback { branch: SoftFallbackBranch::Vector }),
5800        )
5801        .ok()
5802    } else {
5803        None
5804    };
5805    // Collect the text branch (ranked by `write_cursor`, as 0.7.2), then
5806    // post-filter it against the same metadata the vector branch was pruned by
5807    // in SQL (the vector branch is filtered in phase 1; the text branch has no
5808    // metadata columns of its own).
5809    let text_candidates: Vec<SearchHit> = {
5810        // 0.7.0 perf-experiments: optional FTS5 LIMIT cap. Gated on
5811        // FATHOMDB_PERF_EXPERIMENTS=1; opt-in via
5812        // FATHOMDB_PERF_SEARCH_LIMIT=<k>. No-op by default — preserves
5813        // 0.6.x unbounded result-set semantics. Removed (or made the
5814        // hardcoded default) at Wave 5 landing per
5815        // dev/plans/0.7.0-perf-experiments.md.
5816        let perf_limit: Option<usize> = if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_some() {
5817            std::env::var("FATHOMDB_PERF_SEARCH_LIMIT").ok().and_then(|s| s.parse().ok())
5818        } else {
5819            None
5820        };
5821        // G1: SELECT body + kind + write_cursor (interim id) and the
5822        // `bm25()` text-relevance score. IR-C (2026-06-10,
5823        // `performance-output-and-compare.md`): the per-branch rank RRF fuses on
5824        // must be **`bm25()` relevance**, not `write_cursor` (insertion order) —
5825        // the prior `ORDER BY write_cursor` meant the lexical arm never ranked by
5826        // relevance, the single biggest fusion bug. `bm25()` is more-negative ⇒
5827        // better, so ascending puts best matches first; `write_cursor` is the
5828        // deterministic tiebreak. The filter is applied as a Rust post-filter so
5829        // the unfiltered path is untouched.
5830        let sql = match perf_limit {
5831            Some(k) => format!(
5832                "SELECT body, kind, write_cursor, bm25(search_index) FROM search_index \
5833                 WHERE search_index MATCH ?1 ORDER BY bm25(search_index), write_cursor LIMIT {k}"
5834            ),
5835            None => "SELECT body, kind, write_cursor, bm25(search_index) FROM search_index \
5836                 WHERE search_index MATCH ?1 ORDER BY bm25(search_index), write_cursor"
5837                .to_string(),
5838        };
5839        let mut statement = tx.prepare(&sql)?;
5840        let rows = statement.query_map([compiled.match_expression.as_str()], |row| {
5841            Ok(SearchHit {
5842                body: row.get::<_, String>(0)?,
5843                kind: row.get::<_, String>(1)?,
5844                id: row.get::<_, i64>(2)? as u64,
5845                score: row.get::<_, f64>(3)?,
5846                branch: SoftFallbackBranch::Text,
5847                source_id: None,
5848                ce_score: None,
5849            })
5850        })?;
5851        rows.flatten().collect()
5852    };
5853    let mut text_results: Vec<SearchHit> = Vec::with_capacity(text_candidates.len());
5854    for hit in text_candidates {
5855        if text_hit_passes_filter(&tx, hit.id, &hit.kind, filter)? {
5856            text_results.push(hit);
5857        }
5858    }
5859
5860    // G11 (Slice 15) — edge-body FTS branch from `search_index_edges`.
5861    // Appended to text_results; tagged with SoftFallbackBranch::TextEdge so
5862    // callers can distinguish edge hits from node hits.
5863    //
5864    // fix-1 [P2]: JOIN canonical_edges to exclude superseded edge rows
5865    // (invalidate-not-accumulate can leave a superseded body in the FTS index).
5866    // fix-2 [P2]: use edge_fts_hit_passes_filter (NOT text_hit_passes_filter).
5867    // Edge hits always have source_type="edge_fact"; text_hit_passes_filter
5868    // calls resolve_source_type(relation_kind) which returns Err for unknown
5869    // relation kinds, silently rejecting every edge hit when a source_type
5870    // filter is set — the exact inverse of correct behaviour.
5871    // fix-3 [P2]: edge_fts_hit_passes_filter now queries vector_default for
5872    // created_after/status (mirroring text_hit_passes_filter). Collect edge
5873    // candidates into a Vec first (drops stmt borrow on tx) so we can pass
5874    // &tx to edge_fts_hit_passes_filter without a borrow conflict.
5875    let edge_candidates: Vec<SearchHit> = {
5876        let edge_sql = "SELECT sei.body, sei.kind, sei.write_cursor, bm25(search_index_edges) \
5877             FROM search_index_edges sei \
5878             JOIN canonical_edges ce ON ce.write_cursor = sei.write_cursor \
5879             WHERE search_index_edges MATCH ?1 \
5880               AND ce.superseded_at IS NULL \
5881             ORDER BY bm25(search_index_edges), sei.write_cursor";
5882        // search_index_edges may not exist on very old DBs not yet at step-14;
5883        // ignore the error gracefully (returns empty slice).
5884        if let Ok(mut stmt) = tx.prepare(edge_sql) {
5885            if let Ok(rows) = stmt.query_map([compiled.match_expression.as_str()], |row| {
5886                Ok(SearchHit {
5887                    body: row.get::<_, String>(0)?,
5888                    kind: row.get::<_, String>(1)?,
5889                    id: row.get::<_, i64>(2)? as u64,
5890                    score: row.get::<_, f64>(3)?,
5891                    branch: SoftFallbackBranch::TextEdge,
5892                    source_id: None,
5893                    ce_score: None,
5894                })
5895            }) {
5896                rows.flatten().collect()
5897            } else {
5898                Vec::new()
5899            }
5900        } else {
5901            Vec::new()
5902        }
5903    };
5904    for row in edge_candidates {
5905        if edge_fts_hit_passes_filter(&tx, row.id, &row.kind, filter)? {
5906            text_results.push(row);
5907        }
5908    }
5909    tx.commit()?;
5910
5911    // GA-2 / Slice-40 (◆ B-1) measurement seam: when `vector_stage_only` is set
5912    // (only ever by the eu7 recall harness via `set_vector_stage_only_for_test`,
5913    // off for every production caller), return the pre-fusion VECTOR-branch
5914    // ranking (bit-KNN K=192 + f32 rerank) verbatim, skipping `fuse_rrf` /
5915    // recency / `rerank_fused`. This exposes the ANN-quantization FIDELITY
5916    // signal — vector top-N vs the exact-f32 VECTOR top-10 ground truth — that
5917    // the AC-075 0.90 floor is defined to measure. It is NOT a `fusion_mode`
5918    // knob: the production branch below is byte-unchanged and RRF stays
5919    // unconditional.
5920    // G0 Phase-2 (BLOCK-1) side-channel meter — default (all-zero, rate 0.0) on
5921    // the non-graph-arm paths; populated by the BFS seed phase when graph-arm runs.
5922    let mut graph_stats = GraphFrontierStats::default();
5923
5924    // 0.8.8 EXP-OBS (Slice 5) — capture per-arm rank maps + counts BEFORE the arms
5925    // are consumed by fusion. All reads; only when `explain` (else zero work).
5926    // `body_rank_map` keeps the FIRST occurrence (== the rank `fuse_three_arms`
5927    // uses, which dedups keeping the first). `*_fused_scores` is captured from the
5928    // post-recency / pre-CE intermediate so `fused_score` is faithful to what
5929    // `ce_rerank` normalizes.
5930    let body_rank_map = |hits: &[SearchHit]| -> HashMap<String, u32> {
5931        let mut m: HashMap<String, u32> = HashMap::new();
5932        for (i, h) in hits.iter().enumerate() {
5933            m.entry(h.body.clone()).or_insert(i as u32);
5934        }
5935        m
5936    };
5937    let body_score_map = |hits: &[SearchHit]| -> HashMap<String, f64> {
5938        hits.iter().map(|h| (h.body.clone(), h.score)).collect()
5939    };
5940
5941    let (exp_vector_ranks, exp_text_ranks, exp_vector_n, exp_text_n) = if explain {
5942        (
5943            Some(body_rank_map(&vector_results)),
5944            Some(body_rank_map(&text_results)),
5945            vector_results.len() as u32,
5946            text_results.len() as u32,
5947        )
5948    } else {
5949        (None, None, 0, 0)
5950    };
5951    let mut exp_graph_ranks: Option<HashMap<String, u32>> = None;
5952    let mut exp_fused_scores: Option<HashMap<String, f64>> = None;
5953    let mut exp_graph_n: u32 = 0;
5954
5955    let results = if vector_stage_only {
5956        vector_results
5957    } else if use_graph_arm {
5958        // R3 (Slice 30) — graph arm: BFS over temporal fact-edges seeded from
5959        // the top-10 two-arm fused candidates, depth ≤ 3, cap 50.
5960        // Temporal filter: superseded_at IS NULL AND (t_invalid IS NULL OR t_invalid > now).
5961        // Synthesized-node penalty: kind = 'unknown' → score *= 0.3.
5962        //
5963        // Approach: compute the two-arm fused result first (for BFS seeding),
5964        // then fuse three arms: the two-arm result (as "vector" arm), an empty
5965        // text arm, and the graph candidates. The two-arm result preserves all
5966        // existing ranking semantics; the graph arm contributes new candidates.
5967        let two_arm_fused = fuse_rrf(vector_results, text_results);
5968        // C1: seed the graph arm from the query's FTS match expression (entities /
5969        // edge-facts), not the doc-node fused hits. `fused_hits` is still passed for
5970        // the seed-body exclusion set.
5971        let (graph_candidates, stats) = bfs_graph_arm_candidates(
5972            reader,
5973            &two_arm_fused,
5974            compiled.match_expression.as_str(),
5975            3,
5976            50,
5977        )?;
5978        graph_stats = stats;
5979        if explain {
5980            exp_graph_ranks = Some(body_rank_map(&graph_candidates));
5981            exp_graph_n = graph_candidates.len() as u32;
5982        }
5983        // Named intermediate (byte-identical to the prior nested call) so explain
5984        // can read the pre-CE fused scores without perturbing the ranking.
5985        let fused = apply_recency_reweight(
5986            fuse_three_arms(two_arm_fused, vec![], graph_candidates),
5987            recency_enabled,
5988        );
5989        if explain {
5990            exp_fused_scores = Some(body_score_map(&fused));
5991        }
5992        rerank_fused(raw_query, fused, rerank_depth, alpha, pool_n)
5993    } else {
5994        // G9 + G12: RRF-fuse the two ranked branches (keyed on body, vector-first
5995        // tiebreak) into the unconditional new ranking, recency-reweight (gated,
5996        // off by default), then pass through the identity rerank seam. The
5997        // vector-empty `soft_fallback` signal was computed above, BEFORE this
5998        // branch-collapse.
5999        let fused = apply_recency_reweight(fuse_rrf(vector_results, text_results), recency_enabled);
6000        if explain {
6001            exp_fused_scores = Some(body_score_map(&fused));
6002        }
6003        rerank_fused(raw_query, fused, rerank_depth, alpha, pool_n)
6004    };
6005
6006    // 0.8.8 EXP-OBS — assemble the sidecar `Explanation` from the captured maps +
6007    // the final `results`. `embedder_id` is left empty here (the worker has no
6008    // identity) and filled by `search_inner_with_stats`.
6009    let explanation = if explain {
6010        let fused_scores = exp_fused_scores.unwrap_or_default();
6011        let per_hit: Vec<PerHitExplain> = results
6012            .iter()
6013            .map(|h| PerHitExplain {
6014                id: h.id,
6015                arm: h.branch,
6016                vector_rank: exp_vector_ranks.as_ref().and_then(|m| m.get(&h.body).copied()),
6017                text_rank: exp_text_ranks.as_ref().and_then(|m| m.get(&h.body).copied()),
6018                graph_rank: exp_graph_ranks.as_ref().and_then(|m| m.get(&h.body).copied()),
6019                fused_score: fused_scores.get(&h.body).copied().unwrap_or(h.score),
6020                ce_score: h.ce_score,
6021                blended: h.score,
6022            })
6023            .collect();
6024        let ce_active = rerank_depth > 0 && per_hit.iter().any(|p| p.ce_score.is_some());
6025        Some(Explanation {
6026            trace: QueryTrace {
6027                query_chars: raw_query.chars().count() as u32,
6028                k: final_limit as u32,
6029                rerank_depth: rerank_depth as u32,
6030                pool_n: pool_n as u32,
6031                alpha,
6032                use_graph_arm,
6033                recency: recency_enabled,
6034                embedder_id: String::new(),
6035                ce_active,
6036                vector_hits: exp_vector_n,
6037                text_hits: exp_text_n,
6038                graph_hits: exp_graph_n,
6039            },
6040            per_hit,
6041        })
6042    } else {
6043        None
6044    };
6045
6046    Ok((cursor, soft_fallback, results, graph_stats, explanation))
6047}
6048
6049/// R3 (Slice 30) + C1 (0.8.1 graph-arm seeding) — graph-arm BFS candidate generation.
6050///
6051/// **C1 seeding (the BLOCK-1 fix):** the frontier is seeded from the graph's OWN
6052/// query-matched text surfaces — NOT from doc-node hits (doc nodes carry
6053/// `logical_id = NULL`, so the old doc-seeding produced an empty frontier). Two
6054/// seed sources are unioned on `match_expression` (the compiled FTS query):
6055///   A. **edge-fact FTS** (`search_index_edges`) — both endpoints (`from_id`,
6056///      `to_id`) of matched, temporally-live, non-fallback edges;
6057///   B. **entity-node FTS** (`search_index` ⋈ `canonical_nodes`) — matched nodes
6058///      with `logical_id IS NOT NULL` (excludes doc nodes — the bug surface).
6059/// Each distinct candidate `logical_id` is counted in `seeds_considered`; those
6060/// confirmed active in `canonical_nodes` are `seeds_resolved` and pushed onto the
6061/// frontier (dangling edge endpoints count considered-but-unresolved).
6062///
6063/// Phase 2 is unchanged: BFS over `canonical_edges` with the temporal filter,
6064/// carrying each traversed edge's `source_id` (G0 BLOCK-2) onto the emitted hit.
6065/// Collects reachable node bodies (up to `cap`) as [`SearchHit`]s tagged
6066/// `SoftFallbackBranch::GraphArm`. Score = `1.0 / (1.0 + hop_count)` with a
6067/// synthesized-node penalty (`kind = 'unknown'` → score *= 0.3). Bodies already
6068/// present in `fused_hits` are excluded (already covered by the two-arm result).
6069fn bfs_graph_arm_candidates(
6070    reader: &mut Connection,
6071    fused_hits: &[SearchHit],
6072    match_expression: &str,
6073    max_depth: u32,
6074    cap: usize,
6075) -> rusqlite::Result<(Vec<SearchHit>, GraphFrontierStats)> {
6076    // C1 — seed-FTS fan-out cap per source (A: edge endpoints, B: entity nodes).
6077    const SEED_FTS_N: usize = 10;
6078    const SYNTHESIZED_PENALTY: f64 = 0.3;
6079
6080    // Bodies already in the fused result — exclude these from graph arm output.
6081    let seed_bodies: std::collections::HashSet<&str> =
6082        fused_hits.iter().map(|h| h.body.as_str()).collect();
6083
6084    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
6085
6086    let mut frontier: VecDeque<(String, u32)> = VecDeque::new(); // (logical_id, depth)
6087    let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
6088    let mut candidates: Vec<SearchHit> = Vec::new();
6089    // G0 Phase-2 (BLOCK-1) frontier meter — distinct seed candidates considered vs
6090    // resolved-active; `resolved_seed_rate` flips 0→>0 once entities/edge-facts seed.
6091    let mut stats = GraphFrontierStats::default();
6092    {
6093        // C1 seeding — gather distinct candidate (logical_id, provenance source_id)
6094        // pairs from the graph's OWN query-matched FTS surfaces (NOT doc-node hits).
6095        // Order-preserving dedup (first provenance wins) so `seeds_considered` counts
6096        // each candidate once. `source_id` is the session the seed traces back to: the
6097        // matched edge's `source_id` (source A) or the entity node's own (source B).
6098        let mut candidate_seeds: Vec<(String, Option<String>)> = Vec::new();
6099        let mut seen_candidates: std::collections::HashSet<String> =
6100            std::collections::HashSet::new();
6101        let push_candidate =
6102            |lid: String,
6103             source_id: Option<String>,
6104             seen: &mut std::collections::HashSet<String>,
6105             out: &mut Vec<(String, Option<String>)>| {
6106                if seen.insert(lid.clone()) {
6107                    out.push((lid, source_id));
6108                }
6109            };
6110
6111        // Seed source A — edge-fact endpoints (primary). Both endpoints of each
6112        // matched, temporally-live, non-fallback edge are candidate seeds, tagged with
6113        // the edge's `source_id` provenance. `search_index_edges` may be absent on very
6114        // old DBs (< step-14) — degrade to no edge seeds rather than error.
6115        if let Ok(mut edge_seed_stmt) = tx.prepare(
6116            "SELECT ce.from_id, ce.to_id, ce.source_id \
6117             FROM search_index_edges sei \
6118             JOIN canonical_edges ce ON ce.write_cursor = sei.write_cursor \
6119             WHERE search_index_edges MATCH ?1 \
6120               AND ce.superseded_at IS NULL \
6121               AND (ce.t_invalid IS NULL OR datetime(ce.t_invalid) > datetime('now')) \
6122               AND (ce.temporal_fallback IS NULL OR ce.temporal_fallback = 0) \
6123             ORDER BY bm25(search_index_edges), sei.write_cursor \
6124             LIMIT ?2",
6125        ) {
6126            let rows = edge_seed_stmt.query_map(
6127                rusqlite::params![match_expression, SEED_FTS_N as i64],
6128                |row| {
6129                    Ok((
6130                        row.get::<_, String>(0)?,
6131                        row.get::<_, String>(1)?,
6132                        row.get::<_, Option<String>>(2)?,
6133                    ))
6134                },
6135            )?;
6136            for triple in rows {
6137                let (from_id, to_id, source_id) = triple?;
6138                push_candidate(
6139                    from_id,
6140                    source_id.clone(),
6141                    &mut seen_candidates,
6142                    &mut candidate_seeds,
6143                );
6144                push_candidate(to_id, source_id, &mut seen_candidates, &mut candidate_seeds);
6145            }
6146        }
6147
6148        // Seed source B — entity-node FTS (isolated / strongly-named entities).
6149        // `logical_id IS NOT NULL` structurally excludes doc nodes (the bug surface).
6150        // Provenance = the node's own `source_id` (the session it was extracted from).
6151        {
6152            let mut node_seed_stmt = tx.prepare(
6153                "SELECT cn.logical_id, cn.source_id \
6154                 FROM search_index si \
6155                 JOIN canonical_nodes cn ON cn.write_cursor = si.write_cursor \
6156                 WHERE search_index MATCH ?1 \
6157                   AND cn.superseded_at IS NULL \
6158                   AND cn.logical_id IS NOT NULL \
6159                 ORDER BY bm25(search_index), si.write_cursor \
6160                 LIMIT ?2",
6161            )?;
6162            let rows = node_seed_stmt
6163                .query_map(rusqlite::params![match_expression, SEED_FTS_N as i64], |row| {
6164                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
6165                })?;
6166            for pair in rows {
6167                let (lid, source_id) = pair?;
6168                push_candidate(lid, source_id, &mut seen_candidates, &mut candidate_seeds);
6169            }
6170        }
6171
6172        // Resolve + emit: a seed is `resolved` only if an ACTIVE canonical_node carries
6173        // that logical_id (dangling edge endpoints count considered-not-resolved). A
6174        // resolved seed is BOTH a BFS root AND emitted as a graph-arm candidate (depth
6175        // 0, hop_score 1.0) — so an edge-only query match surfaces the connected ENTITY
6176        // nodes, not just the fact body (codex §9 [P2]). Seeds whose body is already in
6177        // the two-arm result are skipped; the cap is respected.
6178        let mut active_stmt = tx.prepare(
6179            "SELECT kind, body, write_cursor FROM canonical_nodes \
6180             WHERE logical_id = ?1 AND superseded_at IS NULL LIMIT 1",
6181        )?;
6182        for (lid, source_id) in candidate_seeds {
6183            stats.seeds_considered += 1;
6184            let row: Option<(String, String, i64)> = active_stmt
6185                .query_row(rusqlite::params![&lid], |r| {
6186                    Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, i64>(2)?))
6187                })
6188                .optional()?;
6189            if let Some((kind, body, write_cursor)) = row {
6190                stats.seeds_resolved += 1;
6191                if visited.insert(lid.clone()) {
6192                    frontier.push_back((lid, 0));
6193                    if !seed_bodies.contains(body.as_str()) && candidates.len() < cap {
6194                        // depth-0 hop_score = 1.0/(1.0+0) = 1.0; synthesized penalty for
6195                        // 'unknown' kind (mirrors the Phase-2 neighbor scoring).
6196                        let score = if kind == "unknown" { SYNTHESIZED_PENALTY } else { 1.0 };
6197                        candidates.push(SearchHit {
6198                            id: write_cursor as u64,
6199                            kind,
6200                            body,
6201                            score,
6202                            branch: SoftFallbackBranch::GraphArm,
6203                            source_id,
6204                            ce_score: None,
6205                        });
6206                    }
6207                }
6208            }
6209        }
6210    }
6211    stats.frontier_nonempty = !frontier.is_empty();
6212
6213    // Phase 2: BFS over canonical_edges (temporal filter). `candidates` already
6214    // holds the depth-0 emitted seeds; BFS appends the reachable neighbors.
6215    // Both statements are prepared ONCE outside the loops — re-preparing inside
6216    // would issue O(frontier_size × neighbors) sqlite3_prepare_v2 calls.
6217    let mut edge_stmt = tx.prepare(
6218        // G0 Phase-2 (BLOCK-2): carry the traversed edge's `source_id` so a
6219        // graph-reached neighbor can resolve back to the session it was extracted
6220        // from. `ORDER BY e.write_cursor` makes the traversal deterministic: when
6221        // several active edges connect this node to the SAME neighbor with
6222        // different `source_id`s, the earliest-written edge wins the `visited`
6223        // dedup, so the carried provenance is stable (not SQLite-order-dependent).
6224        // (codex §9 [P2]; the design §B already rejected the memo's arbitrary
6225        // `LIMIT 1` lookup for the same reason.)
6226        "SELECT e.from_id, e.to_id, e.source_id \
6227         FROM canonical_edges e \
6228         WHERE (e.from_id = ?1 OR e.to_id = ?1) \
6229           AND e.superseded_at IS NULL \
6230           AND (e.t_invalid IS NULL OR datetime(e.t_invalid) > datetime('now')) \
6231           AND (e.temporal_fallback IS NULL OR e.temporal_fallback = 0) \
6232         ORDER BY e.write_cursor \
6233         LIMIT 64",
6234    )?;
6235    // Fetch write_cursor alongside kind+body so graph-arm hits carry a real id
6236    // for apply_recency_reweight (id=0 would force min_id=0 and distort span).
6237    let mut body_stmt = tx.prepare(
6238        "SELECT kind, body, write_cursor FROM canonical_nodes \
6239         WHERE logical_id = ?1 AND superseded_at IS NULL \
6240         LIMIT 1",
6241    )?;
6242
6243    while let Some((lid, depth)) = frontier.pop_front() {
6244        if candidates.len() >= cap {
6245            break;
6246        }
6247        if depth >= max_depth {
6248            continue;
6249        }
6250
6251        // Fetch temporal-live neighbors via edges, each paired with the
6252        // traversing edge's `source_id` (BLOCK-2 provenance carry).
6253        let neighbors: Vec<(String, Option<String>)> = {
6254            let rows = edge_stmt.query_map([&lid], |row| {
6255                Ok((
6256                    row.get::<_, String>(0)?,
6257                    row.get::<_, String>(1)?,
6258                    row.get::<_, Option<String>>(2)?,
6259                ))
6260            })?;
6261            rows.flatten()
6262                .map(|(from_id, to_id, source_id)| {
6263                    let neighbor = if from_id == lid { to_id } else { from_id };
6264                    (neighbor, source_id)
6265                })
6266                .collect()
6267        };
6268
6269        for (neighbor, edge_source_id) in neighbors {
6270            if visited.contains(&neighbor) {
6271                continue;
6272            }
6273            visited.insert(neighbor.clone());
6274
6275            // Fetch neighbor body + write_cursor from canonical_nodes.
6276            let row: Option<(String, String, i64)> = body_stmt
6277                .query_row([&neighbor], |row| {
6278                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?))
6279                })
6280                .optional()?;
6281
6282            if let Some((kind, body, write_cursor)) = row {
6283                // Skip bodies already covered by the two-arm result.
6284                if !seed_bodies.contains(body.as_str()) {
6285                    let hop_score = 1.0 / (1.0 + (depth + 1) as f64);
6286                    let score =
6287                        if kind == "unknown" { hop_score * SYNTHESIZED_PENALTY } else { hop_score };
6288                    candidates.push(SearchHit {
6289                        id: write_cursor as u64,
6290                        kind,
6291                        body,
6292                        score,
6293                        branch: SoftFallbackBranch::GraphArm,
6294                        // BLOCK-2: the session this fact-edge was extracted from.
6295                        source_id: edge_source_id.clone(),
6296                        ce_score: None,
6297                    });
6298                    if candidates.len() >= cap {
6299                        break;
6300                    }
6301                }
6302                // Always push neighbor to frontier for further BFS expansion.
6303                frontier.push_back((neighbor, depth + 1));
6304            }
6305        }
6306    }
6307
6308    drop(edge_stmt);
6309    drop(body_stmt);
6310    tx.commit()?;
6311    stats.graph_candidates_emitted = candidates.len() as u32;
6312    Ok((candidates, stats))
6313}
6314
6315/// Slice 30 (G3) — the ~1M cap on a single op-store read-back page. The public
6316/// `read.collection` / `read.mutations` LIMIT is `min(caller_limit, this)`, so
6317/// no API path can issue an unbounded SELECT. Cursor/limit hardening under a
6318/// genuine ~1M-row append-only log is reserved-gap Slice 32.
6319const READ_COLLECTION_MAX_LIMIT: usize = 1_000_000;
6320
6321/// Slice 30 (G2) — active-only point lookup by `logical_id` on the DEFERRED
6322/// reader tx (mirrors `read_search_in_tx`'s snapshot-stable BEGIN DEFERRED). One
6323/// returned slot per requested id, in REQUEST ORDER; `None` where no ACTIVE row
6324/// (`superseded_at IS NULL`) carries that id. Mirrors the `:4170` canonical
6325/// projection columns + `logical_id`; superseded versions are never returned.
6326fn read_get_by_id_in_tx(
6327    reader: &mut Connection,
6328    logical_ids: &[String],
6329) -> rusqlite::Result<Vec<Option<NodeRecord>>> {
6330    if logical_ids.is_empty() {
6331        return Ok(Vec::new());
6332    }
6333    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
6334    // De-duplicate the requested ids for the IN(...) probe, then re-expand into
6335    // request order (a repeated id echoes the same active row).
6336    let mut found: HashMap<String, NodeRecord> = HashMap::new();
6337    {
6338        let unique: Vec<&String> = {
6339            let mut seen = std::collections::HashSet::new();
6340            logical_ids.iter().filter(|id| seen.insert((*id).clone())).collect()
6341        };
6342        let placeholders = std::iter::repeat_n("?", unique.len()).collect::<Vec<_>>().join(", ");
6343        let sql = format!(
6344            "SELECT logical_id, kind, body, write_cursor
6345             FROM canonical_nodes
6346             WHERE logical_id IN ({placeholders}) AND superseded_at IS NULL"
6347        );
6348        let mut statement = tx.prepare(&sql)?;
6349        let params = rusqlite::params_from_iter(unique.iter().map(|s| s.as_str()));
6350        let rows = statement.query_map(params, |row| {
6351            let logical_id: String = row.get(0)?;
6352            Ok(NodeRecord {
6353                logical_id,
6354                kind: row.get(1)?,
6355                body: row.get(2)?,
6356                write_cursor: row.get::<_, i64>(3)? as u64,
6357            })
6358        })?;
6359        for row in rows {
6360            let record = row?;
6361            found.insert(record.logical_id.clone(), record);
6362        }
6363    }
6364    // tx is read-only; dropping it rolls back the (empty) transaction.
6365    let out = logical_ids.iter().map(|id| found.get(id).cloned()).collect();
6366    Ok(out)
6367}
6368
6369/// Slice 30 (G3) — paginated op-store read-back over `operational_mutations` for
6370/// one `collection`, `ORDER BY id`, on the DEFERRED reader tx. The effective SQL
6371/// LIMIT is `min(limit, READ_COLLECTION_MAX_LIMIT)`; a caller `limit == 0`
6372/// returns an empty `Vec` without a SELECT. The after-id cursor (`id > ?`,
6373/// default 0) excludes the boundary row. The `_for_test` SELECTs
6374/// (`lib.rs` op-store probes) are a shape oracle only — this is a new statement.
6375///
6376/// Slice 33 (G3 / F4-READ) — hardened under a genuine large multi-collection log:
6377/// the SELECT rides the step-13 `operational_mutations(collection_name, id)`
6378/// index (`SEARCH … USING INDEX …(collection_name=? AND id>?)`), so the per-page
6379/// cost is O(page) — the leading `collection_name` equality fixes the prefix and
6380/// the trailing `id` serves both the cursor range and `ORDER BY id` with no temp
6381/// B-tree. The cursor is normalized with `.max(0)` so a negative `after_id` is
6382/// explicitly clamped to the start of the log (ids are ≥ 1) and is never confused
6383/// with a row id; `after_id` past the end and unknown collections yield empty
6384/// pages.
6385fn read_collection_in_tx(
6386    reader: &mut Connection,
6387    collection: &str,
6388    after_id: Option<i64>,
6389    limit: usize,
6390) -> rusqlite::Result<Vec<OpStoreRow>> {
6391    if limit == 0 {
6392        return Ok(Vec::new());
6393    }
6394    let clamped = limit.min(READ_COLLECTION_MAX_LIMIT) as i64;
6395    // Normalize the cursor: a negative after_id is clamped to the start of the
6396    // log. `operational_mutations.id` is autoincrement (≥ 1), so `id > 0` is the
6397    // full log; clamping removes the "is a negative cursor a sentinel or a row
6398    // id?" ambiguity without changing happy-path semantics.
6399    let after = after_id.unwrap_or(0).max(0);
6400    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
6401    let mut statement = tx.prepare(
6402        "SELECT id, collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
6403         FROM operational_mutations
6404         WHERE collection_name = ?1 AND id > ?2
6405         ORDER BY id
6406         LIMIT ?3",
6407    )?;
6408    let rows = statement.query_map(params![collection, after, clamped], |row| {
6409        Ok(OpStoreRow {
6410            id: row.get(0)?,
6411            collection: row.get(1)?,
6412            record_key: row.get(2)?,
6413            op_kind: row.get(3)?,
6414            payload: row.get(4)?,
6415            schema_id: row.get(5)?,
6416            write_cursor: row.get::<_, i64>(6)? as u64,
6417        })
6418    })?;
6419    let mut out = Vec::new();
6420    for row in rows {
6421        out.push(row?);
6422    }
6423    Ok(out)
6424}
6425
6426/// Slice 35 (G4) — execute `read.list` inside a DEFERRED reader transaction.
6427///
6428/// Builds parameterized SQL: `kind = ?1 AND superseded_at IS NULL [AND
6429/// json_extract(body, '$.field') <op> ?N ...]` — injection-safe because:
6430///   (a) `kind` is `?1` (bound parameter);
6431///   (b) each predicate value is a bound `?N` parameter;
6432///   (c) the json_extract path is the ALLOWLIST ENTRY (a server-side constant
6433///       validated at `Predicate` construction time), never the raw caller string;
6434///   (d) `ComparisonOp` compiles to a server-side literal operator string from a
6435///       closed enum, not a caller-supplied string.
6436fn read_list_in_tx(
6437    reader: &mut Connection,
6438    kind: &str,
6439    predicates: &[Predicate],
6440    limit: usize,
6441) -> rusqlite::Result<Vec<NodeRecord>> {
6442    if limit == 0 {
6443        return Ok(Vec::new());
6444    }
6445    // Build the SQL WHERE clauses for each predicate.
6446    // Parameters: ?1 = kind; ?2..?N = predicate values; limit is inlined.
6447    // `logical_id IS NOT NULL` is a SQL-level predicate so that LIMIT counts
6448    // only rows that can be represented as NodeRecord (which requires a non-null
6449    // String logical_id). Anonymous nodes (PreparedWrite::Node { logical_id: None })
6450    // cannot be included in NodeRecord results and are excluded before LIMIT.
6451    // When predicates are present we add `json_valid(body)` so rows with
6452    // non-JSON bodies are skipped rather than causing a `malformed JSON` error.
6453    let json_valid_guard = if predicates.is_empty() { "" } else { " AND json_valid(body)" };
6454    let mut sql = format!(
6455        "SELECT logical_id, kind, body, write_cursor \
6456         FROM canonical_nodes \
6457         WHERE kind = ?1 \
6458         AND superseded_at IS NULL \
6459         AND logical_id IS NOT NULL{json_valid_guard}"
6460    );
6461
6462    // Predicate params start at ?2.
6463    for (i, pred) in predicates.iter().enumerate() {
6464        let param_idx = i + 2; // ?1 is kind
6465        sql.push_str(" AND ");
6466        sql.push_str(&pred.to_sql_clause(param_idx));
6467    }
6468    sql.push_str(&format!(" LIMIT {limit}"));
6469
6470    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
6471    let mut statement = tx.prepare(&sql)?;
6472
6473    // Bind all parameters: [kind, predicate_values...]
6474    let mut params: Vec<rusqlite::types::Value> = Vec::with_capacity(1 + predicates.len());
6475    params.push(rusqlite::types::Value::Text(kind.to_string()));
6476    for pred in predicates {
6477        params.push(pred.bind_value());
6478    }
6479
6480    let rows = statement.query_map(rusqlite::params_from_iter(params.iter()), |row| {
6481        Ok(NodeRecord {
6482            logical_id: row.get(0)?,
6483            kind: row.get(1)?,
6484            body: row.get(2)?,
6485            write_cursor: row.get::<_, i64>(3)? as u64,
6486        })
6487    })?;
6488
6489    let mut out = Vec::new();
6490    for row in rows {
6491        out.push(row?);
6492    }
6493    Ok(out)
6494}
6495
6496// ---------------------------------------------------------------------------
6497// Slice 20 (G5/G6) — BFS graph-traversal helpers
6498// ---------------------------------------------------------------------------
6499
6500/// Hard cap on the number of nodes returned by a single `graph_neighbors` call.
6501/// Ported from v0.5.6 `MAX_TRAVERSAL_DEPTH` (applied as a LIMIT on the CTE and
6502/// the final SELECT). Defense-in-depth against unbounded traversal.
6503const GRAPH_NEIGHBORS_HARD_CAP: usize = 50;
6504
6505/// Build the BFS CTE SQL for the given `direction`.
6506///
6507/// Parameters (positional):
6508///   `?1` — root `logical_id`
6509///   `?2` — max_depth (`u32`, SDK-facing depth ceiling ≤ 3)
6510///
6511/// `datetime('now')` is inlined for the valid-time filter (no extra parameter).
6512/// `LIMIT {GRAPH_NEIGHBORS_HARD_CAP}` appears on both the CTE and the final SELECT.
6513fn build_bfs_sql(direction: TraversalDirection) -> String {
6514    let cap = GRAPH_NEIGHBORS_HARD_CAP;
6515    // cte_cap: the SQLite CTE LIMIT counts path-rows, not distinct nodes. In a
6516    // multigraph (multiple parallel edges between the same pair of nodes), the CTE
6517    // can contain duplicate-target rows before the final SELECT DISTINCT. A cap of
6518    // cap+1 would be exhausted by ~50 parallel edges to the same node, preventing
6519    // other neighbors from being discovered. Use cap*cap as a generous safety
6520    // ceiling that still bounds CTE growth for any realistic graph while allowing
6521    // the final SELECT LIMIT cap to be the authoritative distinct-node cap.
6522    let cte_cap = cap * cap;
6523    // Cycle guard uses char(30) (ASCII Record Separator, 0x1E) as delimiter instead
6524    // of comma, so logical_ids containing commas are handled correctly. char(30) is
6525    // a non-printable control character that callers cannot place in logical_id values
6526    // via normal text input.
6527    match direction {
6528        TraversalDirection::Outgoing => format!(
6529            "WITH RECURSIVE
6530  traversal(logical_id, depth, visited) AS (
6531    SELECT n.logical_id, 0, char(30) || n.logical_id || char(30)
6532    FROM canonical_nodes n
6533    WHERE n.logical_id = ?1 AND n.superseded_at IS NULL
6534    UNION ALL
6535    SELECT e.to_id, t.depth + 1, t.visited || e.to_id || char(30)
6536    FROM traversal t
6537    JOIN canonical_edges e ON e.from_id = t.logical_id
6538    JOIN canonical_nodes next_n ON next_n.logical_id = e.to_id
6539      AND next_n.superseded_at IS NULL
6540    WHERE t.depth < ?2
6541      AND e.superseded_at IS NULL
6542      AND (e.t_invalid IS NULL OR datetime(e.t_invalid) > datetime('now'))
6543      AND instr(t.visited, char(30) || e.to_id || char(30)) = 0
6544    LIMIT {cte_cap}
6545  )
6546SELECT DISTINCT n.logical_id, n.kind, n.body, n.write_cursor
6547FROM traversal tr
6548JOIN canonical_nodes n ON n.logical_id = tr.logical_id
6549WHERE n.superseded_at IS NULL
6550  AND tr.logical_id != ?1
6551LIMIT {cap}"
6552        ),
6553        TraversalDirection::Incoming => format!(
6554            "WITH RECURSIVE
6555  traversal(logical_id, depth, visited) AS (
6556    SELECT n.logical_id, 0, char(30) || n.logical_id || char(30)
6557    FROM canonical_nodes n
6558    WHERE n.logical_id = ?1 AND n.superseded_at IS NULL
6559    UNION ALL
6560    SELECT e.from_id, t.depth + 1, t.visited || e.from_id || char(30)
6561    FROM traversal t
6562    JOIN canonical_edges e ON e.to_id = t.logical_id
6563    JOIN canonical_nodes next_n ON next_n.logical_id = e.from_id
6564      AND next_n.superseded_at IS NULL
6565    WHERE t.depth < ?2
6566      AND e.superseded_at IS NULL
6567      AND (e.t_invalid IS NULL OR datetime(e.t_invalid) > datetime('now'))
6568      AND instr(t.visited, char(30) || e.from_id || char(30)) = 0
6569    LIMIT {cte_cap}
6570  )
6571SELECT DISTINCT n.logical_id, n.kind, n.body, n.write_cursor
6572FROM traversal tr
6573JOIN canonical_nodes n ON n.logical_id = tr.logical_id
6574WHERE n.superseded_at IS NULL
6575  AND tr.logical_id != ?1
6576LIMIT {cap}"
6577        ),
6578        TraversalDirection::Both => format!(
6579            "WITH RECURSIVE
6580  traversal(logical_id, depth, visited) AS (
6581    SELECT n.logical_id, 0, char(30) || n.logical_id || char(30)
6582    FROM canonical_nodes n
6583    WHERE n.logical_id = ?1 AND n.superseded_at IS NULL
6584    UNION ALL
6585    SELECT
6586      CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END,
6587      t.depth + 1,
6588      t.visited || CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END || char(30)
6589    FROM traversal t
6590    JOIN canonical_edges e ON (e.from_id = t.logical_id OR e.to_id = t.logical_id)
6591    JOIN canonical_nodes next_n
6592      ON next_n.logical_id = CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END
6593      AND next_n.superseded_at IS NULL
6594    WHERE t.depth < ?2
6595      AND e.superseded_at IS NULL
6596      AND (e.t_invalid IS NULL OR datetime(e.t_invalid) > datetime('now'))
6597      AND instr(t.visited,
6598            char(30) || CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END || char(30)) = 0
6599    LIMIT {cte_cap}
6600  )
6601SELECT DISTINCT n.logical_id, n.kind, n.body, n.write_cursor
6602FROM traversal tr
6603JOIN canonical_nodes n ON n.logical_id = tr.logical_id
6604WHERE n.superseded_at IS NULL
6605  AND tr.logical_id != ?1
6606LIMIT {cap}"
6607        ),
6608    }
6609}
6610
6611/// Build the BFS CTE SQL for `search_expand` — identical to `build_bfs_sql`
6612/// but the final SELECT uses `GROUP BY` + `MIN(tr.depth)` so that each
6613/// expanded node carries its actual BFS distance from the root.
6614///
6615/// Returns 5 columns: logical_id, kind, body, write_cursor, min_depth.
6616fn build_bfs_with_depth_sql() -> String {
6617    let cap = GRAPH_NEIGHBORS_HARD_CAP;
6618    let cte_cap = cap * cap; // same multigraph-safe headroom as build_bfs_sql
6619    format!(
6620        "WITH RECURSIVE
6621  traversal(logical_id, depth, visited) AS (
6622    SELECT n.logical_id, 0, char(30) || n.logical_id || char(30)
6623    FROM canonical_nodes n
6624    WHERE n.logical_id = ?1 AND n.superseded_at IS NULL
6625    UNION ALL
6626    SELECT
6627      CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END,
6628      t.depth + 1,
6629      t.visited || CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END || char(30)
6630    FROM traversal t
6631    JOIN canonical_edges e ON (e.from_id = t.logical_id OR e.to_id = t.logical_id)
6632    JOIN canonical_nodes next_n
6633      ON next_n.logical_id = CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END
6634      AND next_n.superseded_at IS NULL
6635    WHERE t.depth < ?2
6636      AND e.superseded_at IS NULL
6637      AND (e.t_invalid IS NULL OR datetime(e.t_invalid) > datetime('now'))
6638      AND instr(t.visited,
6639            char(30) || CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END || char(30)) = 0
6640    LIMIT {cte_cap}
6641  )
6642SELECT n.logical_id, n.kind, n.body, n.write_cursor, MIN(tr.depth) AS min_depth
6643FROM traversal tr
6644JOIN canonical_nodes n ON n.logical_id = tr.logical_id
6645WHERE n.superseded_at IS NULL
6646  AND tr.logical_id != ?1
6647GROUP BY n.logical_id
6648LIMIT {cap}"
6649    )
6650}
6651
6652/// Slice 20 (G5) — execute a bounded BFS on the DEFERRED reader transaction.
6653/// Called inside the reader worker loop.
6654fn graph_neighbors_in_tx(
6655    reader: &mut Connection,
6656    root_logical_id: &str,
6657    depth: u32,
6658    direction: TraversalDirection,
6659) -> rusqlite::Result<Vec<NodeRecord>> {
6660    let sql = build_bfs_sql(direction);
6661    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
6662    let depth_i64 = depth as i64;
6663    let mut statement = tx.prepare(&sql)?;
6664    let rows = statement.query_map(params![root_logical_id, depth_i64], |row| {
6665        Ok(NodeRecord {
6666            logical_id: row.get(0)?,
6667            kind: row.get(1)?,
6668            body: row.get(2)?,
6669            write_cursor: row.get::<_, i64>(3)? as u64,
6670        })
6671    })?;
6672    let mut out = Vec::new();
6673    for row in rows {
6674        out.push(row?);
6675    }
6676    Ok(out)
6677}
6678
6679/// Slice 20 (G6) — resolve search hit `write_cursor`s to `logical_id`s, run
6680/// BFS for each root, and merge into a [`SearchExpandResult`]. Called inside
6681/// the reader worker loop on the DEFERRED reader transaction.
6682fn search_expand_in_tx(
6683    reader: &mut Connection,
6684    search_hits: &[SearchHit],
6685    depth: u32,
6686) -> rusqlite::Result<SearchExpandResult> {
6687    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
6688
6689    // Step 1: resolve write_cursor → logical_id for each search hit.
6690    // Possible outcomes per hit:
6691    //   - None: no matching write_cursor in canonical_nodes (superseded) → drop.
6692    //   - Some(""):  row exists but logical_id IS NULL (anonymous node) or TextEdge hit
6693    //                → keep as valid search result, skip BFS expansion (empty sentinel).
6694    //   - Some(lid): active named node → keep; use as BFS root.
6695    let mut hit_logical_ids: Vec<Option<String>> = Vec::with_capacity(search_hits.len());
6696    {
6697        let mut node_stmt = tx.prepare(
6698            "SELECT logical_id FROM canonical_nodes
6699             WHERE write_cursor = ?1 AND superseded_at IS NULL
6700             LIMIT 1",
6701        )?;
6702        let mut edge_stmt = tx.prepare(
6703            "SELECT 1 FROM canonical_edges
6704             WHERE write_cursor = ?1 AND superseded_at IS NULL
6705             LIMIT 1",
6706        )?;
6707        for hit in search_hits {
6708            if hit.branch == SoftFallbackBranch::TextEdge {
6709                // Edge-body hit: verify the edge row is still active in THIS snapshot.
6710                // Stale edge hits (superseded between search and expansion) are dropped.
6711                let cursor_i64 = hit.id as i64;
6712                let active: Option<i32> =
6713                    edge_stmt.query_row([cursor_i64], |row| row.get(0)).optional()?;
6714                if active.is_some() {
6715                    hit_logical_ids.push(Some(String::new())); // sentinel: keep hit, skip BFS
6716                } else {
6717                    hit_logical_ids.push(None); // superseded edge: drop
6718                }
6719            } else {
6720                let cursor_i64 = hit.id as i64;
6721                // Returns Option<Option<String>>:
6722                //   None         → no row → superseded
6723                //   Some(None)   → row with NULL logical_id → anonymous node
6724                //   Some(Some(s)) → active named node
6725                let resolved = node_stmt
6726                    .query_row([cursor_i64], |row| row.get::<_, Option<String>>(0))
6727                    .optional()?;
6728                match resolved {
6729                    None => hit_logical_ids.push(None), // superseded: drop
6730                    Some(None) => hit_logical_ids.push(Some(String::new())), // anon: keep, skip BFS
6731                    Some(Some(lid)) => hit_logical_ids.push(Some(lid)), // named: keep + BFS root
6732                }
6733            }
6734        }
6735    }
6736
6737    // Build a set of logical_ids present in the search hits (for deduplication).
6738    // Empty-string sentinels (TextEdge hits) are excluded — they are not real node ids.
6739    let hit_id_set: std::collections::HashSet<String> =
6740        hit_logical_ids.iter().filter_map(|id| id.clone()).filter(|s| !s.is_empty()).collect();
6741
6742    // Step 2: for each root logical_id, run the BFS and collect expanded nodes.
6743    // A node already in `hit_id_set` is NOT added to `expanded`.
6744    // Use the depth-aware variant so each node reports its actual BFS distance.
6745    let bfs_sql = build_bfs_with_depth_sql();
6746    let depth_i64 = depth as i64;
6747    // nearest_hop: for each expanded logical_id track the minimum hop count
6748    // seen across ALL search-hit roots. A node reachable from multiple roots
6749    // at different depths must report the shortest distance (nearest root).
6750    let mut nearest_hop: std::collections::HashMap<String, (NodeRecord, u32)> =
6751        std::collections::HashMap::new();
6752
6753    if depth > 0 {
6754        let mut bfs_stmt = tx.prepare(&bfs_sql)?;
6755        for root_id in hit_logical_ids.iter().flatten().filter(|s| !s.is_empty()) {
6756            let neighbor_rows = bfs_stmt.query_map(params![root_id, depth_i64], |row| {
6757                let node = NodeRecord {
6758                    logical_id: row.get(0)?,
6759                    kind: row.get(1)?,
6760                    body: row.get(2)?,
6761                    write_cursor: row.get::<_, i64>(3)? as u64,
6762                };
6763                let min_depth: i64 = row.get(4)?;
6764                Ok((node, min_depth as u32))
6765            })?;
6766            for row_result in neighbor_rows {
6767                let (node, hop_count) = row_result?;
6768                if hit_id_set.contains(&node.logical_id) {
6769                    // Already a search hit — skip (search score takes priority).
6770                    continue;
6771                }
6772                nearest_hop
6773                    .entry(node.logical_id.clone())
6774                    .and_modify(|(_, prev_hop)| {
6775                        if hop_count < *prev_hop {
6776                            *prev_hop = hop_count;
6777                        }
6778                    })
6779                    .or_insert((node, hop_count));
6780            }
6781        }
6782    }
6783
6784    // Materialize expanded in insertion order (deterministic for tests).
6785    let mut expanded: Vec<(NodeRecord, u32)> = nearest_hop.into_values().collect();
6786    expanded.sort_by(|(a, _), (b, _)| a.logical_id.cmp(&b.logical_id));
6787
6788    // Filter search_hits to only include those whose write_cursor resolved to an
6789    // active logical_id in THIS snapshot. Hits that were superseded between the
6790    // search phase and the expansion phase (the two-snapshot window) are dropped
6791    // rather than returned with stale data.
6792    let resolved_hits: Vec<SearchHit> = search_hits
6793        .iter()
6794        .zip(hit_logical_ids.iter())
6795        .filter_map(|(hit, lid)| lid.as_ref().map(|_| hit.clone()))
6796        .collect();
6797
6798    // Build `all_logical_ids` = resolved search-hit logical_ids + expanded node ids.
6799    // Empty-string sentinels (TextEdge hits) are excluded — they are not real node ids.
6800    let mut all_logical_ids: Vec<String> =
6801        hit_logical_ids.into_iter().flatten().filter(|s| !s.is_empty()).collect();
6802    for (node, _) in &expanded {
6803        if !all_logical_ids.contains(&node.logical_id) {
6804            all_logical_ids.push(node.logical_id.clone());
6805        }
6806    }
6807
6808    Ok(SearchExpandResult { search_hits: resolved_hits, expanded, all_logical_ids })
6809}
6810
6811/// Slice 20 test seam — run `EXPLAIN QUERY PLAN` on the BFS CTE SQL and return
6812/// the plan `detail` column (column index 3) for each row. Used by
6813/// `explain_plan_uses_indexes` to assert index usage.
6814fn explain_graph_neighbors_in_tx(
6815    reader: &mut Connection,
6816    root_logical_id: &str,
6817    depth: u32,
6818    direction: TraversalDirection,
6819) -> rusqlite::Result<Vec<String>> {
6820    let bfs_sql = build_bfs_sql(direction);
6821    let explain_sql = format!("EXPLAIN QUERY PLAN {bfs_sql}");
6822    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
6823    let depth_i64 = depth as i64;
6824    let mut statement = tx.prepare(&explain_sql)?;
6825    // EXPLAIN QUERY PLAN returns rows: (id, parent, notused, detail).
6826    // We collect the `detail` column (index 3).
6827    let rows =
6828        statement.query_map(params![root_logical_id, depth_i64], |row| row.get::<_, String>(3))?;
6829    let mut out = Vec::new();
6830    for row in rows {
6831        out.push(row?);
6832    }
6833    Ok(out)
6834}
6835
6836fn projection_dispatcher_loop(shared: Arc<ProjectionRuntimeShared>) {
6837    let connection = match open_runtime_connection(&shared.path) {
6838        Ok(connection) => connection,
6839        Err(_) => return,
6840    };
6841    loop {
6842        let in_flight = {
6843            let mut state = match shared.state.lock() {
6844                Ok(state) => state,
6845                Err(_) => return,
6846            };
6847            while !state.stopping
6848                && (!state.pending_scan
6849                    || state.frozen
6850                    || state.active_jobs + state.queued_jobs >= PROJECTION_INFLIGHT_LIMIT)
6851            {
6852                state = match shared.state_cvar.wait(state) {
6853                    Ok(state) => state,
6854                    Err(_) => return,
6855                };
6856            }
6857            if state.stopping {
6858                return;
6859            }
6860            state.pending_scan = false;
6861            state.in_flight.clone()
6862        };
6863
6864        // Fetch up to the in-flight budget in one SQL roundtrip and
6865        // enqueue them as a batch — previously this loop fetched ONE job
6866        // per cycle, which capped projection throughput at one row per
6867        // scanner/worker handshake regardless of how much work was queued
6868        // in canonical_nodes.
6869        let budget = {
6870            let state = match shared.state.lock() {
6871                Ok(state) => state,
6872                Err(_) => return,
6873            };
6874            PROJECTION_INFLIGHT_LIMIT.saturating_sub(state.active_jobs + state.queued_jobs)
6875        };
6876        let fetch_cap = budget.clamp(1, PROJECTION_SCAN_FETCH);
6877        match next_pending_projection_jobs(&connection, &in_flight, fetch_cap) {
6878            Ok(jobs) if !jobs.is_empty() => {
6879                if let Ok(mut state) = shared.state.lock() {
6880                    state.queued_jobs = state.queued_jobs.saturating_add(jobs.len());
6881                    for job in &jobs {
6882                        state.in_flight.insert(job.cursor);
6883                    }
6884                    state.pending_scan = true;
6885                    shared.state_cvar.notify_all();
6886                }
6887                if let Ok(mut queue) = shared.queue.lock() {
6888                    for job in jobs {
6889                        queue.push_back(job);
6890                    }
6891                    shared.queue_cvar.notify_all();
6892                }
6893            }
6894            Ok(_) => {}
6895            Err(_) => {
6896                if let Ok(mut state) = shared.state.lock() {
6897                    state.pending_scan = false;
6898                    shared.state_cvar.notify_all();
6899                }
6900            }
6901        }
6902    }
6903}
6904
6905fn projection_worker_loop(shared: Arc<ProjectionRuntimeShared>) {
6906    let mut connection = match open_runtime_connection(&shared.path) {
6907        Ok(connection) => connection,
6908        Err(_) => return,
6909    };
6910    if ensure_vector_partition(&mut connection, shared.embedder_identity.dimension).is_err() {
6911        return;
6912    }
6913    loop {
6914        let jobs = {
6915            let mut queue = match shared.queue.lock() {
6916                Ok(queue) => queue,
6917                Err(_) => return,
6918            };
6919            loop {
6920                let stopping = shared.state.lock().map(|state| state.stopping).unwrap_or(true);
6921                if stopping && queue.is_empty() {
6922                    return;
6923                }
6924                if let Some(job) = queue.pop_front() {
6925                    let mut jobs = vec![job];
6926                    while jobs.len() < PROJECTION_COMMIT_BATCH {
6927                        let Some(job) = queue.pop_front() else {
6928                            break;
6929                        };
6930                        jobs.push(job);
6931                    }
6932                    if let Ok(mut state) = shared.state.lock() {
6933                        state.queued_jobs = state.queued_jobs.saturating_sub(jobs.len());
6934                        state.active_jobs = state.active_jobs.saturating_add(jobs.len());
6935                        shared.state_cvar.notify_all();
6936                    }
6937                    break jobs;
6938                }
6939                queue = match shared.queue_cvar.wait(queue) {
6940                    Ok(queue) => queue,
6941                    Err(_) => return,
6942                };
6943            }
6944        };
6945
6946        // EU-5f — isolate worker faults. A panic inside `embed()` (or the
6947        // commit) must not skip the state cleanup below, or `active_jobs`
6948        // would stay elevated forever and `wait_for_idle` / `drain` would
6949        // wedge into `EngineError::Scheduler` (Finding A). Mirrors the
6950        // reader pool's `LiveGuard` panic-safety. The local commit tx rolls
6951        // back on unwind, leaving the connection clean for reuse.
6952        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
6953            run_projection_jobs(&shared, &mut connection, &jobs);
6954        }))
6955        .is_err();
6956        if panicked {
6957            commit_projection_panic_failures(&shared, &mut connection, &jobs);
6958        }
6959
6960        if let Ok(mut state) = shared.state.lock() {
6961            state.active_jobs = state.active_jobs.saturating_sub(jobs.len());
6962            for job in &jobs {
6963                state.in_flight.remove(&job.cursor);
6964            }
6965            if !state.stopping {
6966                state.pending_scan = true;
6967            }
6968            shared.state_cvar.notify_all();
6969        }
6970    }
6971}
6972
6973enum ProjectionOutcome {
6974    /// `blob` is the un-centered f32 BLOB persisted to
6975    /// `vector_default.embedding`. `bin_blob` is the (possibly centered)
6976    /// f32 BLOB fed to `vec_quantize_binary` for the sign-bit column.
6977    /// EU-5a2: `bin_blob == blob` unless the identity is MC-required
6978    /// AND a mean_vec is pinned.
6979    Success {
6980        cursor: u64,
6981        kind: String,
6982        blob: Vec<u8>,
6983        bin_blob: Vec<u8>,
6984    },
6985    Failure {
6986        cursor: u64,
6987        failure_code: &'static str,
6988    },
6989}
6990
6991fn run_projection_jobs(
6992    shared: &ProjectionRuntimeShared,
6993    connection: &mut Connection,
6994    jobs: &[ProjectionJob],
6995) {
6996    let outcomes = embed_projection_batch(shared, jobs);
6997    let _ = commit_projection_outcomes(connection, &outcomes, shared);
6998}
6999
7000/// Embed a whole commit-batch in ONE `embed_batch` call (amortizes per-call
7001/// overhead; saturates the GPU — minutes -> seconds on a full-corpus embed). The
7002/// batched path is the fast HAPPY path only; on ANY anomaly — no embedder, breaker
7003/// open, single job, batch timeout/failure, row-count or per-row dimension mismatch
7004/// — it falls back to the proven per-job [`run_projection_job`], which carries the
7005/// full retry + circuit-breaker + failure-isolation semantics. So batching can only
7006/// make the common case faster, never change correctness. A panic inside the batch
7007/// embed resume-unwinds exactly like the per-embed watchdog, so the worker's
7008/// batch-level `catch_unwind` records `ProjectionPanic` as before.
7009///
7010/// Batching is **opt-in** via `FATHOMDB_PROJECTION_BATCH=1` (`true`/`on` accepted).
7011/// It reshapes the PR-9 per-embed watchdog/breaker accounting into per-batch, so the
7012/// conservative DEFAULT keeps the proven per-job path — leaving every PR-9 safety
7013/// test (watchdog, serialization, circuit breaker) behaving exactly as before. The
7014/// eval GPU-embed run sets the env to get the batched-forward speedup (minutes ->
7015/// seconds), where the per-job fallback below still backs every error case.
7016fn projection_batch_enabled() -> bool {
7017    matches!(
7018        std::env::var("FATHOMDB_PROJECTION_BATCH").ok().as_deref(),
7019        Some("1") | Some("true") | Some("on")
7020    )
7021}
7022
7023fn embed_projection_batch(
7024    shared: &ProjectionRuntimeShared,
7025    jobs: &[ProjectionJob],
7026) -> Vec<ProjectionOutcome> {
7027    let per_job = || jobs.iter().map(|job| run_projection_job(shared, job)).collect();
7028
7029    let Some(embedder) = shared.embedder.as_ref() else {
7030        return per_job();
7031    };
7032    if jobs.len() < 2
7033        || shared.embed_circuit_open.load(Ordering::Relaxed)
7034        || !projection_batch_enabled()
7035    {
7036        return per_job();
7037    }
7038
7039    let bodies: Vec<String> = jobs.iter().map(|job| job.body.clone()).collect();
7040    let embed_timeout = Duration::from_millis(shared.embed_timeout_ms.load(Ordering::Relaxed));
7041    // Each row keeps its single-embed budget worst-case (batch <= COMMIT_BATCH=16).
7042    let batch_timeout = embed_timeout.saturating_mul(jobs.len() as u32);
7043
7044    let vectors = {
7045        // PR-9 — serialize the embedder call (ONE batched call at a time) and make
7046        // the breaker decision with the guard held (race-free vs other workers),
7047        // mirroring `run_projection_job`. The batch thread counts as one live embed.
7048        let _embed_permit =
7049            shared.embed_serialize.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
7050        let threshold = shared.embed_circuit_threshold.load(Ordering::Relaxed);
7051        if shared.embed_circuit_open.load(Ordering::Relaxed)
7052            || (threshold != 0 && shared.live_embed_threads.load(Ordering::Relaxed) >= threshold)
7053        {
7054            shared.embed_circuit_open.store(true, Ordering::Relaxed);
7055            return per_job();
7056        }
7057        match embed_batch_with_watchdog(
7058            embedder,
7059            &bodies,
7060            batch_timeout,
7061            &shared.live_embed_threads,
7062        ) {
7063            Ok(vectors) => vectors,
7064            // Timeout / failed / disconnected -> the per-job path retries each row
7065            // and engages the breaker exactly as before.
7066            Err(_) => return per_job(),
7067        }
7068    };
7069
7070    if vectors.len() != jobs.len() {
7071        return per_job();
7072    }
7073    let mut outcomes = Vec::with_capacity(jobs.len());
7074    for (job, vector) in jobs.iter().zip(vectors) {
7075        if u32::try_from(vector.len()).unwrap_or(u32::MAX) != shared.embedder_identity.dimension {
7076            // A row came back wrong-dim: fall back per-job for the whole batch
7077            // (rare; keeps the dimension-mismatch failure path identical).
7078            return per_job();
7079        }
7080        // Mirror run_projection_job's post-embed step exactly: persisted f32 BLOB is
7081        // un-centered; centering for the binary column is finalized in
7082        // commit_projection_outcomes (so bin_blob == blob here).
7083        let blob = encode_vector_blob(&vector);
7084        let bin_blob = blob.clone();
7085        outcomes.push(ProjectionOutcome::Success {
7086            cursor: job.cursor,
7087            kind: job.kind.clone(),
7088            blob,
7089            bin_blob,
7090        });
7091    }
7092    outcomes
7093}
7094
7095/// EU-5f — record every job in a panicked batch as a terminal projection
7096/// failure so the scheduler does not re-enqueue and re-panic on the same
7097/// cursors. Best-effort; runs after the worker caught a panic.
7098fn commit_projection_panic_failures(
7099    shared: &ProjectionRuntimeShared,
7100    connection: &mut Connection,
7101    jobs: &[ProjectionJob],
7102) {
7103    let outcomes: Vec<ProjectionOutcome> = jobs
7104        .iter()
7105        .map(|job| ProjectionOutcome::Failure {
7106            cursor: job.cursor,
7107            failure_code: "ProjectionPanic",
7108        })
7109        .collect();
7110    let _ = commit_projection_outcomes(connection, &outcomes, shared);
7111}
7112
7113/// PR-9 — ADR-0.6.0-embedder-protocol **Invariant 5**: run one `embed()`
7114/// under a per-call deadline. A hung (non-panicking) embed would otherwise
7115/// park a projection worker forever — the EU-5f `catch_unwind` only catches
7116/// *panics*. On timeout we return `RuntimeEmbedderError::Timeout`, which the
7117/// caller's existing retry/failure path already handles.
7118///
7119/// Cancellation follows Invariant 5 exactly: the embed runs on a detached
7120/// thread that is allowed to *finish + discard* its result — never aborted
7121/// mid-call (there is no safe thread-cancel API). The caller (the projection
7122/// worker) holds `embed_serialize` across this call, but DROPS it the moment
7123/// this returns — including on timeout — so the abandoned detached thread
7124/// runs lock-free and a hung embed can neither hold the serialization guard
7125/// forever nor deadlock the pool. (The commit happens later, outside this
7126/// call, under the separate `commit_gate`.)
7127///
7128/// Panic-transparent: if `embed()` panics, the panic payload is captured on
7129/// the watchdog thread and resumed on the worker thread, so the existing
7130/// batch-level `catch_unwind` records `ProjectionPanic` exactly as before.
7131///
7132/// `live` counts embed threads currently alive: incremented before the spawn
7133/// and decremented by the thread when it finishes (even if its result was
7134/// abandoned on timeout). The caller reads it to bound the abandoned-thread
7135/// leak via the circuit breaker.
7136fn embed_with_watchdog(
7137    embedder: &Arc<dyn Embedder>,
7138    body: &str,
7139    timeout: Duration,
7140    live: &Arc<AtomicU64>,
7141) -> Result<Vec<f32>, RuntimeEmbedderError> {
7142    let (tx, rx) = mpsc::channel();
7143    let embedder = Arc::clone(embedder);
7144    let body = body.to_string();
7145    // Count this embed thread as live before spawning; the thread decrements
7146    // when it finishes, whether or not its result is still wanted.
7147    live.fetch_add(1, Ordering::Relaxed);
7148    let live_thread = Arc::clone(live);
7149    thread::spawn(move || {
7150        let outcome =
7151            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| embedder.embed(&body)));
7152        // The receiver may already be gone (this call timed out): an async
7153        // channel send never blocks, and a send to a dropped receiver is a
7154        // no-op error we deliberately ignore — the result is discarded.
7155        let _ = tx.send(outcome);
7156        live_thread.fetch_sub(1, Ordering::Relaxed);
7157    });
7158    match rx.recv_timeout(timeout) {
7159        Ok(Ok(result)) => result,
7160        Ok(Err(panic_payload)) => std::panic::resume_unwind(panic_payload),
7161        Err(mpsc::RecvTimeoutError::Timeout) => Err(RuntimeEmbedderError::Timeout),
7162        // The watchdog thread dropped its sender without sending — should not
7163        // happen (panics are captured above), but treat as a failed embed so
7164        // the retry/failure path engages rather than silently succeeding.
7165        Err(mpsc::RecvTimeoutError::Disconnected) => Err(RuntimeEmbedderError::Failed {
7166            message: "embed watchdog thread dropped its result channel".to_string(),
7167        }),
7168    }
7169}
7170
7171/// Batch sibling of [`embed_with_watchdog`]: run ONE `embed_batch` on a detached,
7172/// timeout-bounded thread. Same Invariant-5 cancellation contract (the thread is
7173/// allowed to finish + discard on timeout, never aborted mid-call), same
7174/// panic-transparency (a panic is resumed on the caller so the worker's batch-level
7175/// `catch_unwind` records `ProjectionPanic`), same `live` accounting (one batch
7176/// thread = one live embed, bounding the abandoned-thread leak via the breaker).
7177fn embed_batch_with_watchdog(
7178    embedder: &Arc<dyn Embedder>,
7179    bodies: &[String],
7180    timeout: Duration,
7181    live: &Arc<AtomicU64>,
7182) -> Result<Vec<Vec<f32>>, RuntimeEmbedderError> {
7183    let (tx, rx) = mpsc::channel();
7184    let embedder = Arc::clone(embedder);
7185    let bodies = bodies.to_vec();
7186    live.fetch_add(1, Ordering::Relaxed);
7187    let live_thread = Arc::clone(live);
7188    thread::spawn(move || {
7189        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
7190            let refs: Vec<&str> = bodies.iter().map(String::as_str).collect();
7191            embedder.embed_batch(&refs)
7192        }));
7193        let _ = tx.send(outcome);
7194        live_thread.fetch_sub(1, Ordering::Relaxed);
7195    });
7196    match rx.recv_timeout(timeout) {
7197        Ok(Ok(result)) => result,
7198        Ok(Err(panic_payload)) => std::panic::resume_unwind(panic_payload),
7199        Err(mpsc::RecvTimeoutError::Timeout) => Err(RuntimeEmbedderError::Timeout),
7200        Err(mpsc::RecvTimeoutError::Disconnected) => Err(RuntimeEmbedderError::Failed {
7201            message: "embed batch watchdog thread dropped its result channel".to_string(),
7202        }),
7203    }
7204}
7205
7206fn run_projection_job(shared: &ProjectionRuntimeShared, job: &ProjectionJob) -> ProjectionOutcome {
7207    // PR-9 — embed circuit breaker (see `embed_circuit_open`). Once abandoned
7208    // (timed-out) embed threads have piled up to the threshold the embedder is
7209    // treated as broken; fail subsequent jobs fast WITHOUT attempting an embed,
7210    // so a wedged embedder cannot keep leaking abandoned watchdog threads. This
7211    // entry check is the fast path; the latch decision itself is made under the
7212    // embed guard below (race-free against other workers).
7213    if shared.embed_circuit_open.load(Ordering::Relaxed) {
7214        return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: "EmbedderError" };
7215    }
7216    let delays = shared.retry_delays_ms.lock().map(|delays| delays.clone()).unwrap_or_default();
7217    let mut last_code = "EmbedderError";
7218    for (attempt, delay_ms) in std::iter::once(0_u64).chain(delays.iter().copied()).enumerate() {
7219        if attempt > 0 {
7220            if shared.state.lock().map(|state| state.stopping).unwrap_or(true) {
7221                return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code };
7222            }
7223            thread::sleep(Duration::from_millis(delay_ms));
7224        }
7225        // PR-9 — re-check the breaker on every attempt, not just at entry:
7226        // another worker (or an earlier attempt of this job) may have latched
7227        // it while we were sleeping between retries. Bail before spawning yet
7228        // another timeout-bound watchdog thread, so the abandoned-thread leak
7229        // stays bounded even on the multi-retry path.
7230        if shared.embed_circuit_open.load(Ordering::Relaxed) {
7231            return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code };
7232        }
7233        // PR-9 / ADR-0.6.0 Invariant 5 — every embed runs under the per-call
7234        // watchdog deadline so a hung embed surfaces Timeout instead of
7235        // parking this worker forever.
7236        let embed_timeout = Duration::from_millis(shared.embed_timeout_ms.load(Ordering::Relaxed));
7237        let vector = match shared.embedder.as_ref() {
7238            Some(embedder) => {
7239                // PR-9 — serialize the embed call engine-side (see
7240                // `embed_serialize`): the shared embedder is invoked one call
7241                // at a time, for SAFETY with arbitrary caller-supplied
7242                // embedders (throughput is ~neutral on the candle default).
7243                // The guard is held across the watchdog call and released
7244                // here, so commit/IO below stays parallel and a timed-out
7245                // embed frees it. The guard owns no data; a panic-resumed
7246                // embed poisons it, so we recover the inner guard rather than
7247                // wedge the whole pool.
7248                let _embed_permit =
7249                    shared.embed_serialize.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
7250                // PR-9 — breaker decision, made WITH the guard held so it is
7251                // race-free against other workers: if abandoned embed threads
7252                // from earlier timeouts have piled up to the threshold, latch
7253                // the breaker and fail fast WITHOUT spawning another one. The
7254                // live count is checked here (also covers a breaker latched by
7255                // another worker while we were queued on the lock), bounding
7256                // the abandoned-thread leak to ~threshold regardless of whether
7257                // the embedder hangs always or only intermittently.
7258                let threshold = shared.embed_circuit_threshold.load(Ordering::Relaxed);
7259                if shared.embed_circuit_open.load(Ordering::Relaxed)
7260                    || (threshold != 0
7261                        && shared.live_embed_threads.load(Ordering::Relaxed) >= threshold)
7262                {
7263                    shared.embed_circuit_open.store(true, Ordering::Relaxed);
7264                    return ProjectionOutcome::Failure {
7265                        cursor: job.cursor,
7266                        failure_code: last_code,
7267                    };
7268                }
7269                match embed_with_watchdog(
7270                    embedder,
7271                    &job.body,
7272                    embed_timeout,
7273                    &shared.live_embed_threads,
7274                ) {
7275                    Ok(vector) => vector,
7276                    Err(RuntimeEmbedderError::Timeout) => {
7277                        // The embed thread is now abandoned (still counted in
7278                        // live_embed_threads until it returns); the breaker
7279                        // check above caps how many can accumulate.
7280                        last_code = "EmbedderError";
7281                        continue;
7282                    }
7283                    Err(RuntimeEmbedderError::Failed { .. }) => {
7284                        last_code = "EmbedderError";
7285                        continue;
7286                    }
7287                }
7288            }
7289            None => {
7290                last_code = "EmbedderNotConfiguredError";
7291                continue;
7292            }
7293        };
7294
7295        if u32::try_from(vector.len()).unwrap_or(u32::MAX) != shared.embedder_identity.dimension {
7296            last_code = "EmbedderDimensionMismatchError";
7297            continue;
7298        }
7299
7300        let blob = encode_vector_blob(&vector);
7301        // EU-5a2 mean-centering apply path (projection write side). The
7302        // f32 BLOB persisted is ALWAYS un-centered; `bin_blob` carries
7303        // the (possibly centered) f32 fed to `vec_quantize_binary`. The
7304        // centering decision is finalized in `commit_projection_outcomes`
7305        // where the writer connection is in-hand and the read of
7306        // `_fathomdb_embedder_profiles.mean_vec` is in the same tx as
7307        // the INSERT. NoopEmbedder (EU-5a2's only live identity) is not
7308        // MC-required, so `bin_blob == blob` throughout EU-5a2.
7309        let bin_blob = blob.clone();
7310        return ProjectionOutcome::Success {
7311            cursor: job.cursor,
7312            kind: job.kind.clone(),
7313            blob,
7314            bin_blob,
7315        };
7316    }
7317
7318    ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code }
7319}
7320
7321fn next_pending_projection_jobs(
7322    connection: &Connection,
7323    in_flight: &BTreeSet<u64>,
7324    max_jobs: usize,
7325) -> rusqlite::Result<Vec<ProjectionJob>> {
7326    if max_jobs == 0 {
7327        return Ok(Vec::new());
7328    }
7329    let cursor = load_projection_cursor(connection)?;
7330    // Over-fetch by `in_flight.len()` so the post-filter still returns
7331    // up to `max_jobs` after skipping cursors already in-flight.
7332    let sql_limit = max_jobs.saturating_add(in_flight.len()).min(256);
7333    // G11 (Slice 15) — UNION extends the projection queue to include edge bodies.
7334    // Edge bodies use kind `'edge_fact'` so `resolve_source_type` maps them to
7335    // `source_type = 'edge_fact'` in `vector_default` (partition correctness).
7336    // The UNION is ordered by write_cursor so projection proceeds in
7337    // insertion order across nodes and edges.
7338    let sql = format!(
7339        "SELECT write_cursor, kind, body FROM (
7340             SELECT canonical_nodes.write_cursor, canonical_nodes.kind, canonical_nodes.body
7341             FROM canonical_nodes
7342             JOIN _fathomdb_vector_kinds
7343               ON _fathomdb_vector_kinds.kind = canonical_nodes.kind
7344             LEFT JOIN _fathomdb_projection_terminal
7345               ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
7346             WHERE canonical_nodes.write_cursor > ?1
7347               AND _fathomdb_projection_terminal.write_cursor IS NULL
7348
7349             UNION ALL
7350
7351             SELECT canonical_edges.write_cursor, 'edge_fact', canonical_edges.body
7352             FROM canonical_edges
7353             JOIN _fathomdb_vector_kinds
7354               ON _fathomdb_vector_kinds.kind = 'edge_fact'
7355             LEFT JOIN _fathomdb_projection_terminal
7356               ON _fathomdb_projection_terminal.write_cursor = canonical_edges.write_cursor
7357             WHERE canonical_edges.write_cursor > ?1
7358               AND canonical_edges.body IS NOT NULL
7359               AND canonical_edges.superseded_at IS NULL
7360               AND _fathomdb_projection_terminal.write_cursor IS NULL
7361         ) ORDER BY write_cursor
7362         LIMIT {sql_limit}"
7363    );
7364    let mut statement = connection.prepare_cached(&sql)?;
7365    let rows = statement.query_map([cursor], |row| {
7366        Ok(ProjectionJob { cursor: row.get(0)?, kind: row.get(1)?, body: row.get(2)? })
7367    })?;
7368    let mut jobs = Vec::with_capacity(max_jobs);
7369    for row in rows {
7370        let job = row?;
7371        if in_flight.contains(&job.cursor) {
7372            continue;
7373        }
7374        jobs.push(job);
7375        if jobs.len() >= max_jobs {
7376            break;
7377        }
7378    }
7379    Ok(jobs)
7380}
7381
7382fn database_has_pending_projection_work(path: &Path) -> rusqlite::Result<bool> {
7383    let connection = open_runtime_connection(path)?;
7384    let cursor = load_projection_cursor(&connection)?;
7385    // Check canonical_nodes for un-projected work.
7386    let has_node_work: bool = connection
7387        .query_row(
7388            "SELECT 1
7389             FROM canonical_nodes
7390             JOIN _fathomdb_vector_kinds ON _fathomdb_vector_kinds.kind = canonical_nodes.kind
7391             LEFT JOIN _fathomdb_projection_terminal
7392               ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
7393             WHERE canonical_nodes.write_cursor > ?1
7394               AND _fathomdb_projection_terminal.write_cursor IS NULL
7395             LIMIT 1",
7396            [cursor],
7397            |_row| Ok(true),
7398        )
7399        .or_else(|err| match err {
7400            rusqlite::Error::QueryReturnedNoRows => Ok(false),
7401            _ => Err(err),
7402        })?;
7403    if has_node_work {
7404        return Ok(true);
7405    }
7406    // G11 (Slice 15) fix-1 [P2] — also check canonical_edges for edge bodies
7407    // that were not projected before the engine closed. Without this check,
7408    // drain() returns idle while edge vectors remain unembedded on reopen.
7409    // fix-31 [P2]: exclude superseded edges from the pending check so the
7410    // scheduler does not pick up stale tombstoned rows as projection work.
7411    connection
7412        .query_row(
7413            "SELECT 1
7414             FROM canonical_edges ce
7415             LEFT JOIN _fathomdb_projection_terminal pt
7416               ON pt.write_cursor = ce.write_cursor
7417             WHERE ce.body IS NOT NULL
7418               AND ce.superseded_at IS NULL
7419               AND pt.write_cursor IS NULL
7420             LIMIT 1",
7421            [],
7422            |_row| Ok(true),
7423        )
7424        .or_else(|err| match err {
7425            rusqlite::Error::QueryReturnedNoRows => Ok(false),
7426            _ => Err(err),
7427        })
7428}
7429
7430struct CanonicalNodeRow {
7431    cursor: u64,
7432    kind: String,
7433    body: String,
7434}
7435
7436/// 0.8.0 Slice 5 (G1) — re-tokenize `search_index` from the canonical source
7437/// rows after the step-11 tokenizer-default upgrade drops + recreates the FTS5
7438/// virtual table. Projection-only: it reads `canonical_nodes` (the source of
7439/// truth, untouched) and rewrites the FTS shadow; it performs **no**
7440/// source-record migration. Every canonical node already carries an FTS row at
7441/// write time (the projection-time INSERT is unconditional), so reinserting
7442/// every node exactly reproduces the prior index content under the new
7443/// tokenizer. Runs in a single transaction on the writer connection before
7444/// readers spawn.
7445///
7446/// Crash-retryable (fix-1): the reindex and its durable completion marker
7447/// (`SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY` in `_fathomdb_open_state`)
7448/// commit together in ONE `BEGIN IMMEDIATE…COMMIT`. A crash before the commit
7449/// rolls both back, leaving no marker; the next open re-runs. A crash after
7450/// the commit finds the marker present and skips. Idempotent.
7451fn reproject_search_index_after_tokenizer_upgrade(connection: &Connection) -> rusqlite::Result<()> {
7452    let rows = canonical_node_rows(connection)?;
7453    connection.execute_batch("BEGIN IMMEDIATE")?;
7454    let result = (|| {
7455        connection.execute("DELETE FROM search_index", [])?;
7456        {
7457            let mut statement = connection
7458                .prepare("INSERT INTO search_index(body, kind, write_cursor) VALUES(?1, ?2, ?3)")?;
7459            for row in &rows {
7460                statement.execute(params![row.body, row.kind, row.cursor])?;
7461            }
7462        }
7463        connection.execute(
7464            "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
7465             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
7466            params![SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY, "1"],
7467        )?;
7468        Ok(())
7469    })();
7470    match result {
7471        Ok(()) => connection.execute_batch("COMMIT"),
7472        Err(err) => {
7473            let _ = connection.execute_batch("ROLLBACK");
7474            Err(err)
7475        }
7476    }
7477}
7478
7479/// 0.8.0 Slice 5 (G1) fix-1 — has the post-tokenizer-upgrade re-tokenization
7480/// committed durably on this DB? Keys off the
7481/// `SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY` row written inside the reindex
7482/// transaction; its absence on a v11 DB means the reindex never committed
7483/// (fresh-after-step-11 or crash-in-window) and must (re-)run.
7484///
7485/// A MISSING `_fathomdb_open_state` table is reported as "complete" (skip the
7486/// reproject): that table is created by migration step 1, so its absence means
7487/// the DB never ran our migrations (e.g. a synthetic DB whose `user_version`
7488/// was stamped to 11 by hand, or a legacy/foreign shape). Such DBs are
7489/// rejected by the downstream embedder-identity/integrity probes; the reproject
7490/// must not run — and must not mask those errors — on them. On a genuinely
7491/// migrated DB the table always exists, so the crash-repair path is unaffected.
7492fn search_index_tokenizer_reproject_complete(connection: &Connection) -> rusqlite::Result<bool> {
7493    match connection.query_row(
7494        "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
7495        [SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY],
7496        |row| row.get::<_, String>(0),
7497    ) {
7498        Ok(value) => Ok(value == "1"),
7499        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
7500        Err(rusqlite::Error::SqliteFailure(_, Some(ref message)))
7501            if message.contains("no such table") =>
7502        {
7503            Ok(true)
7504        }
7505        Err(err) => Err(err),
7506    }
7507}
7508
7509fn canonical_node_rows(connection: &Connection) -> rusqlite::Result<Vec<CanonicalNodeRow>> {
7510    let mut statement = connection
7511        .prepare("SELECT write_cursor, kind, body FROM canonical_nodes ORDER BY write_cursor")?;
7512    let rows = statement.query_map([], |row| {
7513        Ok(CanonicalNodeRow {
7514            cursor: row.get::<_, u64>(0)?,
7515            kind: row.get::<_, String>(1)?,
7516            body: row.get::<_, String>(2)?,
7517        })
7518    })?;
7519    rows.collect()
7520}
7521
7522#[cfg(feature = "operator")]
7523fn hex_encode(bytes: &[u8]) -> String {
7524    let mut out = String::with_capacity(bytes.len() * 2);
7525    for byte in bytes {
7526        out.push(hex_nibble(byte >> 4));
7527        out.push(hex_nibble(byte & 0x0f));
7528    }
7529    out
7530}
7531
7532#[cfg(feature = "operator")]
7533fn hex_nibble(value: u8) -> char {
7534    match value {
7535        0..=9 => (b'0' + value) as char,
7536        10..=15 => (b'a' + value - 10) as char,
7537        _ => unreachable!(),
7538    }
7539}
7540
7541#[cfg(feature = "operator")]
7542fn physical_section(connection: &Connection, full: bool) -> Section {
7543    let mut findings = Vec::new();
7544    if let Err(err) = connection.query_row("PRAGMA page_count", [], |row| row.get::<_, i64>(0)) {
7545        findings.push(Finding {
7546            code: "E_CORRUPT_HEADER",
7547            stage: "PhysicalProbe",
7548            locator: locator_from_rusqlite_error(&err),
7549            doc_anchor: "design/recovery.md#header-malformed",
7550            detail: format!("page_count probe failed: {err}"),
7551        });
7552    }
7553    if full {
7554        match collect_integrity_check_findings(connection) {
7555            Ok(rows) => findings.extend(rows),
7556            Err(err) => findings.push(Finding {
7557                code: "E_CORRUPT_INTEGRITY_CHECK",
7558                stage: "IntegrityCheck",
7559                locator: locator_from_rusqlite_error(&err),
7560                doc_anchor: "design/recovery.md#integrity-check-full-findings",
7561                detail: format!("PRAGMA integrity_check failed: {err}"),
7562            }),
7563        }
7564    }
7565    if findings.is_empty() {
7566        Section::Clean
7567    } else {
7568        Section::Findings(findings)
7569    }
7570}
7571
7572#[cfg(feature = "operator")]
7573fn logical_section(connection: &Connection) -> Section {
7574    let mut findings = Vec::new();
7575    if let Err(err) = connection.query_row("PRAGMA schema_version", [], |row| row.get::<_, i64>(0))
7576    {
7577        findings.push(Finding {
7578            code: "E_CORRUPT_SCHEMA",
7579            stage: "SchemaProbe",
7580            locator: locator_from_rusqlite_error(&err),
7581            doc_anchor: "design/recovery.md#schema-inconsistent",
7582            detail: format!("schema_version probe failed: {err}"),
7583        });
7584    }
7585    match connection.query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)) {
7586        Ok(0) => findings.push(Finding {
7587            code: "E_CORRUPT_SCHEMA",
7588            stage: "SchemaProbe",
7589            locator: CorruptionLocator::MigrationStep { from: 0, to: 0 },
7590            doc_anchor: "design/recovery.md#schema-inconsistent",
7591            detail: "user_version is zero".to_string(),
7592        }),
7593        Ok(_) => {}
7594        Err(err) => findings.push(Finding {
7595            code: "E_CORRUPT_SCHEMA",
7596            stage: "SchemaProbe",
7597            locator: locator_from_rusqlite_error(&err),
7598            doc_anchor: "design/recovery.md#schema-inconsistent",
7599            detail: format!("user_version probe failed: {err}"),
7600        }),
7601    }
7602    if findings.is_empty() {
7603        Section::Clean
7604    } else {
7605        Section::Findings(findings)
7606    }
7607}
7608
7609#[cfg(feature = "operator")]
7610fn semantic_section(connection: &Connection) -> Section {
7611    match load_default_profile(connection) {
7612        Ok(_) => Section::Clean,
7613        Err(rusqlite::Error::QueryReturnedNoRows) => Section::Findings(vec![Finding {
7614            code: "E_CORRUPT_EMBEDDER_IDENTITY",
7615            stage: "EmbedderIdentity",
7616            locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
7617            doc_anchor: "design/recovery.md#embedder-identity-drift",
7618            detail: "default embedder profile row is missing".to_string(),
7619        }]),
7620        Err(err) => Section::Findings(vec![Finding {
7621            code: "E_CORRUPT_EMBEDDER_IDENTITY",
7622            stage: "EmbedderIdentity",
7623            locator: locator_from_rusqlite_error(&err),
7624            doc_anchor: "design/recovery.md#embedder-identity-drift",
7625            detail: format!("default embedder profile probe failed: {err}"),
7626        }]),
7627    }
7628}
7629
7630#[cfg(feature = "operator")]
7631fn collect_integrity_check_findings(connection: &Connection) -> rusqlite::Result<Vec<Finding>> {
7632    let mut statement = connection.prepare("PRAGMA integrity_check")?;
7633    let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
7634    let mut findings = Vec::new();
7635    for row in rows {
7636        let message = row?;
7637        if message == "ok" {
7638            continue;
7639        }
7640        findings.push(Finding {
7641            code: "E_CORRUPT_INTEGRITY_CHECK",
7642            stage: "IntegrityCheck",
7643            locator: CorruptionLocator::OpaqueSqliteError {
7644                sqlite_extended_code: rusqlite::ffi::SQLITE_CORRUPT,
7645            },
7646            doc_anchor: "design/recovery.md#integrity-check-full-findings",
7647            detail: message,
7648        });
7649    }
7650    Ok(findings)
7651}
7652
7653#[cfg(feature = "operator")]
7654fn locator_from_rusqlite_error(err: &rusqlite::Error) -> CorruptionLocator {
7655    let extended = err.sqlite_error().map(|inner| inner.extended_code).unwrap_or(0);
7656    CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: extended }
7657}
7658
7659fn open_runtime_connection(path: &Path) -> rusqlite::Result<Connection> {
7660    let connection = Connection::open(path)?;
7661    connection.pragma_update(None, "journal_mode", "WAL")?;
7662    Ok(connection)
7663}
7664
7665fn load_projection_cursor(connection: &Connection) -> rusqlite::Result<u64> {
7666    connection
7667        .query_row(
7668            "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
7669            [PROJECTION_CURSOR_KEY],
7670            |row| row.get::<_, String>(0),
7671        )
7672        .map(|value| value.parse::<u64>().unwrap_or(0))
7673        .or_else(|err| match err {
7674            rusqlite::Error::QueryReturnedNoRows => Ok(0),
7675            _ => Err(err),
7676        })
7677}
7678
7679fn store_projection_cursor(connection: &Connection, cursor: u64) -> rusqlite::Result<()> {
7680    connection.execute(
7681        "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
7682         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
7683        params![PROJECTION_CURSOR_KEY, cursor.to_string()],
7684    )?;
7685    Ok(())
7686}
7687
7688fn record_projection_terminal(
7689    connection: &Connection,
7690    cursor: u64,
7691    state: &str,
7692) -> rusqlite::Result<()> {
7693    connection.execute(
7694        "INSERT OR IGNORE INTO _fathomdb_projection_terminal(write_cursor, state) VALUES(?1, ?2)",
7695        params![cursor, state],
7696    )?;
7697    Ok(())
7698}
7699
7700fn terminal_state_for_cursor(
7701    connection: &Connection,
7702    cursor: u64,
7703) -> rusqlite::Result<Option<String>> {
7704    connection
7705        .query_row(
7706            "SELECT state FROM _fathomdb_projection_terminal WHERE write_cursor = ?1",
7707            [cursor],
7708            |row| row.get::<_, String>(0),
7709        )
7710        .map(Some)
7711        .or_else(|err| match err {
7712            rusqlite::Error::QueryReturnedNoRows => Ok(None),
7713            _ => Err(err),
7714        })
7715}
7716
7717fn advance_projection_cursor(connection: &Connection) -> rusqlite::Result<u64> {
7718    let mut cursor = load_projection_cursor(connection)?;
7719    loop {
7720        let next = cursor.saturating_add(1);
7721        if terminal_state_for_cursor(connection, next)?.is_some() {
7722            cursor = next;
7723        } else {
7724            break;
7725        }
7726    }
7727    store_projection_cursor(connection, cursor)?;
7728    Ok(cursor)
7729}
7730
7731fn commit_projection_outcomes(
7732    connection: &mut Connection,
7733    outcomes: &[ProjectionOutcome],
7734    shared: &ProjectionRuntimeShared,
7735) -> rusqlite::Result<()> {
7736    let embedder_identity = &shared.embedder_identity;
7737    let mc = identity_requires_mean_centering(embedder_identity);
7738    // EU-5f — serialize the whole commit across workers so the at-pin
7739    // re-quantize sees a totally-ordered history (see `commit_gate`).
7740    let _gate = shared.commit_gate.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
7741    let tx = connection.transaction()?;
7742    // EU-5a2/EU-5f — the live pinned mean. Read once at the top; may pin
7743    // mid-batch (set to `Some` after a threshold-crossing row below).
7744    let mut current_mean: Option<Vec<f32>> = if mc {
7745        tx.query_row(
7746            "SELECT mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
7747            [],
7748            |row| row.get::<_, Option<Vec<u8>>>(0),
7749        )
7750        .ok()
7751        .flatten()
7752        .map(|bytes| decode_vector_blob(&bytes))
7753    } else {
7754        None
7755    };
7756    let mut staged_events: Vec<EmbedderEvent> = Vec::new();
7757    for outcome in outcomes {
7758        match outcome {
7759            ProjectionOutcome::Success { cursor, kind, blob, bin_blob } => {
7760                if terminal_state_for_cursor(&tx, *cursor)?.is_some() {
7761                    continue;
7762                }
7763                // EU-5f — feed the streaming accumulator and decide the pin
7764                // atomically under the accumulator lock (add -> count ->
7765                // take), so exactly one row/worker can cross the threshold.
7766                // Only while MC-required and not yet pinned.
7767                let pin_mean: Option<Vec<f32>> = if mc && current_mean.is_none() {
7768                    let mut acc = shared.mean_accumulator.lock().unwrap_or_else(|p| p.into_inner());
7769                    match acc.as_mut() {
7770                        Some(a) => {
7771                            a.add(&decode_vector_blob(bin_blob));
7772                            if a.count() >= MEAN_VEC_PIN_THRESHOLD {
7773                                let mean = a.materialize();
7774                                *acc = None;
7775                                Some(mean)
7776                            } else {
7777                                None
7778                            }
7779                        }
7780                        None => None,
7781                    }
7782                } else {
7783                    None
7784                };
7785
7786                let source_type = resolve_source_type(kind).map_err(|_| {
7787                    rusqlite::Error::SqliteFailure(
7788                        rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
7789                        Some(format!("unknown kind for source_type mapping: {kind}")),
7790                    )
7791                })?;
7792                let now_unix =
7793                    SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
7794                        as i64;
7795                tx.execute(
7796                    "INSERT OR IGNORE INTO _fathomdb_vector_rows(rowid, kind, write_cursor) VALUES(?1, ?2, ?3)",
7797                    params![cursor, kind, cursor],
7798                )?;
7799                // EU-5a2/EU-5f — sign-quant input is the mean-subtracted
7800                // vector iff a mean is live (`current_mean`); otherwise the
7801                // un-centered `bin_blob`. A row inserted just before the
7802                // crossing is centered retroactively by the re-quantize
7803                // pass below.
7804                let centered_blob: Vec<u8> = match &current_mean {
7805                    Some(mean) if mean.len() * 4 == bin_blob.len() => {
7806                        encode_vector_blob(&subtract_mean(&decode_vector_blob(bin_blob), mean))
7807                    }
7808                    _ => bin_blob.clone(),
7809                };
7810                tx.execute(
7811                    // Slice 10 / G10 — `status` ships the empty-string sentinel
7812                    // (vec0 TEXT metadata is NOT NULL-able); no real population
7813                    // source yet (reserved-gap candidate 13).
7814                    "INSERT OR IGNORE INTO vector_default(
7815                        rowid, embedding, embedding_bin, source_type, kind, created_at, status
7816                     ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, '')",
7817                    params![cursor, blob, centered_blob, source_type, kind, now_unix],
7818                )?;
7819                record_projection_terminal(&tx, *cursor, "up_to_date")?;
7820
7821                // EU-5f — this row crossed the threshold: pin the mean and
7822                // re-quantize every row written so far (incl. earlier rows
7823                // in this same tx, which are visible to the SELECT) within
7824                // the same transaction so the pin is atomic.
7825                if let Some(mean) = pin_mean {
7826                    tx.execute(
7827                        "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
7828                        params![encode_vector_blob(&mean)],
7829                    )?;
7830                    let rows: Vec<(i64, Vec<u8>)> = {
7831                        let mut statement = tx.prepare(
7832                            "SELECT rowid, embedding FROM vector_default ORDER BY rowid",
7833                        )?;
7834                        let mapped = statement.query_map([], |row| {
7835                            Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?))
7836                        })?;
7837                        let mut out = Vec::new();
7838                        for r in mapped {
7839                            out.push(r?);
7840                        }
7841                        out
7842                    };
7843                    let (doc_count, _) =
7844                        run_pin_and_requantize_pass(&tx, &rows, &mean).map_err(|_| {
7845                            rusqlite::Error::SqliteFailure(
7846                                rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
7847                                Some("mean-centering re-quantize pass failed".to_string()),
7848                            )
7849                        })?;
7850                    staged_events.push(EmbedderEvent::MeanVecPinned {
7851                        dim: u32::try_from(mean.len()).unwrap_or(u32::MAX),
7852                        doc_count,
7853                    });
7854                    current_mean = Some(mean);
7855                }
7856            }
7857            ProjectionOutcome::Failure { cursor, failure_code } => {
7858                if terminal_state_for_cursor(&tx, *cursor)?.is_some() {
7859                    continue;
7860                }
7861                let existing: u64 = tx.query_row(
7862                    "SELECT COUNT(*) FROM operational_mutations
7863                     WHERE collection_name = 'projection_failures'
7864                       AND json_extract(payload_json, '$.write_cursor') = ?1",
7865                    [cursor],
7866                    |row| row.get(0),
7867                )?;
7868                if existing == 0 {
7869                    let payload = format!(
7870                        r#"{{"write_cursor":{cursor},"failure_code":"{failure_code}","recorded_at":0}}"#
7871                    );
7872                    tx.execute(
7873                        "INSERT INTO operational_mutations(
7874                            collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
7875                         ) VALUES('projection_failures', ?1, 'append', ?2, NULL, ?3)",
7876                        params![cursor.to_string(), payload, cursor],
7877                    )?;
7878                }
7879                record_projection_terminal(&tx, *cursor, "failed")?;
7880            }
7881        }
7882    }
7883    // 0.7.2 PR-2bc S2 — the AUTOMATIC in-ingest drift detector (EWMA recent
7884    // mean + cos-threshold + debounce + 200k cap + `MeanRecomputeDeferred`)
7885    // was CARVED OUT and DEFERRED to 0.8.x; its recall premise was refuted
7886    // (the mean is a non-lever) and the benefit is unmeasured. The mean is
7887    // refreshed only on demand via `Engine::recompute_mean` (the
7888    // `doctor recompute-mean` verb). See `dev/design/embedder.md` §0.3 and
7889    // `dev/plans/prompts/0.8.x-auto-mean-drift-DEFERRED.md`. Nothing here
7890    // mutates `mean_vec` after the initial pin.
7891
7892    advance_projection_cursor(&tx)?;
7893    tx.commit()?;
7894    // EU-5f — publish MeanVecPinned only after the pin tx is durable, so a
7895    // rolled-back pin never emits a spurious event.
7896    if !staged_events.is_empty() {
7897        if let Ok(mut events) = shared.pending_events.lock() {
7898            events.extend(staged_events);
7899        }
7900    }
7901    Ok(())
7902}
7903
7904/// EU-5f — open-time recovery pin (`dev/design/embedder.md` §0.3, Hazard 4).
7905/// Derives the corpus mean from the existing un-centered `vector_default`
7906/// rows, pins it, and re-quantizes every row, all in one transaction on the
7907/// single-threaded open connection (no workers running yet, so no gate is
7908/// needed). Called only when MC is required, no mean is pinned, and the row
7909/// count already meets the threshold.
7910fn recover_mean_vec_pin(
7911    connection: &mut Connection,
7912    identity: &EmbedderIdentity,
7913) -> Result<(), EngineError> {
7914    let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
7915    recompute_mean_in_tx(&tx, identity)?;
7916    tx.commit().map_err(|_| EngineError::Storage)?;
7917    Ok(())
7918}
7919
7920/// 0.7.2 PR-2b — shared mean (re)compute core, run INSIDE the caller's
7921/// transaction. Derives the FULL-corpus mean from the un-centered
7922/// `vector_default.embedding` BLOBs, writes `mean_vec`, and re-quantizes
7923/// EVERY row via the existing [`run_pin_and_requantize_pass`] so no row is
7924/// left under a stale centering.
7925///
7926/// This generalizes the EU-5f open-time recovery pin: it has NO "no mean
7927/// pinned yet" guard, so it equally serves the FIRST pin (recovery) and a
7928/// REFRESH of an already-pinned mean (PR-2b drift / `doctor recompute-mean`).
7929/// The caller owns the transaction boundary, which is what makes a fault
7930/// between the `mean_vec` UPDATE and re-quantize completion roll back
7931/// wholesale (`dev/design/embedder.md` §0.5 atomicity). It does NOT publish
7932/// any event — that is the caller's job, strictly post-durable-commit.
7933fn recompute_mean_in_tx(
7934    tx: &rusqlite::Transaction<'_>,
7935    identity: &EmbedderIdentity,
7936) -> Result<MeanRecomputeReport, EngineError> {
7937    recompute_mean_in_tx_inner(tx, identity, false)
7938}
7939
7940/// 0.7.2 PR-2b — recompute core with an optional fault-injection point. The
7941/// `fail_after_mean_update` flag (debug builds only, set via a test seam)
7942/// errors AFTER the `mean_vec` UPDATE but BEFORE the re-quantize completes,
7943/// so the caller's tx rolls back the partial recentering.
7944fn recompute_mean_in_tx_inner(
7945    tx: &rusqlite::Transaction<'_>,
7946    identity: &EmbedderIdentity,
7947    fail_after_mean_update: bool,
7948) -> Result<MeanRecomputeReport, EngineError> {
7949    let started = Instant::now();
7950    let dim = identity.dimension as usize;
7951    // The previously-pinned mean (if any) is read first so we can report
7952    // the pre-recompute drift cosine.
7953    let old_mean = read_pinned_mean_vec(tx, identity.dimension)?;
7954    let rows: Vec<(i64, Vec<u8>)> = {
7955        let mut statement = tx
7956            .prepare("SELECT rowid, embedding FROM vector_default ORDER BY rowid")
7957            .map_err(|_| EngineError::Storage)?;
7958        let mapped = statement
7959            .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)))
7960            .map_err(|_| EngineError::Storage)?;
7961        let mut out = Vec::new();
7962        for r in mapped {
7963            out.push(r.map_err(|_| EngineError::Storage)?);
7964        }
7965        out
7966    };
7967    let mut accumulator = MeanAccumulator::new(dim);
7968    for (_rowid, blob) in &rows {
7969        if blob.len() != dim * 4 {
7970            return Err(EngineError::Storage);
7971        }
7972        accumulator.add(&decode_vector_blob(blob));
7973    }
7974    let old_doc_count = accumulator.count();
7975    let mean = accumulator.materialize();
7976    let drift_cos_before = match &old_mean {
7977        Some(old) => cosine_similarity(&mean, old),
7978        None => 1.0,
7979    };
7980    tx.execute(
7981        "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
7982        params![encode_vector_blob(&mean)],
7983    )
7984    .map_err(|_| EngineError::Storage)?;
7985    if fail_after_mean_update {
7986        // Injected fault: bail before re-quantizing so the caller's tx
7987        // rolls back the `mean_vec` UPDATE too (crash-atomicity proof).
7988        return Err(EngineError::Storage);
7989    }
7990    let (doc_count, _) = run_pin_and_requantize_pass(tx, &rows, &mean)?;
7991    Ok(MeanRecomputeReport {
7992        dim: u32::try_from(dim).unwrap_or(u32::MAX),
7993        old_doc_count,
7994        doc_count_requantized: doc_count,
7995        drift_cos_before,
7996        mean_was_pinned: old_mean.is_some(),
7997        elapsed_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
7998    })
7999}
8000
8001fn enforce_provenance_retention(connection: &Connection, cap: u64) -> rusqlite::Result<()> {
8002    if cap == 0 {
8003        return Ok(());
8004    }
8005    let slack = cap.max(20) / 20;
8006    let upper = cap.saturating_add(slack.max(1));
8007    let count: u64 =
8008        connection.query_row("SELECT COUNT(*) FROM operational_mutations", [], |row| row.get(0))?;
8009    if count <= upper {
8010        return Ok(());
8011    }
8012    let to_delete = count.saturating_sub(cap);
8013    connection.execute(
8014        "DELETE FROM operational_mutations
8015         WHERE id IN (
8016             SELECT id FROM operational_mutations
8017             ORDER BY id
8018             LIMIT ?1
8019         )",
8020        [to_delete],
8021    )?;
8022    Ok(())
8023}
8024
8025fn projection_status(
8026    connection: &Connection,
8027    kind: &str,
8028) -> Result<lifecycle::ProjectionStatus, EngineError> {
8029    let latest = connection
8030        .query_row(
8031            "SELECT COALESCE(MAX(write_cursor), 0) FROM canonical_nodes WHERE kind = ?1",
8032            [kind],
8033            |row| row.get::<_, u64>(0),
8034        )
8035        .map_err(|_| EngineError::Storage)?;
8036    if latest == 0 {
8037        return Ok(lifecycle::ProjectionStatus::UpToDate);
8038    }
8039    let pending: u64 = connection
8040        .query_row(
8041            "SELECT COUNT(*)
8042             FROM canonical_nodes
8043             LEFT JOIN _fathomdb_projection_terminal
8044               ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
8045             WHERE canonical_nodes.kind = ?1
8046               AND _fathomdb_projection_terminal.write_cursor IS NULL",
8047            [kind],
8048            |row| row.get(0),
8049        )
8050        .map_err(|_| EngineError::Storage)?;
8051    if pending > 0 {
8052        return Ok(lifecycle::ProjectionStatus::Pending);
8053    }
8054    match terminal_state_for_cursor(connection, latest).map_err(|_| EngineError::Storage)? {
8055        Some(state) if state == "failed" => Ok(lifecycle::ProjectionStatus::Failed),
8056        _ => Ok(lifecycle::ProjectionStatus::UpToDate),
8057    }
8058}
8059
8060fn canonical_database_path(path: &Path) -> Result<PathBuf, EngineOpenError> {
8061    let parent = path
8062        .parent()
8063        .filter(|parent| !parent.as_os_str().is_empty())
8064        .unwrap_or_else(|| Path::new("."));
8065    let canonical_parent = parent.canonicalize().map_err(|_| EngineOpenError::Io {
8066        message: "database parent directory is not accessible".to_string(),
8067    })?;
8068    let file_name = path.file_name().ok_or_else(|| EngineOpenError::Io {
8069        message: "database path has no file name".to_string(),
8070    })?;
8071
8072    Ok(canonical_parent.join(file_name))
8073}
8074
8075fn acquire_lock(path: &Path) -> Result<File, EngineOpenError> {
8076    let lock_path = lock_path(path);
8077    let mut options = OpenOptions::new();
8078    options.read(true).write(true).create(true);
8079    #[cfg(unix)]
8080    options.mode(0o600);
8081
8082    let mut file = options.open(&lock_path).map_err(|_| EngineOpenError::Io {
8083        message: "could not open database lock file".to_string(),
8084    })?;
8085
8086    match file.try_lock() {
8087        Ok(()) => {
8088            let pid = std::process::id().to_string();
8089            let _ = file.set_len(0);
8090            let _ = file.seek(SeekFrom::Start(0));
8091            let _ = file.write_all(pid.as_bytes());
8092            Ok(file)
8093        }
8094        Err(std::fs::TryLockError::WouldBlock) => {
8095            Err(EngineOpenError::DatabaseLocked { holder_pid: read_holder_pid(&lock_path) })
8096        }
8097        Err(_) => {
8098            Err(EngineOpenError::Io { message: "could not acquire database lock".to_string() })
8099        }
8100    }
8101}
8102
8103fn lock_path(path: &Path) -> PathBuf {
8104    let mut lock_path = path.as_os_str().to_os_string();
8105    lock_path.push(LOCK_SUFFIX);
8106    PathBuf::from(lock_path)
8107}
8108
8109fn read_holder_pid(path: &Path) -> Option<u32> {
8110    std::fs::read_to_string(path).ok()?.trim().parse().ok()
8111}
8112
8113fn map_migration_error(err: SchemaMigrationError) -> EngineOpenError {
8114    match err {
8115        SchemaMigrationError::IncompatibleSchemaVersion { seen, supported } => {
8116            EngineOpenError::IncompatibleSchemaVersion { seen, supported }
8117        }
8118        SchemaMigrationError::MigrationError(report) => EngineOpenError::MigrationError {
8119            schema_version_before: report.schema_version_before,
8120            schema_version_current: report.schema_version_current,
8121            step_id: report.migration_steps.last().map_or(0, |step| step.step_id),
8122        },
8123        SchemaMigrationError::Storage { message } => {
8124            EngineOpenError::Io { message: message.to_string() }
8125        }
8126    }
8127}
8128
8129/// 0.7.0 perf-experiments hook: process-start `sqlite3_config` calls.
8130/// Runs exactly once per process; must precede any `Connection::open`.
8131/// Gated on `FATHOMDB_PERF_EXPERIMENTS=1`. Each individual config
8132/// option is opt-in via its own env var so unrelated experiments do
8133/// not implicitly co-fire.
8134///
8135/// Currently supports:
8136/// - `FATHOMDB_PERF_SQLITE_MEMSTATUS_OFF=1`:
8137///   `sqlite3_config(SQLITE_CONFIG_MEMSTATUS, 0)` — drops the
8138///   allocator stats locking surface (whitepaper § 7.4). Composes
8139///   with other levers; small payoff alone.
8140///
8141/// Pattern: shutdown → config → initialize, mirroring B.1 attempt #2
8142/// (`d448263`, reverted). The captured rc for each config call is
8143/// logged to stderr so experiments can verify the call took effect.
8144fn init_perf_experiments_runtime() {
8145    static INIT: Once = Once::new();
8146    INIT.call_once(|| {
8147        if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
8148            return;
8149        }
8150        let memstatus_off =
8151            std::env::var_os("FATHOMDB_PERF_SQLITE_MEMSTATUS_OFF").is_some_and(|v| v == "1");
8152        // FATHOMDB_PERF_SQLITE_PAGECACHE=<page_size_bytes>:<page_count>
8153        // E.g. "4096:5000" => pre-allocate 4096 B × 5000 pages = 20 MB
8154        // global page-cache backing. SQLite distributes this across
8155        // connections; reduces global allocator pressure for page
8156        // cache fills.
8157        let pagecache = std::env::var("FATHOMDB_PERF_SQLITE_PAGECACHE").ok();
8158        // FATHOMDB_PERF_SQLITE_PCACHE2=1 installs the per-instance
8159        // custom page-cache allocator (pcache2.rs). Targets AC-020
8160        // residual contention on the default pcache1 mutex.
8161        let pcache2_on =
8162            std::env::var_os("FATHOMDB_PERF_SQLITE_PCACHE2").is_some_and(|v| v == "1");
8163        if !memstatus_off && pagecache.is_none() && !pcache2_on {
8164            return;
8165        }
8166        // SAFETY: sqlite3_shutdown / sqlite3_initialize are documented
8167        // as safe to call before any other SQLite API; sqlite3_config
8168        // must be called between shutdown and initialize. We pre-empt
8169        // rusqlite's lazy first-call sqlite3_initialize via this
8170        // explicit shutdown-then-config-then-initialize sequence,
8171        // identical to B.1 attempt #2's plumbing.
8172        unsafe {
8173            let rc_shutdown = rusqlite::ffi::sqlite3_shutdown();
8174            let rc_memstatus = if memstatus_off {
8175                rusqlite::ffi::sqlite3_config(rusqlite::ffi::SQLITE_CONFIG_MEMSTATUS, 0_i32)
8176            } else {
8177                -1
8178            };
8179            // SQLITE_CONFIG_PAGECACHE = 7 per sqlite3.h. With buffer=NULL,
8180            // SQLite allocates the backing memory itself but still
8181            // partitions it for use as the page-cache pool.
8182            let rc_pagecache = if let Some(spec) = pagecache.as_ref() {
8183                let mut parts = spec.split(':');
8184                let sz = parts.next().and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
8185                let n = parts.next().and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
8186                if sz > 0 && n > 0 {
8187                    rusqlite::ffi::sqlite3_config(
8188                        7, // SQLITE_CONFIG_PAGECACHE
8189                        std::ptr::null_mut::<std::ffi::c_void>(),
8190                        sz,
8191                        n,
8192                    )
8193                } else {
8194                    eprintln!(
8195                        "perf-experiment: bad FATHOMDB_PERF_SQLITE_PAGECACHE spec '{spec}' (expect '<bytes>:<count>')"
8196                    );
8197                    -1
8198                }
8199            } else {
8200                -1
8201            };
8202            let rc_pcache2 = if pcache2_on {
8203                // SQLITE_CONFIG_PCACHE2 = 18 per sqlite3.h. The methods
8204                // table must outlive the SQLite engine; we pass a
8205                // pointer to our static.
8206                rusqlite::ffi::sqlite3_config(
8207                    rusqlite::ffi::SQLITE_CONFIG_PCACHE2,
8208                    &raw const pcache2::PCACHE2_METHODS.0,
8209                )
8210            } else {
8211                -1
8212            };
8213            let rc_init = rusqlite::ffi::sqlite3_initialize();
8214            eprintln!(
8215                "perf-experiment: runtime-config rcs shutdown={rc_shutdown} \
8216                 memstatus={rc_memstatus} pagecache={rc_pagecache} pcache2={rc_pcache2} \
8217                 initialize={rc_init} (0=SQLITE_OK; 21=SQLITE_MISUSE; -1=not configured)"
8218            );
8219        }
8220    });
8221}
8222
8223fn register_sqlite_vec_extension() {
8224    static REGISTER: Once = Once::new();
8225    REGISTER.call_once(|| unsafe {
8226        let entrypoint: unsafe extern "C" fn(
8227            *mut rusqlite::ffi::sqlite3,
8228            *mut *const std::os::raw::c_char,
8229            *const rusqlite::ffi::sqlite3_api_routines,
8230        ) -> std::os::raw::c_int = std::mem::transmute(sqlite3_vec_init as *const ());
8231        rusqlite::ffi::sqlite3_auto_extension(Some(entrypoint));
8232    });
8233}
8234
8235fn probe_open_integrity(connection: &Connection) -> Result<(), EngineOpenError> {
8236    // `SELECT COUNT(*) FROM sqlite_schema` forces a full traversal of the
8237    // sqlite_schema b-tree; this surfaces page-1 b-tree corruption that a
8238    // bare `PRAGMA schema_version` (which only reads the schema cookie
8239    // out of the file header) would miss.
8240    connection
8241        .query_row("SELECT COUNT(*) FROM sqlite_schema", [], |row| row.get::<_, i64>(0))
8242        .map(|_| ())
8243        .map_err(|err| map_open_sqlite_error(err, OpenStage::SchemaProbe))
8244}
8245
8246fn probe_database_header(connection: &Connection) -> Result<(), EngineOpenError> {
8247    connection
8248        .query_row("PRAGMA application_id", [], |row| row.get::<_, i64>(0))
8249        .map(|_| ())
8250        .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))
8251}
8252
8253/// Pre-`pragma WAL` sidecar validation. SQLite silently discards a WAL
8254/// file whose header magic is wrong or whose advertised page size is
8255/// outside `[512, SQLITE_MAX_PAGE_SIZE]`, which would cause us to lose
8256/// committed frames at open time. AC-035a requires that we instead
8257/// refuse to open with `Corruption(WalReplayFailure)` rather than
8258/// silently rebuild from a truncated WAL.
8259fn probe_wal_sidecar(db_path: &Path) -> Result<(), EngineOpenError> {
8260    let mut wal_path = db_path.as_os_str().to_owned();
8261    wal_path.push("-wal");
8262    let wal_path = PathBuf::from(wal_path);
8263    // Bounded read: the WAL header is fixed-layout in the first 32
8264    // bytes (magic + format + page-size + checkpoint-seq + salts +
8265    // checksums); frame data starts at offset 32 and is irrelevant to
8266    // the magic + page-size pre-check. A `std::fs::read` of the whole
8267    // sidecar would force an unclean-shutdown open path to allocate
8268    // and copy the entire WAL into memory before SQLite touches
8269    // recovery — a real latency + RSS regression on AC-035.
8270    use std::io::Read;
8271    let mut file = match std::fs::File::open(&wal_path) {
8272        Ok(file) => file,
8273        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
8274        Err(_) => return Ok(()),
8275    };
8276    let mut bytes = [0u8; 32];
8277    if file.read_exact(&mut bytes).is_err() {
8278        // A short (< 32-byte) sidecar carries no committed frames;
8279        // SQLite treats it as empty and re-initializes WAL state.
8280        return Ok(());
8281    }
8282    let magic = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
8283    let page_size = u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
8284    // WAL_MAGIC mask per SQLite `walIndexRecover`: low bit distinguishes
8285    // big-endian vs little-endian checksum encoding; the rest of the
8286    // magic is fixed.
8287    const WAL_MAGIC_MASK: u32 = 0xFFFF_FFFE;
8288    const WAL_MAGIC: u32 = 0x377F_0682;
8289    const SQLITE_MAX_PAGE_SIZE: u32 = 65536;
8290    let magic_ok = (magic & WAL_MAGIC_MASK) == WAL_MAGIC;
8291    let page_size_ok =
8292        page_size.is_power_of_two() && (512..=SQLITE_MAX_PAGE_SIZE).contains(&page_size);
8293    if magic_ok && page_size_ok {
8294        return Ok(());
8295    }
8296    Err(EngineOpenError::Corruption(CorruptionDetail {
8297        kind: CorruptionKind::WalReplayFailure,
8298        stage: OpenStage::WalReplay,
8299        locator: CorruptionLocator::FileOffset { offset: if !magic_ok { 0 } else { 8 } },
8300        recovery_hint: RecoveryHint {
8301            code: "E_CORRUPT_WAL_REPLAY",
8302            doc_anchor: "design/recovery.md#wal-replay-failures",
8303        },
8304    }))
8305}
8306
8307fn reject_legacy_shape(connection: &Connection) -> Result<(), EngineOpenError> {
8308    let has_legacy_table = table_exists(connection, "fathom_nodes")
8309        || table_exists(connection, "fathom_edges")
8310        || table_exists(connection, "fathom_chunks");
8311    if !has_legacy_table {
8312        return Ok(());
8313    }
8314
8315    let seen =
8316        connection.query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)).unwrap_or(0);
8317    Err(EngineOpenError::IncompatibleSchemaVersion { seen, supported: SCHEMA_VERSION })
8318}
8319
8320fn table_exists(connection: &Connection, table: &str) -> bool {
8321    connection
8322        .query_row(
8323            "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?1",
8324            [table],
8325            |_row| Ok(()),
8326        )
8327        .is_ok()
8328}
8329
8330#[cfg(feature = "operator")]
8331fn read_schema_objects(
8332    connection: &Connection,
8333    obj_type: &str,
8334) -> Result<Vec<SchemaObject>, EngineError> {
8335    let mut stmt = connection
8336        .prepare(
8337            "SELECT name, sql FROM sqlite_schema
8338             WHERE type = ?1 AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL
8339             ORDER BY name",
8340        )
8341        .map_err(|_| EngineError::Storage)?;
8342    let rows = stmt
8343        .query_map([obj_type], |row| {
8344            Ok(SchemaObject { name: row.get::<_, String>(0)?, sql: row.get::<_, String>(1)? })
8345        })
8346        .map_err(|_| EngineError::Storage)?;
8347    let mut out = Vec::new();
8348    for row in rows {
8349        out.push(row.map_err(|_| EngineError::Storage)?);
8350    }
8351    Ok(out)
8352}
8353
8354#[cfg(feature = "operator")]
8355fn order_canonical_first(mut objects: Vec<SchemaObject>) -> Vec<SchemaObject> {
8356    let mut canonical: Vec<SchemaObject> = Vec::new();
8357    for name in CANONICAL_TABLES {
8358        if let Some(pos) = objects.iter().position(|o| o.name == *name) {
8359            canonical.push(objects.remove(pos));
8360        }
8361    }
8362    canonical.extend(objects);
8363    canonical
8364}
8365
8366fn load_default_profile(connection: &Connection) -> rusqlite::Result<EmbedderIdentity> {
8367    connection.query_row(
8368        "SELECT name, revision, dimension FROM _fathomdb_embedder_profiles WHERE profile = ?1",
8369        [DEFAULT_VECTOR_PROFILE],
8370        |row| {
8371            Ok(EmbedderIdentity::new(
8372                row.get::<_, String>(0)?,
8373                row.get::<_, String>(1)?,
8374                row.get::<_, u32>(2)?,
8375            ))
8376        },
8377    )
8378}
8379
8380fn default_profile_dimension(connection: &Connection) -> Result<u32, EngineError> {
8381    load_default_profile(connection)
8382        .map(|identity| identity.dimension)
8383        .map_err(|_| EngineError::Storage)
8384}
8385
8386fn kind_is_vector_indexed(connection: &Connection, kind: &str) -> Result<bool, EngineError> {
8387    connection
8388        .query_row("SELECT 1 FROM _fathomdb_vector_kinds WHERE kind = ?1", [kind], |_row| Ok(()))
8389        .map(|_| true)
8390        .or_else(|err| match err {
8391            rusqlite::Error::QueryReturnedNoRows => Ok(false),
8392            _ => Err(EngineError::Storage),
8393        })
8394}
8395
8396fn ensure_vector_partition(connection: &mut Connection, dimension: u32) -> rusqlite::Result<()> {
8397    // 0.7.0 Pack 1 schema per dev/design/0.7.0-vector-quant-pack1.md D1/D2:
8398    // f32 `embedding` + binary-quant sibling `embedding_bin` + `source_type`
8399    // partition key + `kind` + `created_at`. The vec0 column type is
8400    // dim-parameterized, so the reshape lives here rather than in the
8401    // SQL-only migration framework — see fathomdb-schema migration step 9
8402    // and dev/plans/runs/0.7.0-PVQ-P1-IMPL-output.json for the deviation
8403    // from the design memo's "Choose (a)" guidance.
8404    //
8405    // Three paths:
8406    //   (1) no vector_default       -> CREATE at new shape.
8407    //   (2) old single-column shape -> stage + drop + recreate at new shape
8408    //                                  + repopulate with vec_quantize_binary.
8409    //   (3) already new shape       -> no-op.
8410    let existing_sql: Option<String> = connection
8411        .query_row(
8412            "SELECT sql FROM sqlite_master WHERE type='table' AND name=?1",
8413            [DEFAULT_VECTOR_PARTITION],
8414            |row| row.get::<_, String>(0),
8415        )
8416        .optional()?;
8417
8418    // Slice 10 / G10 — 3-way shape-sentinel (fixes the prior
8419    // `contains("embedding_bin")` no-op that hid the `status` column from
8420    // existing Pack-1 DBs):
8421    //   `status` present       -> Pack-2 (current) shape, no-op.
8422    //   `embedding_bin` present -> Pack-1 -> stage + recreate + back-fill status.
8423    //   neither                 -> legacy single-column -> migrate to current.
8424    match existing_sql {
8425        None => create_vector_partition(connection, dimension),
8426        Some(sql) if sql.contains("status") => Ok(()),
8427        Some(sql) if sql.contains("embedding_bin") => {
8428            migrate_vector_partition_pack1_to_pack2(connection, dimension)
8429        }
8430        Some(_) => migrate_vector_partition_to_pack1(connection, dimension),
8431    }
8432}
8433
8434/// The current (Pack-2) `vector_default` vec0 shape. Slice 10 / G10 adds a plain
8435/// `status TEXT` metadata column — **not** aux (`+status`): aux columns
8436/// hard-error under a KNN `WHERE`, and the G10 filter constrains `status` in the
8437/// phase-1 KNN statement. `status` ships NULL plumbing only (no population source
8438/// yet).
8439fn vector_partition_create_sql(dimension: u32, if_not_exists: bool) -> String {
8440    let guard = if if_not_exists { "IF NOT EXISTS " } else { "" };
8441    format!(
8442        "CREATE VIRTUAL TABLE {guard}{DEFAULT_VECTOR_PARTITION} USING vec0(\
8443            embedding float[{dimension}],\
8444            embedding_bin bit[{dimension}],\
8445            source_type TEXT partition key,\
8446            kind TEXT,\
8447            created_at INTEGER,\
8448            status TEXT\
8449         )"
8450    )
8451}
8452
8453fn create_vector_partition(connection: &Connection, dimension: u32) -> rusqlite::Result<()> {
8454    connection.execute_batch(&vector_partition_create_sql(dimension, true))
8455}
8456
8457/// Slice 10 / G10 — stage + recreate + back-fill upgrade of an existing
8458/// **Pack-1** `vector_default` (has `embedding_bin`, lacks `status`) to the
8459/// Pack-2 shape. The existing `embedding_bin` blob is preserved verbatim (it may
8460/// be mean-centered; re-quantizing from `embedding` would drop the centering),
8461/// and `status` back-fills NULL. Same transactional discipline as
8462/// `migrate_vector_partition_to_pack1`: a single `Connection::transaction()`;
8463/// reader handles are not opened until `ensure_vector_partition` returns, and
8464/// cross-process access is serialized by the sidecar lock, so readers never see
8465/// a partial reshape.
8466fn migrate_vector_partition_pack1_to_pack2(
8467    connection: &mut Connection,
8468    dimension: u32,
8469) -> rusqlite::Result<()> {
8470    let tx = connection.transaction()?;
8471    tx.execute_batch(
8472        "CREATE TABLE _fathomdb_vector_pack2_stage (
8473             rowid         INTEGER PRIMARY KEY,
8474             embedding     BLOB NOT NULL,
8475             embedding_bin BLOB NOT NULL,
8476             source_type   TEXT,
8477             kind          TEXT,
8478             created_at    INTEGER
8479         );
8480         INSERT INTO _fathomdb_vector_pack2_stage(
8481             rowid, embedding, embedding_bin, source_type, kind, created_at
8482         )
8483             SELECT rowid, embedding, embedding_bin, source_type, kind, created_at
8484             FROM vector_default;
8485         DROP TABLE vector_default;",
8486    )?;
8487    tx.execute_batch(&vector_partition_create_sql(dimension, false))?;
8488    // `vec_bit(...)` re-tags the staged blob with the BIT subtype vec0's bit
8489    // column requires (a raw blob loses the subtype and fails the type check).
8490    // This preserves the existing (possibly mean-centered) bits verbatim — no
8491    // re-quantize, so centering survives the upgrade. `status` back-fills the
8492    // empty-string sentinel (vec0 TEXT metadata is NOT NULL-able; reserved-gap
8493    // candidate 13).
8494    tx.execute_batch(
8495        "INSERT INTO vector_default(
8496             rowid, embedding, embedding_bin, source_type, kind, created_at, status
8497         )
8498             SELECT rowid, embedding, vec_bit(embedding_bin), source_type, kind, created_at, ''
8499             FROM _fathomdb_vector_pack2_stage;
8500         DROP TABLE _fathomdb_vector_pack2_stage;",
8501    )?;
8502    tx.commit()
8503}
8504
8505/// SQL fragment implementing the D3 `kind -> source_type` map.
8506/// Used both by the Pack 1 reshape migration and by the drift-detection
8507/// unit test that pins it to [`resolve_source_type`].
8508const KIND_TO_SOURCE_TYPE_CASE_SQL: &str = "CASE s.kind
8509    WHEN 'email'   THEN 'email'
8510    WHEN 'article' THEN 'article'
8511    WHEN 'paper'   THEN 'paper'
8512    WHEN 'meeting' THEN 'meeting'
8513    WHEN 'note'    THEN 'note'
8514    WHEN 'todo'    THEN 'todo'
8515    WHEN 'doc'     THEN 'article'
8516    ELSE 'article'
8517END";
8518
8519/// Pack 1 in-place reshape of `vector_default`. Stages the existing
8520/// f32 corpus + each row's `kind`, drops the old single-column vec0
8521/// table, recreates at the runtime `dimension` with the Pack 1
8522/// columns, then repopulates with SQL-side `vec_quantize_binary` +
8523/// the D3 `kind -> source_type` mapping. The preflight CHECK on
8524/// unknown kinds has already run as migration step 9 by the time we
8525/// get here.
8526///
8527/// Atomicity: the DROP+CREATE+repopulate sequence runs inside a
8528/// rusqlite `Connection::transaction()` (DEFERRED begin per rusqlite
8529/// `transaction.rs:417`). Cross-process serialization is provided by
8530/// the engine's sidecar `acquire_lock` at `open_with_migrations`
8531/// (`lib.rs:1127` area); reader handles are not opened until
8532/// `ensure_vector_partition` returns (`lib.rs:1241` area), so readers
8533/// never observe a partial reshape.
8534fn migrate_vector_partition_to_pack1(
8535    connection: &mut Connection,
8536    dimension: u32,
8537) -> rusqlite::Result<()> {
8538    let tx = connection.transaction()?;
8539    tx.execute_batch(
8540        "CREATE TABLE _fathomdb_vector_migration_v0_7_0 (
8541             rowid     INTEGER PRIMARY KEY,
8542             embedding BLOB NOT NULL,
8543             kind      TEXT NOT NULL
8544         );
8545         INSERT INTO _fathomdb_vector_migration_v0_7_0(rowid, embedding, kind)
8546             SELECT v.rowid, v.embedding, r.kind
8547             FROM vector_default v
8548             JOIN _fathomdb_vector_rows r ON r.rowid = v.rowid;
8549         DROP TABLE vector_default;",
8550    )?;
8551    // Slice 10 / G10 — recreate directly at the Pack-2 shape (adds `status`), so
8552    // a legacy single-column DB lands the current shape in one reshape.
8553    tx.execute_batch(&vector_partition_create_sql(dimension, false))?;
8554    // `status` back-fills the empty-string sentinel (vec0 TEXT metadata is NOT
8555    // NULL-able; reserved-gap candidate 13). Legacy single-column DBs predate
8556    // mean-centering, so re-quantizing from the un-centered `embedding` is
8557    // correct here.
8558    let repopulate_sql = format!(
8559        "INSERT INTO vector_default(
8560             rowid, embedding, embedding_bin, source_type, kind, created_at, status
8561         )
8562         SELECT
8563             s.rowid,
8564             s.embedding,
8565             vec_quantize_binary(s.embedding),
8566             {KIND_TO_SOURCE_TYPE_CASE_SQL},
8567             s.kind,
8568             strftime('%s', 'now'),
8569             ''
8570         FROM _fathomdb_vector_migration_v0_7_0 s;
8571         DROP TABLE _fathomdb_vector_migration_v0_7_0;"
8572    );
8573    tx.execute_batch(&repopulate_sql)?;
8574    tx.commit()
8575}
8576
8577fn encode_vector_blob(vector: &[f32]) -> Vec<u8> {
8578    vector.iter().flat_map(|value| value.to_le_bytes()).collect()
8579}
8580
8581fn decode_vector_blob(bytes: &[u8]) -> Vec<f32> {
8582    debug_assert_eq!(bytes.len() % 4, 0, "f32 BLOB length must be multiple of 4");
8583    bytes.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()
8584}
8585
8586/// EU-5a2 — does the live embedder identity request mean-centering?
8587/// Identity-name compare per EU-5a1's BGE_SMALL_EMBEDDER_NAME constant
8588/// (`dev/design/embedder.md` §0.6). NoopEmbedder returns `false`.
8589fn identity_requires_mean_centering(identity: &EmbedderIdentity) -> bool {
8590    identity.name == BGE_SMALL_EMBEDDER_NAME
8591}
8592
8593/// EU-5a2 — read the pinned mean vector from
8594/// `_fathomdb_embedder_profiles.mean_vec` for the default profile.
8595/// Returns `Ok(None)` when the column is NULL or the row is missing;
8596/// returns `Err(EngineError::Storage)` on dimension drift (the open-time
8597/// `check_embedder_profile` already fails closed for this, so a runtime
8598/// drift here would be an internal-inconsistency signal).
8599fn read_pinned_mean_vec(
8600    connection: &Connection,
8601    dimension: u32,
8602) -> Result<Option<Vec<f32>>, EngineError> {
8603    let bytes: Option<Vec<u8>> = connection
8604        .query_row(
8605            "SELECT mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
8606            [],
8607            |row| row.get::<_, Option<Vec<u8>>>(0),
8608        )
8609        .or_else(|err| match err {
8610            rusqlite::Error::QueryReturnedNoRows => Ok(None),
8611            other => Err(other),
8612        })
8613        .map_err(|_| EngineError::Storage)?;
8614    let Some(bytes) = bytes else { return Ok(None) };
8615    let expected_len = (dimension as usize).saturating_mul(4);
8616    if bytes.len() != expected_len {
8617        return Err(EngineError::Storage);
8618    }
8619    let mut out = Vec::with_capacity(dimension as usize);
8620    for chunk in bytes.chunks_exact(4) {
8621        let arr = [chunk[0], chunk[1], chunk[2], chunk[3]];
8622        out.push(f32::from_le_bytes(arr));
8623    }
8624    Ok(Some(out))
8625}
8626
8627/// EU-5a2 — pointwise `v - mean`. Length-checked debug-assert; caller
8628/// guarantees equal length via `read_pinned_mean_vec` + dimension check.
8629fn subtract_mean(v: &[f32], mean: &[f32]) -> Vec<f32> {
8630    debug_assert_eq!(v.len(), mean.len(), "subtract_mean dim mismatch");
8631    v.iter().zip(mean.iter()).map(|(a, b)| *a - *b).collect()
8632}
8633
8634/// Maps the writer-facing `kind` value to the locked Pack 1
8635/// `source_type` partition-key vocabulary. Must stay in lockstep with
8636/// the CASE WHEN inlined in migration step 9
8637/// (`fathomdb-schema/src/lib.rs`); the drift-detection unit test in
8638/// this module's `tests` mod enforces that. Per
8639/// `dev/design/0.7.0-vector-quant-pack1.md` D3.
8640fn resolve_source_type(kind: &str) -> Result<&'static str, EngineError> {
8641    Ok(match kind {
8642        "email" => "email",
8643        "article" => "article",
8644        "paper" => "paper",
8645        "meeting" => "meeting",
8646        "note" => "note",
8647        "todo" => "todo",
8648        // Synthetic AC-013 test fixture; coerced so the 6-value HITL lock holds.
8649        "doc" => "article",
8650        // G11 (Slice 15) — edge-body projection; separate `source_type` partition
8651        // key distinguishes edge vectors from node vectors in `vector_default`.
8652        "edge_fact" => "edge_fact",
8653        _ => return Err(EngineError::Storage),
8654    })
8655}
8656
8657/// G11 (Slice 15) — derive a stable hex-encoded sha256 logical_id from a
8658/// `(kind, name)` pair. Both inputs are lowercased before hashing so that
8659/// entity identity is case-insensitive (`"Alice"` == `"alice"`). The
8660/// canonical form is `sha256("<kind>:<name>")` — identical to the
8661/// ADR-0.8.1-byo-llm derivation rule.
8662///
8663/// fix-34 [P1]: because `:` is the delimiter, a `:` in `kind` would let the
8664/// split point move and collide two distinct `(kind, name)` pairs onto one
8665/// identity (e.g. `("a:b","c")` and `("a","b:c")` both hash `"a:b:c"`),
8666/// silently dropping one entity via batch dedup / G0 supersession. An empty
8667/// `name` collapses every name-less entity of a kind onto `sha256("<kind>:")`.
8668/// We reject both at the boundary; this preserves the ADR derivation rule
8669/// (a colon-free `kind` makes the first `:` an unambiguous delimiter, so a `:`
8670/// in `name` stays safe — edge keys deliberately rely on that).
8671fn derive_logical_id(kind: &str, name: &str) -> Result<String, EngineError> {
8672    if kind.contains(':') || name.is_empty() {
8673        return Err(EngineError::Extractor);
8674    }
8675    let input = format!("{}:{}", kind.to_lowercase(), name.to_lowercase());
8676    let mut hasher = Sha256::new();
8677    hasher.update(input.as_bytes());
8678    Ok(format!("{:x}", hasher.finalize()))
8679}
8680
8681/// fix-34 [P2]: dedup a batch of [`PreparedWrite`]s by `logical_id`, keeping the
8682/// first occurrence. Shared by the entity and edge arms of the BYO-LLM ingest
8683/// path so a harness that returns the same node/edge twice in one response does
8684/// not write a row that immediately supersedes its sibling.
8685fn dedup_prepared_by_logical_id(batch: Vec<PreparedWrite>) -> Vec<PreparedWrite> {
8686    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
8687    batch
8688        .into_iter()
8689        .filter(|w| match w {
8690            PreparedWrite::Node { logical_id: Some(id), .. }
8691            | PreparedWrite::Edge { logical_id: Some(id), .. } => seen.insert(id.clone()),
8692            _ => true,
8693        })
8694        .collect()
8695}
8696
8697/// 0.8.6 Slice 5 (ADR-0.8.6) — the family of caller-supplied provider tasks that
8698/// ride the one NDJSON-over-stdio transport. Each task maps to a wire protocol
8699/// string `fathomdb.<task>.v1` and a task discriminator name. Only `Extract`
8700/// exists in 0.8.6; 0.8.10 adds `Consolidate`/`Summarize` on this same transport
8701/// (and their own payload + `EngineError` leaf) WITHOUT a second handshake.
8702#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8703enum ProviderTask {
8704    Extract,
8705}
8706
8707impl ProviderTask {
8708    /// The wire task discriminator, e.g. `"extract"`. Used for `supported_tasks`
8709    /// negotiation and as the request envelope `type`.
8710    fn name(self) -> &'static str {
8711        match self {
8712            ProviderTask::Extract => "extract",
8713        }
8714    }
8715
8716    /// The protocol string FathomDB sends in `hello`/requests and requires in
8717    /// `ready`. For `Extract` this is the UNCHANGED `fathomdb.extract.v1` —
8718    /// byte-identical back-compat for existing ELPS harnesses (ADR-0.8.6 §2.1).
8719    fn protocol(self) -> &'static str {
8720        match self {
8721            ProviderTask::Extract => "fathomdb.extract.v1",
8722        }
8723    }
8724}
8725
8726/// 0.8.6 Slice 5 (ADR-0.8.6) — an open provider transport session: the spawned
8727/// caller subprocess, the buffered stdin writer, the detached stdout-drain
8728/// channel, the bounded-recv timeout, and the negotiated handshake state
8729/// (`model` provenance + `max_docs_per_request`). One session serves one task
8730/// family; the `request`/framing is identical across tasks. `Drop` reaps the
8731/// child (sends stdin EOF via the writer field's own drop, then kill/wait),
8732/// replacing the prior explicit outer kill/wait.
8733struct ProviderSession {
8734    task: ProviderTask,
8735    child: std::process::Child,
8736    writer: std::io::BufWriter<std::process::ChildStdin>,
8737    line_rx: Receiver<std::io::Result<String>>,
8738    io_timeout: Duration,
8739    /// `ready.model`, recorded as output-row provenance (`extractor_model_id`).
8740    model: Option<String>,
8741    max_docs_per_request: usize,
8742}
8743
8744impl Drop for ProviderSession {
8745    fn drop(&mut self) {
8746        // The detached stdout-drain thread exits when the child's stdout closes;
8747        // kill() guarantees that even for a child that ignores stdin EOF. The
8748        // `writer` field drops after this (declaration order) sending EOF too.
8749        let _ = self.child.kill();
8750        let _ = self.child.wait();
8751    }
8752}
8753
8754impl ProviderSession {
8755    /// Run the `hello` → `ready` handshake and `supported_tasks` negotiation.
8756    /// Validates protocol + schema_version (fix-23 [P2]); rejects a zero
8757    /// `max_docs_per_request` (fix-1 [P2]); and, when the harness advertises
8758    /// `supported_tasks`, refuses to proceed unless this session's task is in it.
8759    /// When `supported_tasks` is absent, the harness is assumed to serve the
8760    /// requested task (back-compat: existing extract-only harnesses unchanged).
8761    fn handshake(&mut self) -> Result<(), EngineError> {
8762        let protocol = self.task.protocol();
8763        let hello = serde_json::json!({
8764            "protocol": protocol,
8765            "type": "hello",
8766            "schema_version": 1,
8767        });
8768        let hello_line = serde_json::to_string(&hello).map_err(|_| EngineError::Extractor)?;
8769        writeln!(self.writer, "{hello_line}").map_err(|_| EngineError::Extractor)?;
8770        self.writer.flush().map_err(|_| EngineError::Extractor)?;
8771
8772        let line = recv_extractor_line(&self.line_rx, self.io_timeout)?;
8773        let ready: Value = serde_json::from_str(line.trim()).map_err(|_| EngineError::Extractor)?;
8774        // fix-23 [P2]: validate protocol + schema_version in the ready message per ADR.
8775        if ready.get("type").and_then(|v| v.as_str()) != Some("ready")
8776            || ready.get("protocol").and_then(|v| v.as_str()) != Some(protocol)
8777            || ready.get("schema_version").and_then(|v| v.as_u64()) != Some(1)
8778        {
8779            return Err(EngineError::Extractor);
8780        }
8781
8782        // 0.8.6 Slice 5 (ADR-0.8.6 §2.2): additive, optional `supported_tasks`
8783        // negotiation. If present, the harness must advertise this session's task
8784        // or FathomDB refuses to dispatch it. If absent, default to "serves the
8785        // requested task" so extract-only harnesses keep working unchanged.
8786        if let Some(supported) = ready.get("supported_tasks").and_then(|v| v.as_array()) {
8787            let task_name = self.task.name();
8788            let advertised = supported.iter().any(|t| t.as_str() == Some(task_name));
8789            if !advertised {
8790                return Err(EngineError::Extractor);
8791            }
8792        }
8793
8794        self.model = ready.get("model").and_then(|v| v.as_str()).map(|s| s.to_string());
8795        let max_docs =
8796            ready.get("max_docs_per_request").and_then(|v| v.as_u64()).unwrap_or(8) as usize;
8797        // fix-1 [P2]: reject zero max_docs_per_request to prevent chunks(0) panic.
8798        if max_docs == 0 {
8799            return Err(EngineError::Extractor);
8800        }
8801        self.max_docs_per_request = max_docs;
8802        Ok(())
8803    }
8804
8805    /// Send one framed request for this session's task and receive its matching
8806    /// response. `payload` carries the task-specific fields; the envelope keys
8807    /// (`protocol`, `type`, `request_id`) are added here. The response must have
8808    /// `type == "result"` and a matching `request_id` (fix-24 [P2]); anything
8809    /// else (error, wrong id, missing type) is a protocol fault. For `Extract`
8810    /// the serialized request bytes are identical to the pre-0.8.6 path (serde_json
8811    /// serializes map keys sorted, independent of insertion order).
8812    fn request(
8813        &mut self,
8814        request_id: &str,
8815        payload: Vec<(String, Value)>,
8816    ) -> Result<Value, EngineError> {
8817        let mut req = serde_json::Map::new();
8818        req.insert("protocol".to_string(), Value::from(self.task.protocol()));
8819        req.insert("type".to_string(), Value::from(self.task.name()));
8820        req.insert("request_id".to_string(), Value::from(request_id));
8821        for (k, v) in payload {
8822            req.insert(k, v);
8823        }
8824        let req_line =
8825            serde_json::to_string(&Value::Object(req)).map_err(|_| EngineError::Extractor)?;
8826        writeln!(self.writer, "{req_line}").map_err(|_| EngineError::Extractor)?;
8827        self.writer.flush().map_err(|_| EngineError::Extractor)?;
8828
8829        let result_line = recv_extractor_line(&self.line_rx, self.io_timeout)?;
8830        let result: Value =
8831            serde_json::from_str(result_line.trim()).map_err(|_| EngineError::Extractor)?;
8832        let resp_type = result.get("type").and_then(|v| v.as_str());
8833        let resp_id = result.get("request_id").and_then(|v| v.as_str());
8834        if resp_type != Some("result") || resp_id != Some(request_id) {
8835            return Err(EngineError::Extractor);
8836        }
8837        Ok(result)
8838    }
8839}
8840
8841/// fix-35 [P2]: BYO-LLM extractor I/O timeout. Defaults to 300s to accommodate
8842/// slow LLM harnesses; override (in milliseconds) via
8843/// `FATHOMDB_EXTRACTOR_TIMEOUT_MS` (tests use this to exercise the hung-harness
8844/// path quickly).
8845fn extractor_io_timeout() -> Duration {
8846    std::env::var("FATHOMDB_EXTRACTOR_TIMEOUT_MS")
8847        .ok()
8848        .and_then(|s| s.parse::<u64>().ok())
8849        .map(Duration::from_millis)
8850        .unwrap_or_else(|| Duration::from_secs(300))
8851}
8852
8853/// fix-35 [P1/P2]: receive one line from the stdout reader thread, bounded by
8854/// `timeout`. A timeout, a closed channel (reader thread ended / child EOF), or
8855/// an underlying io error all map to [`EngineError::Extractor`].
8856fn recv_extractor_line(
8857    rx: &Receiver<std::io::Result<String>>,
8858    timeout: Duration,
8859) -> Result<String, EngineError> {
8860    match rx.recv_timeout(timeout) {
8861        Ok(Ok(line)) => Ok(line),
8862        _ => Err(EngineError::Extractor),
8863    }
8864}
8865
8866fn map_runtime_embedder_error(err: RuntimeEmbedderError) -> EngineError {
8867    match err {
8868        RuntimeEmbedderError::Failed { .. } | RuntimeEmbedderError::Timeout => {
8869            EngineError::Embedder
8870        }
8871    }
8872}
8873
8874fn default_embedder_identity() -> EmbedderIdentity {
8875    EmbedderIdentity::new(
8876        DEFAULT_EMBEDDER_NAME,
8877        DEFAULT_EMBEDDER_REVISION,
8878        DEFAULT_EMBEDDER_DIMENSION,
8879    )
8880}
8881
8882fn check_embedder_profile(
8883    connection: &Connection,
8884    supplied: &EmbedderIdentity,
8885) -> Result<bool, EngineOpenError> {
8886    // Returns `true` iff `_fathomdb_embedder_profiles.mean_vec IS NOT NULL`
8887    // for the default profile (and its byte length matches `4 * dimension`
8888    // per `dev/design/embedder.md` §0.2). EU-5a2: column lands in step 10.
8889    let mut statement = match connection.prepare(
8890        "SELECT name, revision, dimension, mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
8891    ) {
8892        Ok(statement) => statement,
8893        Err(_) => return Ok(false),
8894    };
8895    let mut rows = statement.query([]).map_err(|_| {
8896        EngineOpenError::Corruption(CorruptionDetail {
8897            kind: CorruptionKind::EmbedderIdentityDrift,
8898            stage: OpenStage::EmbedderIdentity,
8899            locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
8900            recovery_hint: RecoveryHint {
8901                code: "E_CORRUPT_EMBEDDER_IDENTITY",
8902                doc_anchor: "design/recovery.md#embedder-identity-drift",
8903            },
8904        })
8905    })?;
8906
8907    let Some(row) = rows.next().map_err(|_| {
8908        EngineOpenError::Corruption(CorruptionDetail {
8909            kind: CorruptionKind::EmbedderIdentityDrift,
8910            stage: OpenStage::EmbedderIdentity,
8911            locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
8912            recovery_hint: RecoveryHint {
8913                code: "E_CORRUPT_EMBEDDER_IDENTITY",
8914                doc_anchor: "design/recovery.md#embedder-identity-drift",
8915            },
8916        })
8917    })?
8918    else {
8919        connection
8920            .execute(
8921                "INSERT INTO _fathomdb_embedder_profiles(profile, name, revision, dimension)
8922                 VALUES(?1, ?2, ?3, ?4)",
8923                params![
8924                    DEFAULT_VECTOR_PROFILE,
8925                    supplied.name,
8926                    supplied.revision,
8927                    supplied.dimension
8928                ],
8929            )
8930            .map_err(|_| EngineOpenError::Io {
8931                message: "could not persist embedder profile".to_string(),
8932            })?;
8933        return Ok(false);
8934    };
8935
8936    let stored_name = row.get::<_, String>(0).map_err(|_| {
8937        EngineOpenError::Corruption(CorruptionDetail {
8938            kind: CorruptionKind::EmbedderIdentityDrift,
8939            stage: OpenStage::EmbedderIdentity,
8940            locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
8941            recovery_hint: RecoveryHint {
8942                code: "E_CORRUPT_EMBEDDER_IDENTITY",
8943                doc_anchor: "design/recovery.md#embedder-identity-drift",
8944            },
8945        })
8946    })?;
8947    let stored_revision = row.get::<_, String>(1).map_err(|_| {
8948        EngineOpenError::Corruption(CorruptionDetail {
8949            kind: CorruptionKind::EmbedderIdentityDrift,
8950            stage: OpenStage::EmbedderIdentity,
8951            locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
8952            recovery_hint: RecoveryHint {
8953                code: "E_CORRUPT_EMBEDDER_IDENTITY",
8954                doc_anchor: "design/recovery.md#embedder-identity-drift",
8955            },
8956        })
8957    })?;
8958    let dimension = row.get::<_, u32>(2).map_err(|_| {
8959        EngineOpenError::Corruption(CorruptionDetail {
8960            kind: CorruptionKind::EmbedderIdentityDrift,
8961            stage: OpenStage::EmbedderIdentity,
8962            locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
8963            recovery_hint: RecoveryHint {
8964                code: "E_CORRUPT_EMBEDDER_IDENTITY",
8965                doc_anchor: "design/recovery.md#embedder-identity-drift",
8966            },
8967        })
8968    })?;
8969
8970    let stored = EmbedderIdentity::new(stored_name, stored_revision, dimension);
8971
8972    if stored.name != supplied.name || stored.revision != supplied.revision {
8973        return Err(EngineOpenError::EmbedderIdentityMismatch {
8974            stored,
8975            supplied: supplied.clone(),
8976        });
8977    }
8978    if dimension != supplied.dimension {
8979        return Err(EngineOpenError::EmbedderDimensionMismatch {
8980            stored: dimension,
8981            supplied: supplied.dimension,
8982        });
8983    }
8984
8985    // EU-5a2 / `dev/design/embedder.md` §0.2 invariant: if `mean_vec` is
8986    // populated, byte length MUST equal `4 * dimension`. Debug builds
8987    // assert; release builds fail closed via EmbedderIdentityMismatch
8988    // (the same fail-closed channel the rest of profile drift takes).
8989    let mean_vec: Option<Vec<u8>> = row.get::<_, Option<Vec<u8>>>(3).map_err(|_| {
8990        EngineOpenError::Corruption(CorruptionDetail {
8991            kind: CorruptionKind::EmbedderIdentityDrift,
8992            stage: OpenStage::EmbedderIdentity,
8993            locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
8994            recovery_hint: RecoveryHint {
8995                code: "E_CORRUPT_EMBEDDER_IDENTITY",
8996                doc_anchor: "design/recovery.md#embedder-identity-drift",
8997            },
8998        })
8999    })?;
9000    let pinned = match mean_vec {
9001        Some(bytes) => {
9002            let expected_len = (dimension as usize).saturating_mul(4);
9003            // `dev/design/embedder.md` §0.2 invariant: when populated,
9004            // `mean_vec` byte length MUST equal `4 * dimension`. Fail
9005            // closed via the existing identity-drift channel in both
9006            // debug and release builds — tests deliberately poke
9007            // malformed values to exercise this branch.
9008            if bytes.len() != expected_len {
9009                return Err(EngineOpenError::EmbedderIdentityMismatch {
9010                    stored,
9011                    supplied: supplied.clone(),
9012                });
9013            }
9014            true
9015        }
9016        None => false,
9017    };
9018
9019    Ok(pinned)
9020}
9021
9022#[derive(Clone, Debug, Eq, PartialEq)]
9023enum WritePlan {
9024    Node,
9025    Edge,
9026    AppendOnlyLog,
9027    LatestState,
9028    AdminSchema,
9029}
9030
9031fn validate_batch(
9032    connection: &Connection,
9033    batch: &[PreparedWrite],
9034) -> Result<Vec<WritePlan>, EngineError> {
9035    batch.iter().map(|write| validate_write(connection, write)).collect()
9036}
9037
9038fn collect_projection_jobs(
9039    connection: &Connection,
9040    batch: &[PreparedWrite],
9041) -> Result<Vec<ProjectionJob>, EngineError> {
9042    let mut jobs = Vec::new();
9043    for write in batch {
9044        if let PreparedWrite::Node { kind, body, .. } = write {
9045            if kind_is_vector_indexed(connection, kind)? {
9046                jobs.push(ProjectionJob { cursor: 0, kind: kind.clone(), body: body.clone() });
9047            }
9048        }
9049    }
9050    Ok(jobs)
9051}
9052
9053fn validate_write(
9054    connection: &Connection,
9055    write: &PreparedWrite,
9056) -> Result<WritePlan, EngineError> {
9057    match write {
9058        PreparedWrite::Node { kind, body, source_id, logical_id } => {
9059            if kind.trim().is_empty() || body.trim().is_empty() {
9060                return Err(EngineError::WriteValidation);
9061            }
9062            if let Some(source_id) = source_id {
9063                if source_id.is_empty() {
9064                    return Err(EngineError::WriteValidation);
9065                }
9066            }
9067            // G0 — an explicit logical_id must be non-empty (NULL/None is the
9068            // legacy default; an empty string is never a valid identity).
9069            // Also reject char(30) = \x1e (ASCII RS), which is the BFS cycle-guard
9070            // delimiter; allowing it would corrupt the visited-path substring test.
9071            if let Some(logical_id) = logical_id {
9072                if logical_id.is_empty() || logical_id.contains('\x1e') {
9073                    return Err(EngineError::WriteValidation);
9074                }
9075            }
9076            Ok(WritePlan::Node)
9077        }
9078        PreparedWrite::Edge { kind, from, to, source_id, logical_id, .. } => {
9079            if kind.trim().is_empty() || from.trim().is_empty() || to.trim().is_empty() {
9080                return Err(EngineError::WriteValidation);
9081            }
9082            // Reject char(30) in from/to: these become from_id/to_id in canonical_edges
9083            // and appear in BFS visited strings — an \x1e there would corrupt the guard.
9084            if from.contains('\x1e') || to.contains('\x1e') {
9085                return Err(EngineError::WriteValidation);
9086            }
9087            if let Some(source_id) = source_id {
9088                if source_id.is_empty() {
9089                    return Err(EngineError::WriteValidation);
9090                }
9091            }
9092            if let Some(logical_id) = logical_id {
9093                if logical_id.is_empty() || logical_id.contains('\x1e') {
9094                    return Err(EngineError::WriteValidation);
9095                }
9096            }
9097            Ok(WritePlan::Edge)
9098        }
9099        PreparedWrite::AdminSchema { name, kind, schema_json, retention_json } => {
9100            if name.trim().is_empty()
9101                || !matches!(kind.as_str(), "append_only_log" | "latest_state")
9102                || serde_json::from_str::<Value>(schema_json).is_err()
9103                || serde_json::from_str::<Value>(retention_json).is_err()
9104                || contains_external_ref(schema_json)
9105            {
9106                return Err(EngineError::SchemaValidation);
9107            }
9108            Ok(WritePlan::AdminSchema)
9109        }
9110        PreparedWrite::OpStore { collection, record_key, schema_id, body } => {
9111            if collection.trim().is_empty() || record_key.trim().is_empty() {
9112                return Err(EngineError::WriteValidation);
9113            }
9114            let (kind, schema_json) = collection_metadata(connection, collection)?;
9115            if let Some(schema_id) = schema_id {
9116                if schema_id != collection {
9117                    return Err(EngineError::SchemaValidation);
9118                }
9119                validate_payload(&schema_json, body)?;
9120            } else if serde_json::from_str::<Value>(body).is_err() {
9121                return Err(EngineError::SchemaValidation);
9122            }
9123
9124            match kind.as_str() {
9125                "append_only_log" => Ok(WritePlan::AppendOnlyLog),
9126                "latest_state" => Ok(WritePlan::LatestState),
9127                _ => Err(EngineError::OpStore),
9128            }
9129        }
9130    }
9131}
9132
9133fn collection_metadata(
9134    connection: &Connection,
9135    collection: &str,
9136) -> Result<(String, String), EngineError> {
9137    connection
9138        .query_row(
9139            "SELECT kind, schema_json FROM operational_collections WHERE name = ?1",
9140            [collection],
9141            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
9142        )
9143        .map_err(|_| EngineError::OpStore)
9144}
9145
9146fn validate_payload(schema_json: &str, body: &str) -> Result<(), EngineError> {
9147    let schema =
9148        serde_json::from_str::<Value>(schema_json).map_err(|_| EngineError::SchemaValidation)?;
9149    let payload = serde_json::from_str::<Value>(body).map_err(|_| EngineError::SchemaValidation)?;
9150
9151    let compiled = JSONSchema::compile(&schema).map_err(|_| EngineError::SchemaValidation)?;
9152    compiled.validate(&payload).map_err(|_| EngineError::SchemaValidation)?;
9153
9154    Ok(())
9155}
9156
9157fn contains_external_ref(schema_json: &str) -> bool {
9158    let Ok(value) = serde_json::from_str::<Value>(schema_json) else {
9159        return false;
9160    };
9161    value_contains_external_ref(&value)
9162}
9163
9164fn value_contains_external_ref(value: &Value) -> bool {
9165    match value {
9166        Value::Object(object) => object.iter().any(|(key, value)| {
9167            if key == "$ref" {
9168                return value.as_str().is_some_and(|uri| !uri.starts_with('#'));
9169            }
9170            value_contains_external_ref(value)
9171        }),
9172        Value::Array(values) => values.iter().any(value_contains_external_ref),
9173        _ => false,
9174    }
9175}
9176
9177// fix-30 [P2]: helpers to collect active edge write_cursors BEFORE a supersession
9178// UPDATE so the callers can prune stale vector_default rows.
9179fn prior_edge_cursors_by_logical_id(
9180    tx: &rusqlite::Transaction<'_>,
9181    logical_id: &str,
9182) -> rusqlite::Result<Vec<i64>> {
9183    let mut s = tx.prepare_cached(
9184        "SELECT write_cursor FROM canonical_edges \
9185         WHERE logical_id = ?1 AND superseded_at IS NULL",
9186    )?;
9187    let rows = s.query_map(params![logical_id], |r| r.get(0))?;
9188    rows.collect()
9189}
9190
9191fn prior_edge_cursors_by_triple(
9192    tx: &rusqlite::Transaction<'_>,
9193    from: &str,
9194    to: &str,
9195    kind: &str,
9196) -> rusqlite::Result<Vec<i64>> {
9197    let mut s = tx.prepare_cached(
9198        "SELECT write_cursor FROM canonical_edges \
9199         WHERE from_id = ?1 AND to_id = ?2 AND kind = ?3 AND superseded_at IS NULL",
9200    )?;
9201    let rows = s.query_map(params![from, to, kind], |r| r.get(0))?;
9202    rows.collect()
9203}
9204
9205fn commit_batch(
9206    connection: &mut Connection,
9207    batch: &[PreparedWrite],
9208    plans: &[WritePlan],
9209    base_cursor: u64,
9210    provenance_row_cap: u64,
9211) -> rusqlite::Result<u64> {
9212    let tx = connection.transaction()?;
9213
9214    for (i, (write, plan)) in batch.iter().zip(plans).enumerate() {
9215        // Per-row cursor: row i gets `base_cursor + i + 1`. See the
9216        // comment in `Engine::write_inner`.
9217        let cursor = base_cursor.saturating_add((i as u64).saturating_add(1));
9218        match (write, plan) {
9219            (PreparedWrite::Node { kind, body, source_id, logical_id }, WritePlan::Node) => {
9220                // G0 — supersession is tombstone-then-insert in this same txn:
9221                // mark the prior active version superseded BEFORE inserting the
9222                // new active row, so the partial-unique-active index never sees
9223                // two active rows for one logical_id. Scoped to logical_id ALONE
9224                // (Decision 5, HITL-SIGNED 2026-06-05): a kind-change re-ingest of
9225                // the same logical_id SUPERSEDES, never forks. No-op when logical_id
9226                // is None (legacy/own-identity insert, behavior-identical to 0.7.x).
9227                if let Some(logical_id) = logical_id {
9228                    tx.execute(
9229                        "UPDATE canonical_nodes SET superseded_at = ?1
9230                         WHERE logical_id = ?2 AND superseded_at IS NULL",
9231                        params![cursor, logical_id],
9232                    )?;
9233                }
9234                tx.execute(
9235                    "INSERT INTO canonical_nodes(write_cursor, kind, body, source_id, logical_id)
9236                     VALUES(?1, ?2, ?3, ?4, ?5)",
9237                    params![cursor, kind, body, source_id, logical_id],
9238                )?;
9239                tx.execute(
9240                    "INSERT INTO search_index(body, kind, write_cursor) VALUES(?1, ?2, ?3)",
9241                    params![body, kind, cursor],
9242                )?;
9243                if kind_is_vector_indexed(&tx, kind).unwrap_or(false) {
9244                    tx.execute(
9245                        "INSERT INTO _fathomdb_projection_state(kind, last_enqueued_cursor, updated_at)
9246                         VALUES(?1, ?2, 0)
9247                         ON CONFLICT(kind) DO UPDATE SET last_enqueued_cursor = excluded.last_enqueued_cursor",
9248                        params![kind, cursor],
9249                    )?;
9250                } else {
9251                    // Non-vector-indexed nodes will never be projected,
9252                    // so terminate the cursor up-front to let
9253                    // `advance_projection_cursor` walk past it.
9254                    record_projection_terminal(&tx, cursor, "up_to_date")?;
9255                }
9256            }
9257            (
9258                PreparedWrite::Edge {
9259                    kind,
9260                    from,
9261                    to,
9262                    source_id,
9263                    logical_id,
9264                    body,
9265                    t_valid,
9266                    t_invalid,
9267                    confidence,
9268                    extractor_model_id,
9269                    temporal_fallback,
9270                },
9271                WritePlan::Edge,
9272            ) => {
9273                // G0 — identical tombstone-then-insert supersession on edges,
9274                // keyed by logical_id ALONE (Decision 5, HITL-SIGNED 2026-06-05;
9275                // edge `kind` is relationship-type, not identity — a kind-change
9276                // re-ingest of the same edge logical_id SUPERSEDES, never forks).
9277                // No-op when logical_id is None.
9278                if let Some(logical_id) = logical_id {
9279                    // fix-30 [P2]: collect prior active cursors BEFORE tombstoning
9280                    // so stale vector_default rows can be pruned.
9281                    let prior_g0 = prior_edge_cursors_by_logical_id(&tx, logical_id)?;
9282                    tx.execute(
9283                        "UPDATE canonical_edges SET superseded_at = ?1
9284                         WHERE logical_id = ?2 AND superseded_at IS NULL",
9285                        params![cursor, logical_id],
9286                    )?;
9287                    for sc in &prior_g0 {
9288                        tx.execute("DELETE FROM vector_default WHERE rowid = ?1", [sc])?;
9289                        tx.execute(
9290                            "DELETE FROM _fathomdb_vector_rows WHERE write_cursor = ?1",
9291                            [sc],
9292                        )?;
9293                        // fix-32 [P2]: record terminal so advance_projection_cursor
9294                        // can walk past this now-superseded cursor.
9295                        record_projection_terminal(&tx, *sc as u64, "superseded")?;
9296                    }
9297                }
9298                // G11 — invalidate-not-accumulate: for fact-edges (body IS NOT NULL),
9299                // tombstone any prior active edge on the same (from_id, to_id, kind)
9300                // BEFORE inserting the new row. This is DIFFERENT from the G0
9301                // logical_id tombstone: it is keyed on the triple, not the identity.
9302                // Regular edges (body=None) skip this path — they retain G0 semantics.
9303                if body.is_some() {
9304                    // fix-30 [P2]: collect and prune vector shadow for the superseded edge.
9305                    let prior_g11 = prior_edge_cursors_by_triple(&tx, from, to, kind)?;
9306                    tx.execute(
9307                        "UPDATE canonical_edges SET superseded_at = ?1
9308                         WHERE from_id = ?2 AND to_id = ?3 AND kind = ?4 AND superseded_at IS NULL",
9309                        params![cursor, from, to, kind],
9310                    )?;
9311                    for sc in &prior_g11 {
9312                        tx.execute("DELETE FROM vector_default WHERE rowid = ?1", [sc])?;
9313                        tx.execute(
9314                            "DELETE FROM _fathomdb_vector_rows WHERE write_cursor = ?1",
9315                            [sc],
9316                        )?;
9317                        // fix-32 [P2]: mark terminal so projection cursor can advance.
9318                        record_projection_terminal(&tx, *sc as u64, "superseded")?;
9319                    }
9320                }
9321                let temporal_fallback_i: Option<i64> =
9322                    temporal_fallback.and_then(|f| if f { Some(1) } else { None });
9323                tx.execute(
9324                    "INSERT INTO canonical_edges(
9325                         write_cursor, kind, from_id, to_id, source_id, logical_id,
9326                         body, t_valid, t_invalid, confidence, extractor_model_id,
9327                         temporal_fallback
9328                     ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
9329                    params![
9330                        cursor,
9331                        kind,
9332                        from,
9333                        to,
9334                        source_id,
9335                        logical_id,
9336                        body,
9337                        t_valid,
9338                        t_invalid,
9339                        confidence,
9340                        extractor_model_id,
9341                        temporal_fallback_i
9342                    ],
9343                )?;
9344                // G11 — edge FTS projection into `search_index_edges` (separate
9345                // table from node-body `search_index` — Option B partition).
9346                if let Some(edge_body) = body.as_ref() {
9347                    tx.execute(
9348                        "INSERT INTO search_index_edges(body, kind, write_cursor)
9349                         VALUES(?1, ?2, ?3)",
9350                        params![edge_body, kind, cursor],
9351                    )?;
9352                }
9353                // G11 — edge vector projection: enqueue for projection scheduler
9354                // under a fixed kind `"edge_fact"` (so resolve_source_type maps it
9355                // to `source_type = "edge_fact"` in vector_default). Auto-register
9356                // "edge_fact" in _fathomdb_vector_kinds (idempotent).
9357                if body.is_some() {
9358                    let now_unix =
9359                        SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
9360                            as i64;
9361                    tx.execute(
9362                        "INSERT OR IGNORE INTO _fathomdb_vector_kinds(kind, profile, created_at)
9363                         VALUES('edge_fact', 'default', ?1)",
9364                        params![now_unix],
9365                    )?;
9366                    tx.execute(
9367                        "INSERT INTO _fathomdb_projection_state(
9368                             kind, last_enqueued_cursor, updated_at
9369                         ) VALUES('edge_fact', ?1, 0)
9370                         ON CONFLICT(kind) DO UPDATE
9371                             SET last_enqueued_cursor = excluded.last_enqueued_cursor",
9372                        params![cursor],
9373                    )?;
9374                    // Do NOT call record_projection_terminal — let the scheduler
9375                    // embed the body and mark it terminal after projection.
9376                } else {
9377                    record_projection_terminal(&tx, cursor, "up_to_date")?;
9378                }
9379            }
9380            (
9381                PreparedWrite::AdminSchema { name, kind, schema_json, retention_json },
9382                WritePlan::AdminSchema,
9383            ) => {
9384                tx.execute(
9385                    "INSERT INTO operational_collections(
9386                        name, kind, schema_json, retention_json, format_version, created_at
9387                     ) VALUES(?1, ?2, ?3, ?4, 1, 0)
9388                     ON CONFLICT(name) DO UPDATE SET
9389                        schema_json = excluded.schema_json,
9390                        retention_json = excluded.retention_json",
9391                    params![name, kind, schema_json, retention_json],
9392                )?;
9393                record_projection_terminal(&tx, cursor, "up_to_date")?;
9394            }
9395            (
9396                PreparedWrite::OpStore { collection, record_key, schema_id, body },
9397                WritePlan::AppendOnlyLog,
9398            ) => {
9399                tx.execute(
9400                    "INSERT INTO operational_mutations(
9401                        collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
9402                     ) VALUES(?1, ?2, 'append', ?3, ?4, ?5)",
9403                    params![collection, record_key, body, schema_id, cursor],
9404                )?;
9405                record_projection_terminal(&tx, cursor, "up_to_date")?;
9406            }
9407            (
9408                PreparedWrite::OpStore { collection, record_key, schema_id, body },
9409                WritePlan::LatestState,
9410            ) => {
9411                tx.execute(
9412                    "INSERT INTO operational_state(
9413                        collection_name, record_key, payload_json, schema_id, write_cursor
9414                     ) VALUES(?1, ?2, ?3, ?4, ?5)
9415                     ON CONFLICT(collection_name, record_key) DO UPDATE SET
9416                        payload_json = excluded.payload_json,
9417                        schema_id = excluded.schema_id,
9418                        write_cursor = excluded.write_cursor",
9419                    params![collection, record_key, body, schema_id, cursor],
9420                )?;
9421                record_projection_terminal(&tx, cursor, "up_to_date")?;
9422            }
9423            _ => return Err(rusqlite::Error::InvalidQuery),
9424        }
9425    }
9426
9427    // G8 (Slice 20 / F10) — cross-row dangling-edge flag-and-count. This runs
9428    // AFTER the batch loop (so every same-batch node is already on disk in `tx`
9429    // and a same-batch later-inserted endpoint is visible) and BEFORE retention /
9430    // projection-cursor / commit. It is the cross-row reason this lives here and
9431    // not in single-row pre-insert `validate_write`. Default is FLAG-AND-COUNT:
9432    // we only COUNT, never roll back (strict-mode rollback is deferred to
9433    // reserved-gap band 22 — adding a write-options surface is out of scope).
9434    //
9435    // Probe is `logical_id`-alone against the step-12 partial index
9436    // `canonical_nodes_logical_active_idx ON canonical_nodes(logical_id)
9437    // WHERE superseded_at IS NULL` (its leading column + partial predicate), so
9438    // it SEARCHes the index with no SCAN (see `tests/pr_g8_dangling_edges.rs`
9439    // case (f)). There is no node-kind to match: `canonical_edges` stores only
9440    // the edge's own kind, not the endpoint node's kind.
9441    let dangling_edge_endpoints = {
9442        // O(N) pre-pass: record, per `logical_id`, the LAST (highest) index at
9443        // which an `Edge { logical_id: Some(_), .. }` with that id appears. Keyed
9444        // by `logical_id` ALONE (Decision 5, HITL-SIGNED 2026-06-05) to match the
9445        // supersession UPDATE, which keys by logical_id alone: a kind-change
9446        // re-ingest of the same edge logical_id SUPERSEDES the earlier one.
9447        // Iterating front-to-back and overwriting means the stored value ends up
9448        // as the final index for each id. An edge at index `i` with that id is
9449        // then in-batch-superseded iff `last_index[lid] > i`. This is
9450        // behavior-identical to the prior per-edge `batch[i+1..]` `.any(..)` scan
9451        // (which was O(N²) under the single-writer txn) — same skip-set, same count.
9452        let mut last_index: HashMap<&str, usize> = HashMap::new();
9453        for (i, write) in batch.iter().enumerate() {
9454            if let PreparedWrite::Edge { logical_id: Some(lid), .. } = write {
9455                last_index.insert(lid.as_str(), i);
9456            }
9457        }
9458
9459        let mut probe = tx.prepare(
9460            "SELECT 1 FROM canonical_nodes WHERE logical_id = ?1 AND superseded_at IS NULL LIMIT 1",
9461        )?;
9462        let mut count: u64 = 0;
9463        for (i, write) in batch.iter().enumerate() {
9464            if let PreparedWrite::Edge { from, to, logical_id, .. } = write {
9465                // Honor `edge.superseded_at IS NULL`: an edge inserted in this
9466                // batch is active unless a LATER same-batch edge with the same
9467                // `Some(logical_id)` tombstoned it (the loop's supersession
9468                // UPDATE). Skip such an in-batch-superseded edge. Edges with
9469                // `logical_id: None` are never superseded-in-batch.
9470                if let Some(lid) = logical_id {
9471                    let superseded_in_batch =
9472                        last_index.get(lid.as_str()).is_some_and(|&last| last > i);
9473                    if superseded_in_batch {
9474                        continue;
9475                    }
9476                }
9477                // Probe `from_id` and `to_id` independently (0, 1, or 2 per edge).
9478                for endpoint in [from, to] {
9479                    if !probe.exists(params![endpoint])? {
9480                        count = count.saturating_add(1);
9481                    }
9482                }
9483            }
9484        }
9485        count
9486    };
9487
9488    enforce_provenance_retention(&tx, provenance_row_cap)?;
9489    advance_projection_cursor(&tx)?;
9490
9491    tx.commit()?;
9492    Ok(dangling_edge_endpoints)
9493}
9494
9495fn load_next_cursor(connection: &Connection) -> u64 {
9496    let nodes = max_cursor(connection, "canonical_nodes").unwrap_or(0);
9497    let edges = max_cursor(connection, "canonical_edges").unwrap_or(0);
9498    let mutations = max_cursor(connection, "operational_mutations").unwrap_or(0);
9499    let state = max_cursor(connection, "operational_state").unwrap_or(0);
9500    nodes.max(edges).max(mutations).max(state)
9501}
9502
9503fn max_cursor(connection: &Connection, table: &str) -> rusqlite::Result<u64> {
9504    let sql = format!("SELECT COALESCE(MAX(write_cursor), 0) FROM {table}");
9505    connection.query_row(&sql, [], |row| row.get::<_, u64>(0))
9506}
9507
9508/// Map a rusqlite error to its stable SQLite extended-code name.
9509///
9510/// Returns `None` for non-`SqliteFailure` variants (e.g. JSON conversion
9511/// failures, type mismatches at the rusqlite layer) — those are not
9512/// SQLite-internal events and should not be surfaced under
9513/// `EventSource::SqliteInternal`. The names returned here are the
9514/// canonical `SQLITE_*` symbol names from `sqlite3.h` and are stable
9515/// dispatch keys for AC-021 / AC-006 binding adapters.
9516///
9517/// Only the subset of codes the engine can reach in 0.6.0 is enumerated
9518/// — bare-extended-code matching covers the rest with a stable
9519/// `"SQLITE_UNKNOWN"` fallback so subscribers always see a typed code.
9520///
9521/// Diagnostic completeness for unmapped codes: when this helper returns
9522/// `"SQLITE_UNKNOWN"`, the numeric extended code is not lost — it
9523/// remains on the underlying `rusqlite::Error::SqliteFailure` carried
9524/// in the engine error chain that subscribers can inspect via
9525/// `EngineError`'s `source()`. Expanding the enumerated subset (or
9526/// surfacing the numeric code as a typed payload field) is a 0.7+
9527/// improvement.
9528fn sqlite_extended_code_name(err: &rusqlite::Error) -> Option<&'static str> {
9529    let sqlite_error = err.sqlite_error()?;
9530    let extended = sqlite_error.extended_code;
9531    Some(match extended {
9532        rusqlite::ffi::SQLITE_SCHEMA => "SQLITE_SCHEMA",
9533        rusqlite::ffi::SQLITE_BUSY => "SQLITE_BUSY",
9534        rusqlite::ffi::SQLITE_LOCKED => "SQLITE_LOCKED",
9535        rusqlite::ffi::SQLITE_CORRUPT => "SQLITE_CORRUPT",
9536        rusqlite::ffi::SQLITE_NOTADB => "SQLITE_NOTADB",
9537        rusqlite::ffi::SQLITE_IOERR => "SQLITE_IOERR",
9538        rusqlite::ffi::SQLITE_FULL => "SQLITE_FULL",
9539        rusqlite::ffi::SQLITE_READONLY => "SQLITE_READONLY",
9540        rusqlite::ffi::SQLITE_CONSTRAINT => "SQLITE_CONSTRAINT",
9541        rusqlite::ffi::SQLITE_MISUSE => "SQLITE_MISUSE",
9542        rusqlite::ffi::SQLITE_INTERRUPT => "SQLITE_INTERRUPT",
9543        rusqlite::ffi::SQLITE_NOMEM => "SQLITE_NOMEM",
9544        rusqlite::ffi::SQLITE_PERM => "SQLITE_PERM",
9545        rusqlite::ffi::SQLITE_ABORT => "SQLITE_ABORT",
9546        rusqlite::ffi::SQLITE_PROTOCOL => "SQLITE_PROTOCOL",
9547        rusqlite::ffi::SQLITE_RANGE => "SQLITE_RANGE",
9548        rusqlite::ffi::SQLITE_TOOBIG => "SQLITE_TOOBIG",
9549        rusqlite::ffi::SQLITE_MISMATCH => "SQLITE_MISMATCH",
9550        rusqlite::ffi::SQLITE_AUTH => "SQLITE_AUTH",
9551        rusqlite::ffi::SQLITE_NOTFOUND => "SQLITE_NOTFOUND",
9552        rusqlite::ffi::SQLITE_CANTOPEN => "SQLITE_CANTOPEN",
9553        _ => "SQLITE_UNKNOWN",
9554    })
9555}
9556
9557fn sqlite_extended_code_name_from_int(extended: i32) -> &'static str {
9558    match extended {
9559        rusqlite::ffi::SQLITE_SCHEMA => "SQLITE_SCHEMA",
9560        rusqlite::ffi::SQLITE_BUSY => "SQLITE_BUSY",
9561        rusqlite::ffi::SQLITE_LOCKED => "SQLITE_LOCKED",
9562        rusqlite::ffi::SQLITE_CORRUPT => "SQLITE_CORRUPT",
9563        rusqlite::ffi::SQLITE_NOTADB => "SQLITE_NOTADB",
9564        rusqlite::ffi::SQLITE_IOERR => "SQLITE_IOERR",
9565        rusqlite::ffi::SQLITE_FULL => "SQLITE_FULL",
9566        rusqlite::ffi::SQLITE_READONLY => "SQLITE_READONLY",
9567        rusqlite::ffi::SQLITE_CONSTRAINT => "SQLITE_CONSTRAINT",
9568        rusqlite::ffi::SQLITE_MISUSE => "SQLITE_MISUSE",
9569        rusqlite::ffi::SQLITE_INTERRUPT => "SQLITE_INTERRUPT",
9570        rusqlite::ffi::SQLITE_NOMEM => "SQLITE_NOMEM",
9571        rusqlite::ffi::SQLITE_PERM => "SQLITE_PERM",
9572        rusqlite::ffi::SQLITE_ABORT => "SQLITE_ABORT",
9573        rusqlite::ffi::SQLITE_PROTOCOL => "SQLITE_PROTOCOL",
9574        rusqlite::ffi::SQLITE_RANGE => "SQLITE_RANGE",
9575        rusqlite::ffi::SQLITE_TOOBIG => "SQLITE_TOOBIG",
9576        rusqlite::ffi::SQLITE_MISMATCH => "SQLITE_MISMATCH",
9577        rusqlite::ffi::SQLITE_AUTH => "SQLITE_AUTH",
9578        rusqlite::ffi::SQLITE_NOTFOUND => "SQLITE_NOTFOUND",
9579        rusqlite::ffi::SQLITE_CANTOPEN => "SQLITE_CANTOPEN",
9580        _ => "SQLITE_UNKNOWN",
9581    }
9582}
9583
9584fn map_open_sqlite_error(err: rusqlite::Error, stage: OpenStage) -> EngineOpenError {
9585    let Some(sqlite_error) = err.sqlite_error() else {
9586        return EngineOpenError::Io { message: "could not open database".to_string() };
9587    };
9588    match sqlite_error.extended_code {
9589        rusqlite::ffi::SQLITE_CORRUPT | rusqlite::ffi::SQLITE_NOTADB => {
9590            EngineOpenError::Corruption(CorruptionDetail {
9591                kind: match stage {
9592                    OpenStage::WalReplay => CorruptionKind::WalReplayFailure,
9593                    OpenStage::HeaderProbe => CorruptionKind::HeaderMalformed,
9594                    OpenStage::SchemaProbe => CorruptionKind::SchemaInconsistent,
9595                    OpenStage::EmbedderIdentity => CorruptionKind::EmbedderIdentityDrift,
9596                },
9597                stage,
9598                locator: CorruptionLocator::OpaqueSqliteError {
9599                    sqlite_extended_code: sqlite_error.extended_code,
9600                },
9601                recovery_hint: RecoveryHint {
9602                    code: match stage {
9603                        OpenStage::WalReplay => "E_CORRUPT_WAL_REPLAY",
9604                        OpenStage::HeaderProbe => "E_CORRUPT_HEADER",
9605                        OpenStage::SchemaProbe => "E_CORRUPT_SCHEMA",
9606                        OpenStage::EmbedderIdentity => "E_CORRUPT_EMBEDDER_IDENTITY",
9607                    },
9608                    doc_anchor: match stage {
9609                        OpenStage::WalReplay => "design/recovery.md#wal-replay-failures",
9610                        OpenStage::HeaderProbe => "design/recovery.md#header-malformed",
9611                        OpenStage::SchemaProbe => "design/recovery.md#schema-inconsistent",
9612                        OpenStage::EmbedderIdentity => "design/recovery.md#embedder-identity-drift",
9613                    },
9614                },
9615            })
9616        }
9617        _ => EngineOpenError::Io { message: "could not open database".to_string() },
9618    }
9619}
9620
9621fn emit_open_error_event(subscriber: &Arc<dyn lifecycle::Subscriber>, err: &EngineOpenError) {
9622    if let EngineOpenError::Corruption(detail) = err {
9623        let code = match detail.locator {
9624            CorruptionLocator::OpaqueSqliteError { sqlite_extended_code } => {
9625                Some(sqlite_extended_code_name_from_int(sqlite_extended_code))
9626            }
9627            _ => None,
9628        };
9629        let event = lifecycle::Event {
9630            phase: lifecycle::Phase::Failed,
9631            source: lifecycle::EventSource::SqliteInternal,
9632            category: lifecycle::EventCategory::Corruption,
9633            code,
9634        };
9635        subscriber.on_event(&event);
9636    }
9637}
9638
9639/// Install a `sqlite3_profile` callback on `connection` that dispatches
9640/// per-statement profile records and slow-statement signals to the
9641/// engine's subscriber registry.
9642///
9643/// Why FFI rather than `rusqlite::Connection::profile`: the safe API
9644/// (rusqlite 0.31) accepts only a `fn(&str, Duration)` with no
9645/// environment, so it cannot carry a per-engine subscriber-registry
9646/// pointer. We use `sqlite3_profile` directly with a leaked-into-`Box`
9647/// context whose pointer is tied to the engine's lifetime via
9648/// `Engine::profile_contexts`.
9649///
9650/// `sqlite3_profile` is documented as deprecated in favor of
9651/// `sqlite3_trace_v2`, but it remains supported and is sufficient for
9652/// the wall-clock + SQL-text payload required by AC-005a/b.
9653#[allow(clippy::vec_box)]
9654fn install_profile_callback(
9655    connection: &Connection,
9656    subscribers: &Arc<lifecycle::SubscriberRegistry>,
9657    profiling_enabled: &Arc<AtomicBool>,
9658    slow_threshold_ms: &Arc<AtomicU64>,
9659    contexts: &mut Vec<Box<ProfileContext>>,
9660) {
9661    let mut ctx = Box::new(ProfileContext {
9662        subscribers: Arc::clone(subscribers),
9663        profiling_enabled: Arc::clone(profiling_enabled),
9664        slow_threshold_ms: Arc::clone(slow_threshold_ms),
9665    });
9666    let ctx_ptr: *mut ProfileContext = &mut *ctx;
9667
9668    // SAFETY: the Box outlives the connection. Rust drops struct fields
9669    // in declaration order. `connection` and `reader_pool` are declared
9670    // before `profile_contexts`. `ReaderWorkerPool::Drop` joins every
9671    // reader worker, and each worker uninstalls and drops its owned
9672    // connection inside `reader_worker_loop` before the worker thread
9673    // returns. Therefore all connections — and SQLite's internal
9674    // profile-callback state with them — are torn down before the
9675    // `Box<ProfileContext>` allocations are freed. `Engine::close`
9676    // additionally clears the callback via
9677    // `sqlite3_profile(handle, None, NULL)` before connection close to
9678    // drain any in-flight callback dispatch.
9679    unsafe {
9680        rusqlite::ffi::sqlite3_profile(
9681            connection.handle(),
9682            Some(profile_callback_trampoline),
9683            ctx_ptr.cast::<std::ffi::c_void>(),
9684        );
9685    }
9686    contexts.push(ctx);
9687}
9688
9689/// Uninstall the profile callback so SQLite stops calling into our
9690/// freed `Box<ProfileContext>` pointer once a connection is being torn
9691/// down. Call before dropping `profile_contexts`.
9692fn uninstall_profile_callback(connection: &Connection) {
9693    // SAFETY: passing `None` as the callback unregisters the previous
9694    // callback; SQLite documents this as legal and idempotent.
9695    unsafe {
9696        rusqlite::ffi::sqlite3_profile(connection.handle(), None, std::ptr::null_mut());
9697    }
9698}
9699
9700/// Pack 6.G G.1 — configure SQLite per-connection lookaside on a reader
9701/// worker connection. Must be called BEFORE any statement is prepared
9702/// or any PRAGMA is run on `connection`; per the SQLite docs
9703/// (https://www.sqlite.org/malloc.html §3) lookaside is silently
9704/// ignored if reconfigured after the first allocation on the
9705/// connection. Passing `NULL` for the buffer pointer lets SQLite
9706/// allocate the lookaside backing memory itself.
9707///
9708/// rusqlite 0.31's `set_db_config` only handles the boolean
9709/// `DbConfig::*` variants; `SQLITE_DBCONFIG_LOOKASIDE` is not surfaced
9710/// (it is commented out in `rusqlite/src/config.rs`), so we call the
9711/// raw FFI directly.
9712///
9713/// Returns the rc of `sqlite3_db_config` so callers can debug-assert
9714/// `SQLITE_OK` and surface configuration failure under
9715/// `debug_assertions` test builds without expanding the public surface.
9716/// 0.7.0 perf-experiments hook: apply caller-supplied reader PRAGMAs
9717/// from the `FATHOMDB_PERF_READER_PRAGMAS` env var. Format:
9718/// comma-separated `name=value` pairs (e.g.
9719/// `cache_size=-262144,mmap_size=268435456,temp_store=MEMORY`).
9720///
9721/// **Gated on `FATHOMDB_PERF_EXPERIMENTS=1`.** No-op if the gate env
9722/// var is unset, so production paths are never affected. Failures to
9723/// apply individual PRAGMAs are logged to stderr (via `eprintln!`) but
9724/// do not error the connection open — experiments are best-effort,
9725/// not contract.
9726///
9727/// Scope: 0.7.0 perf-experiment campaign per
9728/// `dev/plans/0.7.0-perf-experiments.md`. Once Wave 5 picks the
9729/// landing combination, the chosen PRAGMAs are hardcoded as the new
9730/// reader-open default and this hook is removed.
9731/// 0.7.0 perf-experiments hook: apply writer-side PRAGMAs from
9732/// `FATHOMDB_PERF_WRITER_PRAGMAS` (same format as reader hook).
9733/// **Runs BEFORE migrations** so PRAGMAs like `page_size` that must
9734/// precede any table creation take effect on a fresh DB.
9735///
9736/// Gated on `FATHOMDB_PERF_EXPERIMENTS=1`. No-op otherwise.
9737fn apply_perf_experiment_writer_pragmas(connection: &Connection) {
9738    if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
9739        return;
9740    }
9741    let raw = match std::env::var("FATHOMDB_PERF_WRITER_PRAGMAS") {
9742        Ok(s) if !s.is_empty() => s,
9743        _ => return,
9744    };
9745    for entry in raw.split(',') {
9746        let entry = entry.trim();
9747        if entry.is_empty() {
9748            continue;
9749        }
9750        let (name, value) = match entry.split_once('=') {
9751            Some((n, v)) => (n.trim(), v.trim()),
9752            None => {
9753                eprintln!("perf-experiment: bad writer pragma entry (expect name=value): {entry}");
9754                continue;
9755            }
9756        };
9757        if name.is_empty() {
9758            eprintln!("perf-experiment: empty pragma name in writer entry: {entry}");
9759            continue;
9760        }
9761        match connection.pragma_update(None, name, value) {
9762            Ok(()) => {
9763                eprintln!(
9764                    "perf-experiment: applied PRAGMA {name}={value} on writer (pre-migration)"
9765                );
9766            }
9767            Err(err) => {
9768                eprintln!("perf-experiment: writer PRAGMA {name}={value} failed: {err}");
9769            }
9770        }
9771    }
9772}
9773
9774fn apply_perf_experiment_reader_pragmas(connection: &Connection) {
9775    if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
9776        return;
9777    }
9778    let raw = match std::env::var("FATHOMDB_PERF_READER_PRAGMAS") {
9779        Ok(s) if !s.is_empty() => s,
9780        _ => return,
9781    };
9782    for entry in raw.split(',') {
9783        let entry = entry.trim();
9784        if entry.is_empty() {
9785            continue;
9786        }
9787        let (name, value) = match entry.split_once('=') {
9788            Some((n, v)) => (n.trim(), v.trim()),
9789            None => {
9790                eprintln!("perf-experiment: bad pragma entry (expect name=value): {entry}");
9791                continue;
9792            }
9793        };
9794        if name.is_empty() {
9795            eprintln!("perf-experiment: empty pragma name in entry: {entry}");
9796            continue;
9797        }
9798        match connection.pragma_update(None, name, value) {
9799            Ok(()) => {
9800                eprintln!("perf-experiment: applied PRAGMA {name}={value} on reader");
9801            }
9802            Err(err) => {
9803                eprintln!("perf-experiment: PRAGMA {name}={value} failed: {err}");
9804            }
9805        }
9806    }
9807}
9808
9809fn configure_reader_lookaside(connection: &Connection) -> std::os::raw::c_int {
9810    // SAFETY: `connection.handle()` returns a valid `*mut sqlite3` for
9811    // the lifetime of `connection`. The variadic
9812    // `sqlite3_db_config(LOOKASIDE)` call expects three trailing
9813    // arguments of types `void*`, `int`, `int` — the prototype shape
9814    // documented in `sqlite3.h`. We pass a null buffer so SQLite owns
9815    // the lookaside backing allocation, and the slot size / count from
9816    // the G.1 constants. No allocations happen on the connection
9817    // before this call (reader open path is `Connection::open` ->
9818    // `configure_reader_lookaside` -> first PRAGMA).
9819    unsafe {
9820        rusqlite::ffi::sqlite3_db_config(
9821            connection.handle(),
9822            rusqlite::ffi::SQLITE_DBCONFIG_LOOKASIDE,
9823            std::ptr::null_mut::<std::ffi::c_void>(),
9824            READER_LOOKASIDE_SLOT_SIZE,
9825            READER_LOOKASIDE_SLOT_COUNT,
9826        )
9827    }
9828}
9829
9830/// Read the high-water-mark for `SQLITE_DBSTATUS_LOOKASIDE_USED` on
9831/// `connection`. The `current` out-param is the live checked-out slot
9832/// count and decays as transactions finalize, so it is unreliable as
9833/// post-warmup evidence. The `hiwtr` out-param latches the largest
9834/// observed `current` value since the last reset and is the right
9835/// signal that lookaside was honored at any point on this connection.
9836/// Reset flag is `0` so reading does not clear the high-water mark.
9837#[cfg(debug_assertions)]
9838fn read_lookaside_used_hiwtr(connection: &Connection) -> std::os::raw::c_int {
9839    let mut current: std::os::raw::c_int = 0;
9840    let mut hiwtr: std::os::raw::c_int = 0;
9841    // SAFETY: handle is valid; both out pointers are to local stack
9842    // ints; reset flag 0 is documented as legal.
9843    unsafe {
9844        rusqlite::ffi::sqlite3_db_status(
9845            connection.handle(),
9846            rusqlite::ffi::SQLITE_DBSTATUS_LOOKASIDE_USED,
9847            &mut current,
9848            &mut hiwtr,
9849            0,
9850        );
9851    }
9852    hiwtr
9853}
9854
9855/// Pack 6.G G.3.5 — read the three page-cache pressure counters on
9856/// `connection`: `SQLITE_DBSTATUS_CACHE_HIT`, `_CACHE_MISS`, and
9857/// `_CACHE_USED`. Returns `(hit, miss, used_bytes)`. Hit/miss are
9858/// monotonic counters (reset flag = 0 here); used_bytes is the live
9859/// page-cache memory footprint at call time. The caller is expected to
9860/// take pre/post snapshots and do delta arithmetic explicitly.
9861#[cfg(debug_assertions)]
9862fn read_cache_status(
9863    connection: &Connection,
9864) -> (std::os::raw::c_int, std::os::raw::c_int, std::os::raw::c_int) {
9865    let mut hit_current: std::os::raw::c_int = 0;
9866    let mut hit_hiwtr: std::os::raw::c_int = 0;
9867    let mut miss_current: std::os::raw::c_int = 0;
9868    let mut miss_hiwtr: std::os::raw::c_int = 0;
9869    let mut used_current: std::os::raw::c_int = 0;
9870    let mut used_hiwtr: std::os::raw::c_int = 0;
9871    // SAFETY: `connection.handle()` returns a valid `*mut sqlite3` for
9872    // the lifetime of `connection`. All out-pointers are to local stack
9873    // ints. Reset flag 0 is documented as legal (no counter is reset).
9874    unsafe {
9875        rusqlite::ffi::sqlite3_db_status(
9876            connection.handle(),
9877            rusqlite::ffi::SQLITE_DBSTATUS_CACHE_HIT,
9878            &mut hit_current,
9879            &mut hit_hiwtr,
9880            0,
9881        );
9882        rusqlite::ffi::sqlite3_db_status(
9883            connection.handle(),
9884            rusqlite::ffi::SQLITE_DBSTATUS_CACHE_MISS,
9885            &mut miss_current,
9886            &mut miss_hiwtr,
9887            0,
9888        );
9889        rusqlite::ffi::sqlite3_db_status(
9890            connection.handle(),
9891            rusqlite::ffi::SQLITE_DBSTATUS_CACHE_USED,
9892            &mut used_current,
9893            &mut used_hiwtr,
9894            0,
9895        );
9896    }
9897    // CACHE_HIT / CACHE_MISS are monotonic counters reported in the
9898    // `current` out-param; CACHE_USED is the live byte count, also in
9899    // `current`. The hiwtr values are unused for this telemetry.
9900    (hit_current, miss_current, used_current)
9901}
9902
9903/// FFI trampoline for `sqlite3_profile`.
9904///
9905/// Invoked by SQLite at statement-finish with the SQL text and the
9906/// statement's wall-clock cost in nanoseconds. We dispatch a
9907/// `ProfileRecord` (when profiling is enabled) and a `SlowStatement`
9908/// signal (when `wall_clock_ms` exceeds the configured slow threshold).
9909///
9910/// Per `dev/design/lifecycle.md` § Public record shape, the public
9911/// payload exposes `wall_clock_ms`, `step_count`, and `cache_delta`.
9912/// `sqlite3_profile` does not surface per-statement step counts or
9913/// cache-hit deltas in its callback; we emit `0` for those fields and
9914/// document the hazard. AC-005b requires the fields be typed numeric,
9915/// not that they carry non-zero values for every backend.
9916unsafe extern "C" fn profile_callback_trampoline(
9917    user_data: *mut std::ffi::c_void,
9918    sql: *const std::os::raw::c_char,
9919    nanoseconds: u64,
9920) {
9921    if user_data.is_null() || sql.is_null() {
9922        return;
9923    }
9924    let ctx = unsafe { &*(user_data.cast::<ProfileContext>()) };
9925    let sql_text = match unsafe { std::ffi::CStr::from_ptr(sql) }.to_str() {
9926        Ok(s) => s,
9927        Err(_) => return,
9928    };
9929
9930    let wall_clock_ms = nanoseconds / 1_000_000;
9931
9932    if ctx.profiling_enabled.load(Ordering::Relaxed) {
9933        let record = lifecycle::ProfileRecord {
9934            wall_clock_ms,
9935            // step_count / cache_delta are not surfaced by
9936            // sqlite3_profile; placeholder 0 satisfies AC-005b's
9937            // "typed numeric" contract. A future profiling refactor
9938            // around sqlite3_stmt_status + sqlite3_db_status would
9939            // populate them with non-zero deltas.
9940            step_count: 0,
9941            cache_delta: 0,
9942        };
9943        ctx.subscribers.dispatch_profile(&record);
9944    }
9945
9946    let threshold = ctx.slow_threshold_ms.load(Ordering::Relaxed);
9947    if wall_clock_ms > threshold {
9948        let signal = lifecycle::SlowStatement { statement: sql_text.to_string(), wall_clock_ms };
9949        ctx.subscribers.dispatch_slow_statement(&signal);
9950    }
9951}
9952
9953#[cfg(test)]
9954mod tests {
9955    use super::{resolve_source_type, Engine, PreparedWrite, KIND_TO_SOURCE_TYPE_CASE_SQL};
9956    use rusqlite::Connection;
9957    use tempfile::TempDir;
9958
9959    // Pack 1 drift-detection: the Rust helper used by the two writer
9960    // sites must agree with the CASE WHEN used by the Pack 1 reshape
9961    // migration in `migrate_vector_partition_to_pack1`. The CASE SQL
9962    // is exported as `KIND_TO_SOURCE_TYPE_CASE_SQL`; this test
9963    // exercises it against an in-memory SQLite (no sqlite-vec extension
9964    // required — only the CASE) and asserts byte-equal output with the
9965    // Rust helper for every kind in the locked Pack 1 vocabulary
9966    // (incl. the synthetic `doc` -> `article` coercion). See
9967    // `dev/design/0.7.0-vector-quant-pack1.md` D3 / D4.
9968    #[test]
9969    fn resolve_source_type_drift_check() {
9970        let kinds = ["email", "article", "paper", "meeting", "note", "todo", "doc"];
9971
9972        // 1. Rust helper return values (table is the contract: changes
9973        //    here must be reflected in the SQL CASE or this test fails).
9974        let want: &[(&str, &str)] = &[
9975            ("email", "email"),
9976            ("article", "article"),
9977            ("paper", "paper"),
9978            ("meeting", "meeting"),
9979            ("note", "note"),
9980            ("todo", "todo"),
9981            ("doc", "article"),
9982        ];
9983        for (kind, expected) in want {
9984            let got = resolve_source_type(kind).unwrap_or_else(|_| {
9985                panic!("resolve_source_type({kind}) returned Err; want Ok({expected})")
9986            });
9987            assert_eq!(got, *expected, "Rust helper drift for kind={kind}");
9988        }
9989        assert!(
9990            resolve_source_type("banana").is_err(),
9991            "unknown kind must surface as writer error"
9992        );
9993
9994        // 2. SQL CASE evaluated against the same kinds. Build a
9995        //    one-row staging row per kind and SELECT through
9996        //    KIND_TO_SOURCE_TYPE_CASE_SQL; assert each row equals the
9997        //    Rust helper's output. Drift in either direction fails.
9998        let conn = Connection::open_in_memory().expect("in-memory sqlite");
9999        conn.execute_batch("CREATE TABLE s(kind TEXT NOT NULL)").expect("create s");
10000        for kind in &kinds {
10001            conn.execute("INSERT INTO s(kind) VALUES (?1)", [kind]).expect("insert kind");
10002        }
10003        let sql = format!("SELECT s.kind, {KIND_TO_SOURCE_TYPE_CASE_SQL} FROM s");
10004        let mut stmt = conn.prepare(&sql).expect("prepare CASE");
10005        let rows: Vec<(String, String)> = stmt
10006            .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
10007            .expect("query")
10008            .map(|r| r.expect("row"))
10009            .collect();
10010        assert_eq!(rows.len(), kinds.len(), "row count drift");
10011        for (kind, sql_result) in &rows {
10012            let rust_result = resolve_source_type(kind).expect("known kind");
10013            assert_eq!(
10014                sql_result, rust_result,
10015                "SQL CASE vs Rust helper drift for kind={kind}: SQL={sql_result}, Rust={rust_result}"
10016            );
10017        }
10018    }
10019
10020    #[test]
10021    fn write_advances_cursor() {
10022        let dir = TempDir::new().unwrap();
10023        let opened = Engine::open(dir.path().join("rewrite.sqlite")).expect("engine should open");
10024        let receipt = opened
10025            .engine
10026            .write(&[PreparedWrite::Node {
10027                kind: "doc".to_string(),
10028                body: "hello".to_string(),
10029                source_id: None,
10030                logical_id: None,
10031            }])
10032            .expect("write should succeed");
10033
10034        assert_eq!(receipt.cursor, 1);
10035    }
10036}