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(¢ered);
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 ¤t_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}