fathomdb_engine/lib.rs
1//! **FathomDB engine** — the runtime core: storage, projections, ingest and
2//! query.
3//!
4//! ⚠ **Most consumers should depend on the [`fathomdb`] facade crate instead.**
5//! `fathomdb` re-exports exactly the governed application surface and gates the
6//! operator/recovery seam behind a cargo feature; this crate is the
7//! implementation and exposes internals the facade deliberately withholds.
8//! Depend on it directly only if you are building FathomDB tooling.
9//!
10//! [`fathomdb`]: https://docs.rs/fathomdb
11//!
12//! # What it does
13//!
14//! One `Engine` owns an embedded SQLite database (FTS5 + `sqlite-vec`), the
15//! single writer thread, a thread-affine reader pool serving DEFERRED-tx
16//! snapshots, the background projection scheduler, and — optionally — an
17//! in-process embedder. There is no server and no sidecar.
18//!
19//! - **Hybrid retrieval.** A vector branch and an FTS5 branch, fused by
20//! Reciprocal Rank Fusion on ordinal *rank* (never on raw, non-comparable
21//! scores), with an optional CPU cross-encoder rerank and an optional
22//! graph-BFS third arm over temporal fact edges.
23//! - **Canonical rows + projections.** Writes land as durable canonical rows;
24//! FTS, vector and attribute indexes are engine-maintained projections
25//! rebuildable from them.
26//! - **Record lifecycle.** Transaction-time supersession keyed on `logical_id`,
27//! an existence axis (`transition` / `purge`), and world-time validity
28//! windows on nodes plus `t_valid` / `t_invalid` on edges.
29//! - **Deletion on request.** `Engine::erase_source` erases every row carrying a
30//! provenance id — including anonymous rows `Engine::purge` cannot reach —
31//! and finishes the erasure at rest.
32//!
33//! # Provenance is mandatory
34//!
35//! `PreparedWrite::Node` and `PreparedWrite::Edge` carry `source_id: SourceId`,
36//! a newtype rather than an `Option<String>`. `Engine::erase_source` addresses
37//! rows **by** `source_id`, so a row written without one could never be erased;
38//! `SourceId::new` is the only public constructor and makes that state
39//! inexpressible.
40//!
41//! # Stability
42//!
43//! Pre-1.0, so **beta**. `SCHEMA_VERSION` is the on-disk contract; migrations
44//! run at open and only there. `PreparedWrite`, `SearchFilter` and
45//! `EngineError` are `#[non_exhaustive]` or documented as additive.
46
47pub mod lifecycle;
48mod pcache2;
49
50use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
51use std::error::Error;
52use std::fmt::{Display, Formatter};
53use std::fs::{File, OpenOptions};
54use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
55use std::path::{Path, PathBuf};
56use std::process::{Command, Stdio};
57use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
58use std::sync::mpsc::{self, Receiver, SyncSender};
59#[cfg(debug_assertions)]
60use std::sync::Barrier;
61use std::sync::Once;
62use std::sync::{Arc, Condvar, Mutex};
63use std::thread::{self, JoinHandle};
64use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
65
66use fathomdb_embedder::EmbedderEvent;
67// `MeanRecomputeTrigger` is used only by the operator-gated `recompute_mean`.
68#[cfg(feature = "operator")]
69use fathomdb_embedder::MeanRecomputeTrigger;
70use fathomdb_embedder_api::{Embedder, EmbedderError as RuntimeEmbedderError, EmbedderIdentity};
71use fathomdb_query::compile_text_query;
72use fathomdb_schema::{
73 migrate_with_event_sink, MigrationError as SchemaMigrationError, MigrationStepReport,
74 LOCK_SUFFIX, MIGRATIONS, SCHEMA_VERSION,
75};
76// `CANONICAL_TABLES` is used only by the operator-gated `dump_row_counts`.
77#[cfg(feature = "operator")]
78use fathomdb_schema::CANONICAL_TABLES;
79use jsonschema::JSONSchema;
80use rusqlite::{params, Connection, OptionalExtension};
81use serde_json::Value;
82// `sha2::Digest` + `sha2::Sha256` — used by `safe_export` (operator-gated)
83// and unconditionally by `ingest_with_extractor` (G11 logical_id derivation).
84#[cfg(feature = "operator")]
85use sha2::Digest;
86#[cfg(not(feature = "operator"))]
87use sha2::Digest as _;
88use sha2::Sha256;
89use sqlite_vec::sqlite3_vec_init;
90
91#[cfg(unix)]
92use std::os::unix::fs::OpenOptionsExt;
93
94// EU-5b lock-flip: the engine's default embedder identity is now the
95// pinned bge-small variant. Pre-existing 0.7.0 workspaces opened with
96// `EmbedderChoice::Default` will fail-closed on identity mismatch per
97// ADR-0.6.0-vector-identity-embedder-owned; callers can still hold an
98// older noop profile by supplying `EmbedderChoice::Caller(NoopEmbedder)`.
99const DEFAULT_EMBEDDER_NAME: &str = "fathomdb-bge-small-en-v1.5";
100const DEFAULT_EMBEDDER_REVISION: &str = "5c38ec7c405ec4b44b94cc5a9bb96e735b38267a";
101const DEFAULT_EMBEDDER_DIMENSION: u32 = 384;
102
103/// Identity name of the bge-small embedder. `OpenReport.embedder_mean_centering_required`
104/// is `true` iff the live embedder identity reports this name. NoopEmbedder
105/// is `false`. Lifted out as a constant so the EU-5b lock-flip (when the
106/// engine's default identity becomes bge-small) is a single-line change.
107///
108/// TODO(EU-5b): when `DEFAULT_EMBEDDER_NAME` flips to this constant, the
109/// Default path will populate `embedder_mean_centering_required = true`
110/// without further engine work. Caller-supplied bge-small (rare today)
111/// already does the right thing.
112const BGE_SMALL_EMBEDDER_NAME: &str = "fathomdb-bge-small-en-v1.5";
113
114/// REQ-006a / AC-007a default slow-statement threshold. Mutated at runtime
115/// via [`Engine::set_slow_threshold_ms`].
116const DEFAULT_SLOW_THRESHOLD_MS: u64 = 100;
117const DEFAULT_VECTOR_PROFILE: &str = "default";
118const DEFAULT_VECTOR_PARTITION: &str = "vector_default";
119
120/// 0.8.18 Slice 5 (#5 vector-equivalence probe) — the committed 45-probe fixture
121/// (byte-identical to `fathomdb-embedder/tests/fixtures/candle_onnx_equivalence_probes.txt`;
122/// a drift-guard test pins the two copies equal). One probe per non-empty line;
123/// lines whose first non-whitespace char is `#` are comments.
124const VECTOR_EQUIVALENCE_PROBE_FIXTURE: &str = include_str!("vector_equivalence_probes.txt");
125
126/// 0.8.18 Slice 5 (#5 vector-equivalence probe) — the FROZEN D4 tolerance floor,
127/// **P2 component**: the un-centered Phase-2 L2 epsilon. `‖reembed − reference‖₂`
128/// (un-centered, `vec_distance_l2` semantics) strictly greater than this ⇒
129/// divergence ⇒ dense refused. Named constant so the final ε (HITL look at
130/// landing) is trivially tunable. The **P1 component** (Phase-1 mean-centered
131/// `embedding_bin` sign-flip count) has an *exact-zero* floor: ANY single flip on
132/// the 45 probes ⇒ divergence (see [`VECTOR_EQUIVALENCE_P1_FLIP_FLOOR`]).
133const VECTOR_EQUIVALENCE_L2_EPSILON: f32 = 1e-5;
134
135/// 0.8.18 Slice 5 — the FROZEN D4 tolerance floor, **P1 component**: the maximum
136/// tolerated Phase-1 mean-centered `embedding_bin` sign-flip count across all 45
137/// probes. `0` = exact: any single flip ⇒ divergence ⇒ dense refused.
138const VECTOR_EQUIVALENCE_P1_FLIP_FLOOR: u64 = 0;
139
140/// 0.8.20 Slice 22 (TC-68) — `_fathomdb_open_state` key holding the
141/// [`probe_verification_fingerprint`] of the last open at which the
142/// vector-equivalence probe actually RAN and PASSED on this workspace. An open
143/// whose freshly computed fingerprint equals this value reuses that verdict and
144/// performs ZERO probe embeds; anything else re-runs the full probe.
145///
146/// It lives in `_fathomdb_open_state` — the engine's existing open-time
147/// durable-marker KV table (migration step 1) — alongside
148/// [`SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY`] and
149/// [`EDGE_VECTOR_PRUNE_MARKER_KEY`], which is exactly this shape of state. So
150/// TC-68 adds **no** table, **no** migration step and **no** `SCHEMA_VERSION`
151/// bump: an old DB simply has no row here and re-runs the probe once.
152const VECTOR_EQUIVALENCE_VERDICT_CACHE_KEY: &str = "vector_equivalence_verified_fingerprint";
153
154/// 0.8.20 Slice 22 (TC-68) — recipe tag mixed into every verdict fingerprint.
155/// **Bump it whenever the SET of fingerprint inputs changes.** Every cached
156/// verdict in the field then stops matching and the probe re-runs once per
157/// workspace — the fail-SAFE direction, and the reason a stale recipe can never
158/// silently keep vouching for a narrower check than the current build performs.
159const VECTOR_EQUIVALENCE_FINGERPRINT_RECIPE: &str = "fathomdb-veq-verdict-v1";
160/// Default drain budget for `rebuild_projections` / `rebuild_vec0`. The
161/// rebuild path freezes the scheduler before truncating shadow rows, so
162/// the only outstanding work is whatever workers were mid-flight when
163/// the call landed; 30 s is generous for normal job sizes and bounded
164/// for tests.
165#[cfg(feature = "operator")]
166const REBUILD_DRAIN_TIMEOUT_MS: u64 = 30_000;
167/// OPP-12 Phase-1 (0.8.19 Slice 10) — drain budget the `transition`/`purge`
168/// lifecycle verbs use to settle in-flight projection work before mutating.
169/// Same 30 s budget as `REBUILD_DRAIN_TIMEOUT_MS`, but not `operator`-gated
170/// (the lifecycle verbs are always-on governed surface).
171const LIFECYCLE_DRAIN_TIMEOUT_MS: u64 = 30_000;
172/// 0.8.0 Slice 5 (G1) — schema version that introduces the global FTS5
173/// tokenizer-default upgrade (`SCHEMA_VERSION` 11, migration step 11). A DB
174/// migrated to (or past) this version re-tokenizes `search_index` from
175/// canonical source rows on open (the drop+recreate leaves the FTS index
176/// empty). Repair is keyed off the completion marker below — NOT off crossing
177/// the step boundary — so it is crash-retryable (see
178/// `SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY`).
179const SEARCH_INDEX_TOKENIZER_SCHEMA_VERSION: u32 = 11;
180/// 0.8.0 Slice 5 (G1) fix-1 — `_fathomdb_open_state` key set, in the SAME
181/// transaction as the reproject DELETE+INSERT, once the post-tokenizer-upgrade
182/// re-tokenization commits durably. Step 11 commits `user_version = 11` with an
183/// EMPTY `search_index` in its own transaction; the reproject runs in a later
184/// transaction on open. A crash in that window leaves a durable `user_version =
185/// 11` + empty index. Gating repair on a boundary crossing (`before < 11`)
186/// would skip it on the next open (it sees `before == 11`), stranding the index
187/// empty forever. Gating on this marker's ABSENCE instead makes repair
188/// idempotent and crash-retryable: written atomically with the reindex, so a
189/// crash before commit leaves no marker and the next open re-runs.
190const SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY: &str =
191 "search_index_tokenizer_reproject_complete";
192/// 0.8.20 Slice 15c (TC-33) fix-6 — schema version at which the
193/// `canonical_edges` INTEGER-epoch recreate (migration step 23) runs. A DB
194/// migrated to (or past) this version has had every edge row DROPPED with NO
195/// DATA MIGRATION, and the migration removed the dropped edges'
196/// `_fathomdb_vector_rows` sidecar rows. The vec0 `vector_default` shadow it
197/// mirrors is engine-created and dim-parameterized, so the migration cannot
198/// touch it; the engine prunes the now-orphaned vec0 rows on open.
199const EDGE_TEMPORAL_EPOCH_SCHEMA_VERSION: u32 = 23;
200/// 0.8.20 Slice 15c (TC-33) fix-6 — `_fathomdb_open_state` key set once the
201/// one-time edge-vector prune commits durably (written in the SAME transaction
202/// as the vec0 DELETEs). Gating repair on this marker's ABSENCE — not on
203/// crossing the step-23 boundary — makes it crash-retryable: the step-23
204/// migration commits `user_version = 23` (edges dropped, sidecar cleared) in its
205/// own transaction, and the prune runs in a later transaction on open. A crash
206/// in that window leaves a durable `user_version = 23` with orphaned vec0 rows;
207/// a boundary-crossing gate (`before < 23`) would skip the prune forever on the
208/// next open (it sees `before == 23`). The marker is absent on any DB upgraded
209/// before this fix shipped, so the prune runs once and cleans the lingering
210/// orphans; thereafter the paired vec0/sidecar insert+delete keeps the invariant
211/// so no new orphans arise.
212const EDGE_VECTOR_PRUNE_MARKER_KEY: &str = "tc33_edge_vector_prune_complete";
213const DEFAULT_PROVENANCE_ROW_CAP: u64 = 1_000_000;
214/// 0.8.20 Slice 5b (R-20-E5) — how many times an erasure verb re-tries
215/// `PRAGMA wal_checkpoint(TRUNCATE)` before refusing with
216/// [`EngineError::ErasureIncomplete`]. Deliberately small: a concurrent reader
217/// pinning a WAL snapshot can hold it for an unbounded time, and an erasure verb
218/// must fail loudly rather than block a caller indefinitely.
219const ERASURE_WAL_TRUNCATE_ATTEMPTS: u32 = 5;
220/// 0.8.20 Slice 5b (R-20-E5) — pause between WAL-truncation attempts
221/// (~100 ms total budget across [`ERASURE_WAL_TRUNCATE_ATTEMPTS`]).
222const ERASURE_WAL_TRUNCATE_BACKOFF_MS: u64 = 25;
223/// 0.8.20 Slice 5b (R-20-E6) — the sentinel that replaces an erased
224/// `result_stable_ids` element in the telemetry sink. Positional alignment with
225/// the parallel `result_ids` array is preserved, so a redacted sink stays
226/// parseable by the gold pipeline.
227const REDACTED_STABLE_ID: &str = "[erased]";
228/// 0.8.20 Slice 5b (design `0.8.20-slice0-erasure-design.md` §2 defect D-A,
229/// HITL-ruled 2026-07-19: *"there must be an auditable record of deletion
230/// event."*) — op-store collections holding ERASURE-AUDIT records.
231///
232/// These rows are **exempt from [`enforce_provenance_retention`]**. Before this
233/// slice they were swept like any other op-store row: cap-first, oldest-`id`
234/// first, with no collection filter — and because the audit row is written
235/// *before* the workload that follows it, it was among the FIRST evicted. The
236/// proof of erasure was therefore destructible, and shared a retention pool with
237/// the very payloads it must prove erased. Accountability (demonstrating *that*
238/// an erasure occurred) is a distinct obligation from erasure itself, and a
239/// retention sweep must not silently discharge it.
240///
241/// **Guarantee:** a row in one of these collections is never removed by the
242/// retention sweep, and (0.8.20 Slice 5 fix-3) never by
243/// [`Engine::excise_collection_record`] either — see
244/// [`is_erasure_bookkeeping_collection`].
245const ERASURE_AUDIT_COLLECTIONS: &[&str] = &["excise_source_audit", "excise_record_audit"];
246/// 0.8.20 Slice 5 fix-1 (codex §9 P2) — the `operational_mutations` collection
247/// holding the DURABLE record of a telemetry redaction that is owed but not yet
248/// performed. See [`Engine::discharge_pending_redactions`].
249///
250/// Like the audit collections it is exempt from the retention sweep: an
251/// outstanding erasure obligation must not be discharged by cap pressure.
252const ERASURE_PENDING_REDACTION_COLLECTION: &str = "erasure_pending_redaction";
253/// 0.8.20 Slice 5 fix-3 (codex §9 round-3 P1) — true for the op-store
254/// collections that hold the engine's ERASURE BOOKKEEPING: the durable
255/// pending-redaction queue and the erasure-audit trail.
256///
257/// These are engine-owned invariants that happen to be *stored* as op-store
258/// records. They are not caller data, and the generic record-erasure verb
259/// [`Engine::excise_collection_record`] must refuse to target them:
260///
261/// * **The pending queue** ([`ERASURE_PENDING_REDACTION_COLLECTION`]) records a
262/// telemetry redaction the engine still OWES. Deleting the entry makes
263/// [`Engine::complete_erasure_at_rest`] see no outstanding work, so the next
264/// erasure verb reports SUCCESS while the erased `l:`/`h:` ids are still in
265/// the telemetry sink. That is the exact R-20-E5 violation — *an erasure verb
266/// must never report success on an incomplete erasure* — that the queue was
267/// introduced to close, and the verb re-opened it through an
268/// operator-reachable path (`--excise-collection erasure_pending_redaction
269/// --excise-record-key <verb>`).
270/// * **The audit trail** ([`ERASURE_AUDIT_COLLECTIONS`]) is protected by the
271/// HITL ruling of 2026-07-19: *"there must be an auditable record of deletion
272/// event."* Deleting audit rows one-by-one defeats that ruled-on guarantee as
273/// surely as a retention sweep would. Accountability is a distinct obligation
274/// from erasure, and no verb may silently discharge it.
275///
276/// Neither carries erasable payload, so refusing them costs a caller nothing: a
277/// pending-queue row holds only stable ids the engine is about to remove from
278/// the sink (and deletes itself on discharge), and an audit row holds a
279/// `source_id` bound by the non-PII rule or a SHA-256 record digest.
280///
281/// The refusal is TYPED ([`EngineError::InvalidArgument`]), never a silent
282/// no-op — an operator who aimed at the wrong collection must be told. The shape
283/// mirrors the slice's existing precedent: [`Engine::erase_source`] refuses the
284/// reserved `_`-prefixed provenance namespace while [`Engine::excise_source`]
285/// stays permissive.
286///
287/// Gated on `feature = "operator"` to match its only call site,
288/// [`Engine::excise_collection_record`], which is itself operator-only: without
289/// the matching `cfg` a non-`operator` build emits a `dead_code` warning for a
290/// helper that has nothing to guard, because the verb it guards does not exist
291/// in that build.
292#[cfg(feature = "operator")]
293fn is_erasure_bookkeeping_collection(collection: &str) -> bool {
294 collection == ERASURE_PENDING_REDACTION_COLLECTION
295 || ERASURE_AUDIT_COLLECTIONS.contains(&collection)
296}
297const PROJECTION_CURSOR_KEY: &str = "projection_cursor";
298const PROJECTION_WORKERS: usize = 2;
299/// PR-9 — ADR-0.6.0-embedder-protocol **Invariant 5** default per-`embed()`
300/// watchdog deadline. Every projection-path embed runs under this timeout;
301/// a hung embed surfaces `RuntimeEmbedderError::Timeout` (engaging the
302/// existing retry/failure path) rather than parking a worker forever. The
303/// EU-5f `catch_unwind` only catches *panics*; this catches *hangs*.
304const DEFAULT_EMBED_TIMEOUT_MS: u64 = 30_000;
305/// PR-9 — embed circuit-breaker threshold: the maximum number of watchdog
306/// embed threads allowed alive at once before the breaker latches and
307/// projection jobs fail fast (see `embed_circuit_open` / `live_embed_threads`).
308/// Healthy serialized operation keeps the live count at 0–1, so reaching this
309/// many concurrently-alive embed threads means timed-out embeds are piling up
310/// (a hung/wedged embedder); the breaker then caps the abandoned-thread leak
311/// at roughly this count.
312const DEFAULT_EMBED_CIRCUIT_THRESHOLD: u64 = 8;
313const PROJECTION_COMMIT_BATCH: usize = 16;
314// Each worker should be able to grab a full commit batch while another
315// worker has the same waiting in the queue. Below this, the dispatcher
316// throttles below the workers' commit-batch capacity.
317const PROJECTION_INFLIGHT_LIMIT: usize = PROJECTION_WORKERS * PROJECTION_COMMIT_BATCH;
318// SQL fetch cap inside the dispatcher: enough to fill the in-flight
319// budget in a single scan so we don't pay one SQL roundtrip per job.
320const PROJECTION_SCAN_FETCH: usize = PROJECTION_INFLIGHT_LIMIT;
321const DEFAULT_PROJECTION_RETRY_DELAYS_MS: [u64; 3] = [1_000, 4_000, 16_000];
322
323/// G11 — the fixed projection kind every EDGE body is scheduled under
324/// (`resolve_source_type` maps it to `source_type = 'edge_fact'` in
325/// `vector_default`). Named so the fix-4 deferral can say WHICH rows it
326/// deliberately leaves on the shipped terminal path (see
327/// `projection_dispatcher_loop`).
328const EDGE_FACT_KIND: &str = "edge_fact";
329
330/// Reader pool size. Per `dev/design/engine.md` § Writer / reader split,
331/// reader connections are pooled and never serialize behind one
332/// connection. AC-021 exercises 8 concurrent readers.
333const READER_POOL_SIZE: usize = 8;
334
335/// Per-reader-connection lookaside slot size, in bytes. Pack 6.G G.1.
336/// Picked from G.0 telemetry (`allocator_lookaside` 26.67% conc cycles
337/// with 3.89× ratio) + the SQLite docs' typical-workload sizing
338/// guidance (https://www.sqlite.org/malloc.html §3): 1200-byte slots
339/// cover the small allocations from `sqlite3DbMallocRaw`,
340/// `sqlite3Fts5ExprNew`, and `vec0Filter_knn` visible at the top of the
341/// concurrent profile.
342const READER_LOOKASIDE_SLOT_SIZE: std::os::raw::c_int = 1200;
343
344/// Per-reader-connection lookaside slot count. SQLite default is 128;
345/// we use 500 to absorb the per-statement allocation footprint of the
346/// hybrid search workload across a sticky worker connection without
347/// falling back to the glibc malloc-arena mutex.
348const READER_LOOKASIDE_SLOT_COUNT: std::os::raw::c_int = 500;
349
350pub struct Engine {
351 path: PathBuf,
352 next_cursor: AtomicU64,
353 closed: AtomicBool,
354 lock: Mutex<Option<File>>,
355 connection: Mutex<Option<Connection>>,
356 reader_pool: ReaderWorkerPool,
357 counters: lifecycle::Counters,
358 subscribers: Arc<lifecycle::SubscriberRegistry>,
359 profiling_enabled: Arc<AtomicBool>,
360 slow_threshold_ms: Arc<AtomicU64>,
361 runtime_embedder: Option<Arc<dyn Embedder>>,
362 runtime_embedder_identity: EmbedderIdentity,
363 projection_runtime: ProjectionRuntime,
364 provenance_row_cap: AtomicU64,
365 /// Per-connection profile-callback contexts. Each box's pointer is
366 /// installed into the connection's `sqlite3_profile` userdata; the
367 /// box must outlive the connection so the callback never reads
368 /// freed memory. Connections are dropped before this vec on
369 /// `close`/`Drop`, so the lifetime ordering holds.
370 ///
371 /// Why `Box<ProfileContext>` and not `ProfileContext` directly: the
372 /// FFI pointer captured during `install_profile_callback` MUST
373 /// remain stable for the connection's lifetime; pushing onto a
374 /// `Vec<ProfileContext>` could reallocate and invalidate that
375 /// pointer.
376 #[allow(clippy::vec_box)]
377 profile_contexts: Mutex<Vec<Box<ProfileContext>>>,
378 /// Pack 6.G G.1 — `sqlite3_db_config(LOOKASIDE)` rc per reader
379 /// worker, captured at open time before any PRAGMA / prepare ran
380 /// on the connection. Read only by the debug-only test accessor
381 /// `reader_lookaside_config_rcs_for_test`; held in release builds
382 /// too because the field is set unconditionally at open and a cfg
383 /// gate would force two open-locked return shapes.
384 #[allow(dead_code)]
385 reader_lookaside_rcs: Vec<i32>,
386 /// 0.8.8 Slice 15 (OPP-9) — opt-in telemetry sink. `None` (default) = OFF.
387 /// Local JSONL append; no network/egress. The OFF path never takes this lock —
388 /// it is gated by `telemetry_enabled` (below).
389 telemetry: Mutex<Option<TelemetrySink>>,
390 /// 0.8.8 Slice 15 — fast OFF-path guard. `false` (default) → search does ZERO
391 /// telemetry work: a single `Relaxed` atomic load, NO mutex acquisition (the
392 /// §B.1 footprint / zero-cost gate, codex §9 P2). Set `true` by
393 /// `enable_telemetry` after the sink is installed; the `telemetry` mutex is only
394 /// ever taken when this flag is set.
395 telemetry_enabled: AtomicBool,
396 /// 0.8.18 Slice 5 (#5 vector-equivalence probe, R-VEQ-4/6) — degraded-open
397 /// latch, re-derived at every open by the #5 self-check. `true` ⇒ every
398 /// vector-dependent arm refuses at the `search_inner_with_stats` choke point
399 /// with `EngineError::VectorEquivalenceMismatch`. Read lock-free on the query
400 /// hot path (a single `Relaxed`/`Acquire` load); the text-only/FTS-only path
401 /// never reads it.
402 dense_disabled: AtomicBool,
403 /// R-VEQ-6 — the human-readable reason attached to the query-time refusal (and
404 /// surfaced on `OpenReport.dense_disabled_reason`). Set once at open; read only
405 /// when `dense_disabled` is `true`.
406 dense_disabled_reason: Mutex<Option<String>>,
407 /// R-VEQ-6 — telemetry counter: number of query-time vector-dependent-arm
408 /// refusals raised because the engine opened in the `dense_disabled` state.
409 /// Observable pre/post-query via `vector_equivalence_refusal_count`.
410 vector_equivalence_refusals: AtomicU64,
411 #[cfg(debug_assertions)]
412 force_next_commit_failure: AtomicBool,
413}
414
415/// 0.8.8 Slice 15 (OPP-9) — opt-in telemetry capture state (per `enable_telemetry`).
416/// Records query→result→feedback events to a local JSONL sink. Ids are
417/// `SearchHit.id` — the interim identity carrier per
418/// `ADR-0.8.0-canonical-identity-substrate` (write_cursor today; swaps to
419/// `logical_id` at the G0 keystone with no carrier reshape), consistent with
420/// `PerHitExplain.id`. Query text and `source_id` are NEVER captured (privacy, ADR
421/// §C). `query_id = "q{nonce}-{seq}"` is fully deterministic; `ts_monotonic_ms` is
422/// monotonic since enable (NOT wall-clock).
423struct TelemetrySink {
424 path: PathBuf,
425 base: Instant,
426 nonce: u64,
427 seq: u64,
428 last_query_id: Option<String>,
429}
430
431#[derive(Clone, Debug)]
432struct ProjectionJob {
433 cursor: u64,
434 kind: String,
435 body: String,
436}
437
438#[derive(Debug, Default)]
439struct ProjectionRuntimeState {
440 active_jobs: usize,
441 queued_jobs: usize,
442 frozen: bool,
443 pending_scan: bool,
444 stopping: bool,
445 in_flight: BTreeSet<u64>,
446}
447
448struct ProjectionRuntimeShared {
449 path: PathBuf,
450 embedder: Option<Arc<dyn Embedder>>,
451 embedder_identity: EmbedderIdentity,
452 /// Host-owned lifecycle diagnostics for worker failures, which occur on
453 /// background connections rather than through an `Engine` method call.
454 subscribers: Arc<lifecycle::SubscriberRegistry>,
455 state: Mutex<ProjectionRuntimeState>,
456 state_cvar: Condvar,
457 queue: Mutex<VecDeque<ProjectionJob>>,
458 queue_cvar: Condvar,
459 retry_delays_ms: Mutex<Vec<u64>>,
460 /// PR-9 — ADR-0.6.0-embedder-protocol Invariant 5 per-`embed()` watchdog
461 /// deadline (ms). Read lock-free on the projection hot path. Default
462 /// `DEFAULT_EMBED_TIMEOUT_MS` (30s); the test seam
463 /// `set_embed_timeout_ms_for_test` lowers it so the hanging-embedder
464 /// test need not wait 30s. A hung embed surfaces
465 /// `RuntimeEmbedderError::Timeout`, engaging the existing retry/failure
466 /// path in `run_projection_job`.
467 embed_timeout_ms: AtomicU64,
468 /// PR-9 — engine-side embed serialization guard. The pool runs
469 /// `PROJECTION_WORKERS` workers; this guard ensures the shared
470 /// `Arc<dyn Embedder>` is invoked by at most one worker at a time.
471 ///
472 /// Rationale is SAFETY, not throughput. The engine accepts arbitrary
473 /// caller-supplied embedders (the pyo3 / napi bridges, per ADR-0.6.0)
474 /// whose `embed` is `Sync` only by trait contract; many real impls (a
475 /// GIL-bound Python model, a non-reentrant native lib, an internal cache)
476 /// are not actually safe under concurrent calls. Serializing engine-side
477 /// makes the projection robust to embedders that are not truly
478 /// concurrency-safe, without the engine having to trust each impl. The
479 /// default `CandleBgeEmbedder` was shown safe under concurrent forwards
480 /// in the PR-9 pre-flight, so for it the guard is belt-and-suspenders.
481 ///
482 /// Throughput is ~neutral: `candle` fans every `BertModel::forward` onto a
483 /// single process-wide rayon pool, so two concurrent forwards merely
484 /// share that pool (trading per-embed latency, not aggregate work) rather
485 /// than getting 2x — serializing avoids some scheduler/cache thrash but is
486 /// not a large win. (An earlier "~13x" figure compared a debug-build
487 /// unserialized run against a release-build number and was withdrawn; a
488 /// PR-9 micro-benchmark put release embeds at ~14 ms short / ~960 ms for a
489 /// 512-token doc, watchdog overhead ~0.)
490 ///
491 /// Commit/IO stays parallel across workers (see `commit_gate`); this guard
492 /// wraps only the embed call. It is held by the worker across the watchdog
493 /// call and released here, so a timed-out (abandoned) embed frees it and
494 /// cannot stall the pool — the guard owns no data, so a panic-resumed
495 /// embed that poisons it is recovered via `into_inner`.
496 ///
497 /// Deliberate trade-off (codex PR-9 CONCERN-1, accepted): on the *timeout*
498 /// path the worker drops this guard while the abandoned detached embed
499 /// thread is still running lock-free, so serialization is briefly relaxed
500 /// until that thread finishes. This is the prescribed choice over holding
501 /// the guard inside the embed thread — which would let a genuinely-hung
502 /// embed hold it forever and deadlock the whole pool, exactly the wedge
503 /// ADR-0.6.0 Invariant 5 and this slice's spec forbid. Timeouts are the
504 /// fault path only; the embed circuit breaker (`embed_circuit_open`) caps
505 /// how many such abandoned threads can be alive at once. A future slice may
506 /// replace this hard serialize with an operator-configurable embed
507 /// concurrency limit (ADR-0.6.0 Invariant 4 pool-size override) for I/O-
508 /// or GPU-bound embedders; that knob is out of PR-9 scope.
509 embed_serialize: Mutex<()>,
510 /// PR-9 — embed circuit breaker. `live_embed_threads` counts watchdog embed
511 /// threads currently alive (incremented when one is spawned, decremented
512 /// when it finishes — see `embed_with_watchdog`). Under healthy serialized
513 /// operation this is 0 or 1; it only grows when timed-out embeds are
514 /// abandoned and keep running (ADR-0.6.0 Invariant 5 forbids aborting a
515 /// running embed). When a new embed would push the live count to
516 /// `embed_circuit_threshold`, the breaker latches `embed_circuit_open` and
517 /// projection jobs fail fast WITHOUT spawning further embeds — bounding the
518 /// abandoned-thread leak to ~threshold REGARDLESS of whether the embedder
519 /// hangs on every input or only intermittently (a returning embed
520 /// decrements the count rather than resetting a streak, so an
521 /// intermittently-hanging embedder still latches as its hung threads pile
522 /// up, and a merely-slow-but-returning embedder self-clears and never
523 /// false-trips). Latches for the engine session (a reopen resets it); a
524 /// half-open/cool-down retry is future work. `threshold == 0` disables it.
525 live_embed_threads: Arc<AtomicU64>,
526 embed_circuit_open: AtomicBool,
527 embed_circuit_threshold: AtomicU64,
528 /// EU-5b — streaming mean accumulator for the per-workspace mean
529 /// pinning lifecycle (`dev/design/embedder.md` §0.3). `Some(_)` iff
530 /// the identity is MC-required AND no mean has been pinned yet on
531 /// disk. The accumulator graduates to `None` after the at-pin
532 /// commit; subsequent docs feed nothing.
533 mean_accumulator: Mutex<Option<MeanAccumulator>>,
534 /// EU-5b — `MeanVecPinned` events queued by the projection-commit
535 /// transaction for the next test-seam drain. Production callers
536 /// consume these via the `OpenReport.embedder_events` channel; the
537 /// drain seam is `Engine::drain_mean_centering_events_for_test`.
538 pending_events: Mutex<Vec<EmbedderEvent>>,
539 /// EU-5f — serializes the body of `commit_projection_outcomes` across
540 /// the `PROJECTION_WORKERS` worker connections. Each worker commits on
541 /// its own connection; holding this gate for the whole commit makes the
542 /// commit transactions totally ordered, which is what makes the at-pin
543 /// re-quantize pass provably complete (every row is wholly before or
544 /// after the unique pin tx, so none can survive un-centered). Embedding
545 /// (`run_projection_job`) runs OUTSIDE the gate and stays parallel.
546 commit_gate: Mutex<()>,
547 /// 0.7.2 PR-2bc S1 fix-1 — overridable phase-2 rerank `LIMIT` for the
548 /// search hot path. Equals `SEARCH_RERANK_LIMIT` (10) in production; a
549 /// test seam (`set_search_limit_for_test`) can RAISE it (clamped to >=10,
550 /// so it can never shrink below production semantics) so the recall
551 /// harness can pull top-(10+slack) and exclude the self-retrieving
552 /// query-source doc before truncating to 10. Production reads this atomic
553 /// (default 10) — there is NO env var read on the hot path.
554 search_limit_override: AtomicUsize,
555 /// Slice 10 / G12-recency — dedicated recency-reweight flag, **off by
556 /// default** (NOT `fusion_mode`). When set, fused hits are reweighted toward
557 /// the more recent `write_cursor` AFTER bit-KNN. Flipped by the
558 /// `set_recency_reweight_enabled_for_test` seam; no production toggle yet.
559 recency_reweight_enabled: AtomicBool,
560 /// 0.8.16 Slice 5 / F9 — dedicated importance/confidence reweight flag,
561 /// **off by default** (mirrors `recency_reweight_enabled`; NOT `fusion_mode`).
562 /// When set, fused hits are multiplicatively reweighted by node `importance`
563 /// (`canonical_nodes.importance`) and edge `confidence`
564 /// (`canonical_edges.confidence`) AFTER bit-KNN + RRF fusion — `NULL ⇒ neutral
565 /// (1.0)`. Flipped by `set_importance_reweight_enabled_for_test`; no production
566 /// toggle yet (F9 ships OFF-by-default as a MECHANISM, no eval-quality claim).
567 importance_reweight_enabled: AtomicBool,
568 /// GA-2 / Slice-40 (◆ B-1) measurement seam, **off by default**. When set,
569 /// `read_search_in_tx` returns the pre-fusion VECTOR-branch ranking
570 /// (bit-KNN K=192 + f32 rerank) verbatim — the ANN-quantization fidelity
571 /// signal — INSTEAD of the unconditional RRF-fused result. This changes
572 /// nothing for any production caller (the flag is never set outside the
573 /// `eu7` recall harness via `set_vector_stage_only_for_test`); it does NOT
574 /// reintroduce a `fusion_mode` knob (RRF stays unconditional) and does NOT
575 /// alter `fuse_rrf` / `rerank_fused` / recency. It only lets the AC-075
576 /// recall gate measure ANN+ vector top-10 vs the exact-f32 VECTOR top-10
577 /// ground truth in isolation (the quantization-FIDELITY axis the 0.90 floor
578 /// is defined to measure), not the hybrid `search()` output.
579 vector_stage_only_for_test: AtomicBool,
580 /// 0.7.2 PR-2b — debug-only fault injection: when set, `recompute_mean_in_tx`
581 /// errors AFTER writing `mean_vec` but BEFORE finishing the re-quantize
582 /// pass, so the crash-atomicity test can prove the whole recompute rolls
583 /// back (no half-recentered corpus). One-shot (cleared on consume).
584 #[cfg(debug_assertions)]
585 force_recompute_failure: AtomicBool,
586 /// TC-91 — one-shot worker-commit fault seam. `0` is disabled, `1`
587 /// requests a synthetic SQLite busy error, and `2` a rusqlite-layer
588 /// storage error immediately before commit.
589 /// Kept entirely in the runtime and compiled only for tests.
590 #[cfg(debug_assertions)]
591 force_projection_commit_failure: AtomicUsize,
592 /// TC-91 test-only rendezvous after error reporting and before worker
593 /// cleanup. It proves a stop in that window leaves canonical pending work
594 /// for the next open rather than relying on an in-memory retry queue.
595 #[cfg(debug_assertions)]
596 projection_commit_failure_pause: Mutex<Option<(Arc<Barrier>, Arc<Barrier>)>>,
597 /// TC-91 test-only acknowledgement after `stopping` is set and before a
598 /// close joins workers, used with `projection_commit_failure_pause`.
599 #[cfg(debug_assertions)]
600 projection_stop_ack: Mutex<Option<Arc<Barrier>>>,
601}
602
603impl std::fmt::Debug for ProjectionRuntimeShared {
604 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
605 f.debug_struct("ProjectionRuntimeShared")
606 .field("path", &self.path)
607 .field("embedder_identity", &self.embedder_identity)
608 .finish_non_exhaustive()
609 }
610}
611
612#[derive(Debug)]
613struct ProjectionRuntime {
614 shared: Arc<ProjectionRuntimeShared>,
615 dispatcher: Mutex<Option<JoinHandle<()>>>,
616 workers: Mutex<Vec<JoinHandle<()>>>,
617}
618
619impl std::fmt::Debug for Engine {
620 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
621 f.debug_struct("Engine")
622 .field("path", &self.path)
623 .field("closed", &self.closed.load(Ordering::SeqCst))
624 .field("runtime_embedder_identity", &self.runtime_embedder_identity)
625 .finish_non_exhaustive()
626 }
627}
628
629/// Per-connection profile-callback context.
630///
631/// Holds the registry handle the callback dispatches to, plus shared
632/// references to the engine's profiling toggle and slow-statement
633/// threshold. The `Arc` clones here mirror the same atomics held by
634/// `Engine`, so `set_profiling` / `set_slow_threshold_ms` mutations are
635/// visible inside the callback without restart (REQ-006a / AC-005a /
636/// AC-007b runtime-toggle contract).
637#[derive(Debug)]
638struct ProfileContext {
639 subscribers: Arc<lifecycle::SubscriberRegistry>,
640 profiling_enabled: Arc<AtomicBool>,
641 slow_threshold_ms: Arc<AtomicU64>,
642}
643
644/// Thread-affine reader worker pool (Pack 6 F.0).
645///
646/// Per `dev/design/engine.md` § Writer / reader split, reader connections
647/// must not serialize behind a single mutex. Each worker thread owns
648/// exactly one read-only `Connection` for its lifetime; `Connection`
649/// objects never cross thread boundaries after startup. `Engine::search`
650/// dispatches a request via a per-worker bounded channel using a
651/// lock-free round-robin counter on the hot path.
652struct ReaderWorkerPool {
653 senders: Vec<SyncSender<ReaderRequest>>,
654 handles: Mutex<Option<Vec<JoinHandle<()>>>>,
655 next: AtomicUsize,
656 shutdown: AtomicBool,
657 live_workers: Arc<AtomicUsize>,
658}
659
660impl std::fmt::Debug for ReaderWorkerPool {
661 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
662 f.debug_struct("ReaderWorkerPool")
663 .field("worker_count", &self.senders.len())
664 .field("live_workers", &self.live_workers.load(Ordering::Relaxed))
665 .field("shutdown", &self.shutdown.load(Ordering::Relaxed))
666 .finish()
667 }
668}
669
670/// One request handled by exactly one reader worker. The response is
671/// returned through a fresh oneshot channel so requests cannot be
672/// routed to or duplicated across workers.
673enum ReaderRequest {
674 Search {
675 compiled: fathomdb_query::CompiledQuery,
676 /// Un-centered f32 query vector serialized for `vec_f32`. Phase 2
677 /// f32 rerank uses this verbatim.
678 query_vector: Option<String>,
679 /// EU-5a2 — (possibly centered) f32 query vector for the phase 1
680 /// `vec_quantize_binary` sign-quant. Equal to `query_vector` for
681 /// non-MC-required identities (the EU-5a2 default).
682 query_vector_bin: Option<String>,
683 /// 0.7.2 PR-2bc S1 fix-1 — phase-2 rerank `LIMIT`. Read from
684 /// `ProjectionRuntimeShared::search_limit_override` (default
685 /// `SEARCH_RERANK_LIMIT` = 10, clamped >=10) by `search_inner`
686 /// before dispatch, so the worker never reads any env var.
687 search_limit: usize,
688 /// G10 — optional closed metadata filter (`None` = unfiltered, the
689 /// byte-identical-to-0.7.2 path). Applied in the phase-1 candidates
690 /// statement (vector branch) and as a Rust post-filter (text branch).
691 /// Boxed so the `ReaderRequest::Search` variant stays small (the request
692 /// rides a `Result<(), ReaderRequest>` retry channel).
693 filter: Option<Box<SearchFilter>>,
694 /// G12-recency — whether the dedicated recency reweight is enabled for
695 /// this request (read from `recency_reweight_enabled`, off by default).
696 recency_enabled: bool,
697 /// F9 (0.8.16 Slice 5) — whether the dedicated importance/confidence
698 /// reweight is enabled for this request (read from
699 /// `importance_reweight_enabled`, off by default).
700 importance_enabled: bool,
701 /// GA-2 / Slice-40 (◆ B-1) measurement seam — when true the worker
702 /// returns the pre-fusion vector-branch ranking instead of the fused
703 /// result (read from `vector_stage_only_for_test`, off by default).
704 vector_stage_only: bool,
705 /// 0.8.1 Slice 10 (R1) — raw query text for the CE reranker. Passed
706 /// from `search_inner` to `read_search_in_tx` → `rerank_fused`.
707 /// FIX-4: `Box<str>` (16 bytes) instead of `String` (24 bytes) to keep
708 /// the Search variant smaller (mirroring the boxed `filter` field).
709 raw_query: Box<str>,
710 /// 0.8.1 Slice 10 (R1) — per-request rerank depth (snapshot of
711 /// `ProjectionRuntimeShared::rerank_depth`). `0` = identity path.
712 rerank_depth: usize,
713 /// 0.8.1 Slice 30 (R3) — when `true`, run the graph-BFS arm (seeded
714 /// from top-10 fused hits, depth ≤ 3, cap 50, temporal filter) and
715 /// fuse its candidates into the final ranking via `fuse_three_arms`.
716 /// When `false` (the default), the graph arm pool is `vec![]` and
717 /// results are byte-identical to the pre-Slice-30 two-arm pipeline.
718 use_graph_arm: bool,
719 /// 0.8.5 (EXP-0) — CE-blend weight (clamped to `[0,1]` in `ce_rerank`).
720 /// `0.3` is the byte-identical default; `1.0` is the measured-parity config.
721 alpha: f64,
722 /// 0.8.5 (EXP-0) — reranked-pool size (clamped to the hit count). The
723 /// binding resolves `pool_n.unwrap_or(rerank_depth)` before dispatch.
724 pool_n: usize,
725 /// 0.8.8 EXP-OBS (Slice 5) — when `true`, capture per-arm ranks + the
726 /// fused/CE score breakdown + query trace into a `SearchResult`
727 /// `Explanation` sidecar. `false` (the default for `search`/`search_filtered`/
728 /// `search_reranked`) does ZERO extra work and returns `explanation = None`
729 /// (R-OBS-2 zero-cost; byte-identical `results`).
730 explain: bool,
731 /// 0.8.20 Slice 15b fix-2 (R-20-NV / R-20-RV) — the VALIDITY view the
732 /// node-hydration SELECTs filter by. `ReadView::default()` reproduces
733 /// the pre-fix predicate on any corpus that never authored a window
734 /// (step 22 back-filled NULL/NULL with no DEFAULT, and `validity_sql`
735 /// treats NULL as unbounded ⇒ the conjunct is a provable no-op there).
736 /// The existence axis is refused upstream, never carried here.
737 view: ReadView,
738 respond: SyncSender<ReaderResponse>,
739 },
740 /// Slice 30 (G2) — active-only point lookup by `logical_id`. Returns one
741 /// slot per requested id, in request order, `None` where no active row
742 /// carries that id. Its own typed `respond` channel keeps the `Search`
743 /// `ReaderResponse` byte-identical (no Search regression).
744 GetById {
745 logical_ids: Vec<String>,
746 /// R-20-RV — the read view this lookup runs under. `ReadView::default()`
747 /// is the strict (pre-slice) view.
748 view: ReadView,
749 respond: SyncSender<rusqlite::Result<Vec<Option<NodeRecord>>>>,
750 },
751 /// Slice 30 (G3) — paginated op-store read-back over `operational_mutations`
752 /// for a `collection`, `ORDER BY id`, with a MANDATORY (already-clamped)
753 /// limit + optional after-id cursor.
754 ReadCollection {
755 collection: String,
756 after_id: Option<i64>,
757 limit: usize,
758 respond: SyncSender<rusqlite::Result<Vec<OpStoreRow>>>,
759 },
760 /// Slice 35 (G4) — list active canonical nodes of a `kind`, filtered by
761 /// zero or more `Predicate`s (AND-combined), up to `limit` rows.
762 /// Path validation already happened at `Predicate` construction time;
763 /// the worker only compiles + executes parameterized SQL.
764 ReadList {
765 kind: String,
766 predicates: Vec<Predicate>,
767 limit: usize,
768 /// R-20-RV — the read view this listing runs under.
769 view: ReadView,
770 respond: SyncSender<rusqlite::Result<Vec<NodeRecord>>>,
771 },
772 /// Slice 20 (G5) — bounded BFS from a single root node over
773 /// `canonical_edges`. Returns the set of reachable nodes (excluding the
774 /// root) within `depth` hops, limited to the hard cap 50.
775 GraphNeighbors {
776 root_logical_id: String,
777 depth: u32,
778 direction: TraversalDirection,
779 /// R-20-RV — the read view applied at EVERY node position of the BFS
780 /// CTE (anchor, recursive join, final projection), for every direction.
781 view: ReadView,
782 respond: SyncSender<rusqlite::Result<Vec<NodeRecord>>>,
783 },
784 /// 0.8.20 Slice 10b (R-20-NV) — nodes that crossed a validity boundary in
785 /// `(since, view-instant]`.
786 CrossedBoundarySince {
787 since: i64,
788 view: ReadView,
789 respond: SyncSender<rusqlite::Result<Vec<BoundaryCrossing>>>,
790 },
791 /// Slice 20 (G6) — compose the previous search result with BFS expansion.
792 /// Resolves search hit `write_cursor`s to `logical_id`s, runs G5 traversal
793 /// for each root, deduplicates, and returns a `SearchExpandResult`.
794 SearchExpand {
795 search_hits: Vec<SearchHit>,
796 depth: u32,
797 respond: SyncSender<rusqlite::Result<SearchExpandResult>>,
798 },
799 /// Slice 20 test seam — run `EXPLAIN QUERY PLAN` on the BFS CTE SQL for
800 /// the given root/depth/direction and return the plan detail lines.
801 #[doc(hidden)]
802 ExplainGraphNeighbors {
803 root_logical_id: String,
804 depth: u32,
805 direction: TraversalDirection,
806 respond: SyncSender<rusqlite::Result<Vec<String>>>,
807 },
808 Shutdown,
809 /// Pack 6.G G.1 — debug-only request that asks a worker to read its
810 /// own connection's `SQLITE_DBSTATUS_LOOKASIDE_USED` and return the
811 /// high-water mark (`hiwtr` out-param). Used solely by the integration
812 /// test that asserts post-warmup lookaside slots were consumed; not
813 /// on any production path.
814 #[cfg(debug_assertions)]
815 LookasideStatus {
816 respond: SyncSender<i32>,
817 },
818 /// Pack 6.G G.3.5 — debug-only request that asks a worker to read
819 /// `SQLITE_DBSTATUS_CACHE_HIT`, `_CACHE_MISS`, and `_CACHE_USED`
820 /// off its own connection and return them as `(hit, miss, used_bytes)`.
821 /// `snapshot_label` is opaque to the worker; the caller uses it to
822 /// distinguish pre/post snapshots in its own bookkeeping.
823 #[cfg(debug_assertions)]
824 CacheStatus {
825 snapshot_label: String,
826 respond: SyncSender<(String, i32, i32, i32)>,
827 },
828 /// OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — debug-only request
829 /// that asks a worker to read its own connection's `PRAGMA secure_delete`
830 /// and return it (`0`/`1`). Used solely by the gap-4 test that asserts the
831 /// standing secure_delete flag is ON at EVERY open, not just the writer.
832 #[cfg(debug_assertions)]
833 SecureDeleteStatus {
834 respond: SyncSender<i64>,
835 },
836}
837
838// G0 Phase-2: the Search response carries a 4th element — the graph-arm frontier
839// meter (`GraphFrontierStats`). It rides the internal channel but is dropped before
840// `SearchResult` is built (kept OFF the governed surface); the
841// `_graph_frontier_stats_for_test` seam captures it. Default (all-zero) on non-graph paths.
842// 0.8.8 EXP-OBS (Slice 5): the Search response carries a 5th element — the opt-in
843// retrieval `Explanation` (`None` on every default `explain=false` path; `Some`
844// only on the `search_explained` path). Like the `GraphFrontierStats` 4th element
845// it rides the internal channel as a side-channel; unlike it, the explanation IS
846// surfaced (onto `SearchResult.explanation`) when requested.
847type ReaderResponse = Result<
848 (u64, Option<SoftFallback>, Vec<SearchHit>, GraphFrontierStats, Option<Explanation>),
849 SearchReaderError,
850>;
851
852/// 0.8.20 keystone closeout fix-3 (codex §9 [P2], TOCTOU) — the error a Search
853/// reader worker can return. Two arms:
854/// * `Sqlite` — a backend/storage failure (the pre-fix-3 `rusqlite::Result`
855/// behaviour verbatim; the caller emits the internal-error event and maps to
856/// `EngineError::Storage`);
857/// * `InvalidFilter` — a filter naming an UNDECLARED `filterable` attribute,
858/// detected on the reader's OWN transaction snapshot (see
859/// [`validate_filter_attributes_on_snapshot`]). The caller re-raises it as the
860/// EXISTING `EngineError::InvalidFilter { reason }` typed variant.
861///
862/// Why a channel-carried variant and not a pre-dispatch check on the writer
863/// connection: fix-2 validated on `self.connection` BEFORE dispatch, then the
864/// reader prepared the vec0 query on a DIFFERENT connection/snapshot. A
865/// `configure_projections` DROP landing in that window let the vec0 `attr_<hex>`
866/// column vanish AFTER validation passed → an opaque `no such column` `Storage`
867/// error (the exact untyped failure fix-2 meant to prevent). Validating INSIDE
868/// the reader transaction that also compiles+executes the search binds the check
869/// and the query to ONE snapshot, closing the race; carrying the typed reason
870/// back through this variant keeps the outcome `InvalidFilter`, never `Storage`.
871enum SearchReaderError {
872 Sqlite(rusqlite::Error),
873 InvalidFilter(String),
874}
875
876impl From<rusqlite::Error> for SearchReaderError {
877 fn from(err: rusqlite::Error) -> Self {
878 SearchReaderError::Sqlite(err)
879 }
880}
881
882/// Pack 6.G G.3.5 — per-worker cache-pressure snapshot. Carried only on
883/// the debug-only `CacheStatus` broadcast path and the test accessor;
884/// not part of the public 0.6.0 surface.
885#[cfg(debug_assertions)]
886#[doc(hidden)]
887#[derive(Clone, Debug)]
888pub struct CacheStatusReply {
889 pub worker_idx: usize,
890 pub snapshot_label: String,
891 pub cache_hit: i32,
892 pub cache_miss: i32,
893 pub cache_used_bytes: i32,
894}
895
896/// Per-worker outbound channel capacity. Round-robin dispatch keeps
897/// queue depth at ~0 on hot paths; the small slack absorbs jitter
898/// without a runtime mutex.
899const READER_WORKER_CHANNEL_CAPACITY: usize = 4;
900
901impl ReaderWorkerPool {
902 fn new(connections: Vec<Connection>) -> Self {
903 let live_workers = Arc::new(AtomicUsize::new(0));
904 let mut senders = Vec::with_capacity(connections.len());
905 let mut handles = Vec::with_capacity(connections.len());
906 for (idx, connection) in connections.into_iter().enumerate() {
907 let (tx, rx) = mpsc::sync_channel::<ReaderRequest>(READER_WORKER_CHANNEL_CAPACITY);
908 let live = Arc::clone(&live_workers);
909 let handle = thread::Builder::new()
910 .name(format!("fathomdb-reader-{idx}"))
911 .spawn(move || reader_worker_loop(connection, rx, live))
912 .expect("spawn reader worker");
913 senders.push(tx);
914 handles.push(handle);
915 }
916 Self {
917 senders,
918 handles: Mutex::new(Some(handles)),
919 next: AtomicUsize::new(0),
920 shutdown: AtomicBool::new(false),
921 live_workers,
922 }
923 }
924
925 fn worker_count(&self) -> usize {
926 self.senders.len()
927 }
928
929 fn live_count(&self) -> usize {
930 self.live_workers.load(Ordering::SeqCst)
931 }
932
933 /// Pack 6.G G.1 — broadcast a `LookasideStatus` request to every
934 /// worker (not round-robin) and collect each worker's
935 /// `SQLITE_DBSTATUS_LOOKASIDE_USED`. Used only by the debug
936 /// integration test for post-warmup lookaside-slot consumption.
937 #[cfg(debug_assertions)]
938 fn lookaside_used_per_worker(&self) -> Vec<i32> {
939 let mut results = Vec::with_capacity(self.senders.len());
940 for sender in &self.senders {
941 let (tx, rx) = mpsc::sync_channel::<i32>(1);
942 if sender.send(ReaderRequest::LookasideStatus { respond: tx }).is_ok() {
943 results.push(rx.recv().unwrap_or(-1));
944 } else {
945 results.push(-1);
946 }
947 }
948 results
949 }
950
951 /// Pack 6.G G.3.5 — broadcast a `CacheStatus` request to every
952 /// worker and collect each worker's `(cache_hit, cache_miss,
953 /// cache_used_bytes)` triple. Same broadcast pattern as G.1's
954 /// `lookaside_used_per_worker`. Returns one `CacheStatusReply` per
955 /// worker in worker-index order.
956 #[cfg(debug_assertions)]
957 fn cache_status_per_worker(&self, snapshot_label: &str) -> Vec<CacheStatusReply> {
958 let mut results = Vec::with_capacity(self.senders.len());
959 for (idx, sender) in self.senders.iter().enumerate() {
960 let (tx, rx) = mpsc::sync_channel::<(String, i32, i32, i32)>(1);
961 let request = ReaderRequest::CacheStatus {
962 snapshot_label: snapshot_label.to_string(),
963 respond: tx,
964 };
965 if sender.send(request).is_ok() {
966 if let Ok((label, hit, miss, used)) = rx.recv() {
967 results.push(CacheStatusReply {
968 worker_idx: idx,
969 snapshot_label: label,
970 cache_hit: hit,
971 cache_miss: miss,
972 cache_used_bytes: used,
973 });
974 continue;
975 }
976 }
977 results.push(CacheStatusReply {
978 worker_idx: idx,
979 snapshot_label: snapshot_label.to_string(),
980 cache_hit: -1,
981 cache_miss: -1,
982 cache_used_bytes: -1,
983 });
984 }
985 results
986 }
987
988 /// OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — broadcast a
989 /// `SecureDeleteStatus` request to every worker and collect each worker's
990 /// `PRAGMA secure_delete` value. Same broadcast pattern as G.1's
991 /// `lookaside_used_per_worker`. Proves the standing secure_delete flag is ON
992 /// on the reader-pool connections, not just the writer.
993 #[cfg(debug_assertions)]
994 fn secure_delete_per_worker(&self) -> Vec<i64> {
995 let mut results = Vec::with_capacity(self.senders.len());
996 for sender in &self.senders {
997 let (tx, rx) = mpsc::sync_channel::<i64>(1);
998 if sender.send(ReaderRequest::SecureDeleteStatus { respond: tx }).is_ok() {
999 results.push(rx.recv().unwrap_or(-1));
1000 } else {
1001 results.push(-1);
1002 }
1003 }
1004 results
1005 }
1006
1007 /// Hot path. Lock-free dispatch: `AtomicUsize::fetch_add` selects
1008 /// the worker, then a single `SyncSender::send` enqueues the
1009 /// request. No global mutex is taken on the request path.
1010 // The `Search` variant contains a SyncSender and boxed fields (filter, raw_query);
1011 // even after FIX-4 (raw_query: Box<str>), the variant remains large due to the
1012 // SyncSender channel ownership. The Err return is only ever a no-worker/shutdown
1013 // signal, never heap-allocated repeatedly, so the allow is justified by the
1014 // channel ownership model.
1015 #[allow(clippy::result_large_err)]
1016 fn dispatch(&self, request: ReaderRequest) -> Result<(), ReaderRequest> {
1017 if self.shutdown.load(Ordering::Relaxed) {
1018 return Err(request);
1019 }
1020 let n = self.senders.len();
1021 if n == 0 {
1022 return Err(request);
1023 }
1024 let idx = self.next.fetch_add(1, Ordering::Relaxed) % n;
1025 self.senders[idx].send(request).map_err(|err| err.0)
1026 }
1027
1028 /// Signal every worker to exit and join its thread. Idempotent —
1029 /// safe to call from `Engine::close` and again from
1030 /// `ReaderWorkerPool::Drop`.
1031 fn shutdown(&self) {
1032 if self.shutdown.swap(true, Ordering::SeqCst) {
1033 return;
1034 }
1035 for sender in &self.senders {
1036 let _ = sender.send(ReaderRequest::Shutdown);
1037 }
1038 if let Ok(mut slot) = self.handles.lock() {
1039 if let Some(handles) = slot.take() {
1040 for handle in handles {
1041 let _ = handle.join();
1042 }
1043 }
1044 }
1045 }
1046}
1047
1048impl Drop for ReaderWorkerPool {
1049 fn drop(&mut self) {
1050 self.shutdown();
1051 }
1052}
1053
1054fn reader_worker_loop(
1055 mut connection: Connection,
1056 rx: Receiver<ReaderRequest>,
1057 live_workers: Arc<AtomicUsize>,
1058) {
1059 live_workers.fetch_add(1, Ordering::SeqCst);
1060 // Drop guard so the live counter decrements even on panic.
1061 struct LiveGuard(Arc<AtomicUsize>);
1062 impl Drop for LiveGuard {
1063 fn drop(&mut self) {
1064 self.0.fetch_sub(1, Ordering::SeqCst);
1065 }
1066 }
1067 let _guard = LiveGuard(live_workers);
1068
1069 while let Ok(request) = rx.recv() {
1070 match request {
1071 ReaderRequest::Shutdown => break,
1072 ReaderRequest::Search {
1073 compiled,
1074 query_vector,
1075 query_vector_bin,
1076 search_limit,
1077 filter,
1078 recency_enabled,
1079 importance_enabled,
1080 vector_stage_only,
1081 raw_query,
1082 rerank_depth,
1083 use_graph_arm,
1084 alpha,
1085 pool_n,
1086 explain,
1087 view,
1088 respond,
1089 } => {
1090 let result = read_search_in_tx(
1091 &mut connection,
1092 &compiled,
1093 query_vector.as_deref(),
1094 query_vector_bin.as_deref(),
1095 search_limit,
1096 filter.as_deref(),
1097 recency_enabled,
1098 importance_enabled,
1099 vector_stage_only,
1100 &raw_query,
1101 rerank_depth,
1102 use_graph_arm,
1103 alpha,
1104 pool_n,
1105 explain,
1106 view,
1107 );
1108 // Receiver may have been dropped if the caller went
1109 // away; nothing to do in that case.
1110 let _ = respond.send(result);
1111 }
1112 ReaderRequest::GetById { logical_ids, view, respond } => {
1113 let result = read_get_by_id_in_tx(&mut connection, &logical_ids, &view);
1114 let _ = respond.send(result);
1115 }
1116 ReaderRequest::ReadCollection { collection, after_id, limit, respond } => {
1117 let result = read_collection_in_tx(&mut connection, &collection, after_id, limit);
1118 let _ = respond.send(result);
1119 }
1120 ReaderRequest::ReadList { kind, predicates, limit, view, respond } => {
1121 let result = read_list_in_tx(&mut connection, &kind, &predicates, limit, &view);
1122 let _ = respond.send(result);
1123 }
1124 ReaderRequest::GraphNeighbors { root_logical_id, depth, direction, view, respond } => {
1125 let result = graph_neighbors_in_tx(
1126 &mut connection,
1127 &root_logical_id,
1128 depth,
1129 direction,
1130 &view,
1131 );
1132 let _ = respond.send(result);
1133 }
1134 ReaderRequest::CrossedBoundarySince { since, view, respond } => {
1135 let result = crossed_boundary_since_in_tx(&mut connection, since, &view);
1136 let _ = respond.send(result);
1137 }
1138 ReaderRequest::SearchExpand { search_hits, depth, respond } => {
1139 let result = search_expand_in_tx(&mut connection, &search_hits, depth);
1140 let _ = respond.send(result);
1141 }
1142 ReaderRequest::ExplainGraphNeighbors { root_logical_id, depth, direction, respond } => {
1143 let result = explain_graph_neighbors_in_tx(
1144 &mut connection,
1145 &root_logical_id,
1146 depth,
1147 direction,
1148 );
1149 let _ = respond.send(result);
1150 }
1151 #[cfg(debug_assertions)]
1152 ReaderRequest::LookasideStatus { respond } => {
1153 let _ = respond.send(read_lookaside_used_hiwtr(&connection));
1154 }
1155 #[cfg(debug_assertions)]
1156 ReaderRequest::CacheStatus { snapshot_label, respond } => {
1157 let (hit, miss, used) = read_cache_status(&connection);
1158 let _ = respond.send((snapshot_label, hit, miss, used));
1159 }
1160 #[cfg(debug_assertions)]
1161 ReaderRequest::SecureDeleteStatus { respond } => {
1162 let value: i64 =
1163 connection.query_row("PRAGMA secure_delete", [], |r| r.get(0)).unwrap_or(-1);
1164 let _ = respond.send(value);
1165 }
1166 }
1167 }
1168
1169 // Per `dev/design/engine.md` § Close path, uninstall the profile
1170 // callback before dropping the connection so SQLite cannot fire
1171 // one last callback against a `ProfileContext` whose Box is about
1172 // to free.
1173 uninstall_profile_callback(&connection);
1174 drop(connection);
1175}
1176
1177/// 0.8.20 keystone closeout fix-3 — a test-only rendezvous hook fired at the TOP
1178/// of [`read_search_in_tx`], BEFORE the reader opens its deferred transaction.
1179///
1180/// It exists ONLY to make the validate/execute TOCTOU race deterministic: a test
1181/// arms a closure that parks the reader worker here (after the caller-side search
1182/// setup, before the reader pins its snapshot), performs a concurrent
1183/// `configure_projections` DROP of a `filterable` attribute on the writer
1184/// connection, then releases the reader. The reader then pins a snapshot that
1185/// INCLUDES the drop — exactly the window that used to yield an opaque `no such
1186/// column` `Storage` error and now yields a typed `InvalidFilter`. Kept OFF the
1187/// governed surface (`_for_test`), mirroring the sanctioned
1188/// `set_vector_stage_only_for_test` seam pattern. Disarmed by default: a single
1189/// `Relaxed` atomic load per search (same class as the four hot-path atomics
1190/// already read here), fires at most once (the closure is `take`n), and is a
1191/// no-op in production because nothing ever arms it.
1192mod reader_search_hook {
1193 use std::sync::atomic::{AtomicBool, Ordering};
1194 use std::sync::Mutex;
1195
1196 static ARMED: AtomicBool = AtomicBool::new(false);
1197 #[allow(clippy::type_complexity)]
1198 static HOOK: Mutex<Option<Box<dyn Fn() + Send>>> = Mutex::new(None);
1199
1200 pub(crate) fn arm(hook: Box<dyn Fn() + Send>) {
1201 *HOOK.lock().expect("reader-search hook mutex") = Some(hook);
1202 ARMED.store(true, Ordering::SeqCst);
1203 }
1204
1205 pub(crate) fn clear() {
1206 ARMED.store(false, Ordering::SeqCst);
1207 *HOOK.lock().expect("reader-search hook mutex") = None;
1208 }
1209
1210 /// Fire the armed hook exactly ONCE, then disarm. Cheap early-out when
1211 /// disarmed (the production and common-test path).
1212 pub(crate) fn fire() {
1213 if !ARMED.load(Ordering::SeqCst) {
1214 return;
1215 }
1216 // Disarm first so a re-entrant / second reader never re-fires.
1217 ARMED.store(false, Ordering::SeqCst);
1218 let hook = HOOK.lock().expect("reader-search hook mutex").take();
1219 if let Some(hook) = hook {
1220 hook();
1221 }
1222 }
1223}
1224
1225/// 0.8.20 keystone closeout fix-3 — arm the [`reader_search_hook`] (test-only).
1226/// See that module's docs. `#[doc(hidden)]`, `_for_test`; never re-exported from
1227/// the `fathomdb` facade.
1228#[doc(hidden)]
1229pub fn arm_reader_search_hook_for_test(hook: Box<dyn Fn() + Send>) {
1230 reader_search_hook::arm(hook);
1231}
1232
1233/// 0.8.20 keystone closeout fix-3 — disarm the [`reader_search_hook`] (test-only).
1234#[doc(hidden)]
1235pub fn clear_reader_search_hook_for_test() {
1236 reader_search_hook::clear();
1237}
1238
1239impl ProjectionRuntime {
1240 fn new(
1241 path: PathBuf,
1242 embedder: Option<Arc<dyn Embedder>>,
1243 embedder_identity: EmbedderIdentity,
1244 mean_already_pinned: bool,
1245 subscribers: Arc<lifecycle::SubscriberRegistry>,
1246 ) -> Self {
1247 // EU-5b/EU-5f — only allocate the streaming accumulator when the
1248 // workspace's identity is MC-required AND no mean has been pinned
1249 // yet on disk. Allocating it for an already-pinned workspace would
1250 // let a later 256-doc run RE-pin and overwrite the compute-once
1251 // mean (violating `dev/design/embedder.md` §0.3). Other identities
1252 // pay no memory cost (`Option::None`).
1253 let mc_required = identity_requires_mean_centering(&embedder_identity);
1254 let mean_accumulator = if mc_required && !mean_already_pinned {
1255 Some(MeanAccumulator::new(embedder_identity.dimension as usize))
1256 } else {
1257 None
1258 };
1259 let shared = Arc::new(ProjectionRuntimeShared {
1260 path,
1261 embedder,
1262 embedder_identity,
1263 subscribers,
1264 state: Mutex::new(ProjectionRuntimeState::default()),
1265 state_cvar: Condvar::new(),
1266 queue: Mutex::new(VecDeque::new()),
1267 queue_cvar: Condvar::new(),
1268 retry_delays_ms: Mutex::new(DEFAULT_PROJECTION_RETRY_DELAYS_MS.to_vec()),
1269 embed_timeout_ms: AtomicU64::new(DEFAULT_EMBED_TIMEOUT_MS),
1270 embed_serialize: Mutex::new(()),
1271 live_embed_threads: Arc::new(AtomicU64::new(0)),
1272 embed_circuit_open: AtomicBool::new(false),
1273 embed_circuit_threshold: AtomicU64::new(DEFAULT_EMBED_CIRCUIT_THRESHOLD),
1274 mean_accumulator: Mutex::new(mean_accumulator),
1275 pending_events: Mutex::new(Vec::new()),
1276 commit_gate: Mutex::new(()),
1277 search_limit_override: AtomicUsize::new(SEARCH_RERANK_LIMIT),
1278 recency_reweight_enabled: AtomicBool::new(false),
1279 importance_reweight_enabled: AtomicBool::new(false),
1280 vector_stage_only_for_test: AtomicBool::new(false),
1281 #[cfg(debug_assertions)]
1282 force_recompute_failure: AtomicBool::new(false),
1283 #[cfg(debug_assertions)]
1284 force_projection_commit_failure: AtomicUsize::new(0),
1285 #[cfg(debug_assertions)]
1286 projection_commit_failure_pause: Mutex::new(None),
1287 #[cfg(debug_assertions)]
1288 projection_stop_ack: Mutex::new(None),
1289 });
1290
1291 let dispatcher_shared = Arc::clone(&shared);
1292 let dispatcher = thread::spawn(move || projection_dispatcher_loop(dispatcher_shared));
1293
1294 let mut workers = Vec::with_capacity(PROJECTION_WORKERS);
1295 for _ in 0..PROJECTION_WORKERS {
1296 let worker_shared = Arc::clone(&shared);
1297 workers.push(thread::spawn(move || projection_worker_loop(worker_shared)));
1298 }
1299
1300 Self { shared, dispatcher: Mutex::new(Some(dispatcher)), workers: Mutex::new(workers) }
1301 }
1302
1303 fn notify_new_work(&self) {
1304 if let Ok(mut state) = self.shared.state.lock() {
1305 state.pending_scan = true;
1306 self.shared.state_cvar.notify_all();
1307 }
1308 }
1309
1310 fn set_frozen(&self, frozen: bool) {
1311 if let Ok(mut state) = self.shared.state.lock() {
1312 state.frozen = frozen;
1313 if !frozen {
1314 state.pending_scan = true;
1315 }
1316 self.shared.state_cvar.notify_all();
1317 }
1318 }
1319
1320 fn wait_for_idle(&self, timeout_ms: u64) -> bool {
1321 let deadline = Instant::now() + Duration::from_millis(timeout_ms);
1322 let mut state = match self.shared.state.lock() {
1323 Ok(state) => state,
1324 Err(_) => return false,
1325 };
1326 loop {
1327 if state.active_jobs == 0 && state.queued_jobs == 0 {
1328 drop(state);
1329 if !database_has_pending_projection_work(&self.shared.path).unwrap_or(true) {
1330 return true;
1331 }
1332 state = match self.shared.state.lock() {
1333 Ok(state) => state,
1334 Err(_) => return false,
1335 };
1336 }
1337 let now = Instant::now();
1338 if now >= deadline {
1339 return false;
1340 }
1341 let wait = deadline.saturating_duration_since(now);
1342 let Ok((next_state, _)) = self.shared.state_cvar.wait_timeout(state, wait) else {
1343 return false;
1344 };
1345 state = next_state;
1346 }
1347 }
1348
1349 fn set_retry_delays_for_test(&self, delays_ms: &[u64]) {
1350 if let Ok(mut delays) = self.shared.retry_delays_ms.lock() {
1351 *delays = delays_ms.to_vec();
1352 }
1353 }
1354
1355 #[cfg(debug_assertions)]
1356 fn force_next_projection_commit_failure_for_test(&self) {
1357 self.shared.force_projection_commit_failure.store(1, Ordering::SeqCst);
1358 }
1359
1360 #[cfg(debug_assertions)]
1361 fn force_next_projection_storage_failure_for_test(&self) {
1362 self.shared.force_projection_commit_failure.store(2, Ordering::SeqCst);
1363 }
1364
1365 #[cfg(debug_assertions)]
1366 fn pause_projection_commit_failure_cleanup_for_test(
1367 &self,
1368 reported: Arc<Barrier>,
1369 release: Arc<Barrier>,
1370 ) {
1371 *self
1372 .shared
1373 .projection_commit_failure_pause
1374 .lock()
1375 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((reported, release));
1376 }
1377
1378 #[cfg(debug_assertions)]
1379 fn acknowledge_projection_stop_for_test(&self, acknowledged: Arc<Barrier>) {
1380 *self.shared.projection_stop_ack.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) =
1381 Some(acknowledged);
1382 }
1383
1384 fn set_embed_timeout_ms_for_test(&self, timeout_ms: u64) {
1385 self.shared.embed_timeout_ms.store(timeout_ms, Ordering::Relaxed);
1386 }
1387
1388 fn set_embed_circuit_threshold_for_test(&self, threshold: u64) {
1389 self.shared.embed_circuit_threshold.store(threshold, Ordering::Relaxed);
1390 }
1391
1392 fn embed_circuit_open_for_test(&self) -> bool {
1393 self.shared.embed_circuit_open.load(Ordering::Relaxed)
1394 }
1395
1396 fn stop(&self) {
1397 if let Ok(mut state) = self.shared.state.lock() {
1398 if state.stopping {
1399 return;
1400 }
1401 state.stopping = true;
1402 state.pending_scan = false;
1403 self.shared.state_cvar.notify_all();
1404 }
1405 #[cfg(debug_assertions)]
1406 if let Some(acknowledged) = self
1407 .shared
1408 .projection_stop_ack
1409 .lock()
1410 .unwrap_or_else(|poisoned| poisoned.into_inner())
1411 .take()
1412 {
1413 acknowledged.wait();
1414 }
1415 if let Ok(mut queue) = self.shared.queue.lock() {
1416 queue.clear();
1417 self.shared.queue_cvar.notify_all();
1418 }
1419
1420 if let Ok(mut dispatcher) = self.dispatcher.lock() {
1421 if let Some(handle) = dispatcher.take() {
1422 let _ = handle.join();
1423 }
1424 }
1425 if let Ok(mut workers) = self.workers.lock() {
1426 for handle in workers.drain(..) {
1427 let _ = handle.join();
1428 }
1429 }
1430 }
1431}
1432
1433#[derive(Clone, Debug, Eq, PartialEq)]
1434pub struct OpenReport {
1435 pub schema_version_before: u32,
1436 pub schema_version_after: u32,
1437 pub migration_steps: Vec<MigrationStepReport>,
1438 pub embedder_warmup_ms: u64,
1439 pub query_backend: &'static str,
1440 pub default_embedder: EmbedderIdentity,
1441 /// Total wall time the loader spent materializing default-embedder
1442 /// weights — covers HF GETs, sha256 verification, atomic rename,
1443 /// parent-dir fsync (POSIX), and cache directory writes. This is
1444 /// the "engine open paid by the embedder" envelope, useful for SLA
1445 /// budgeting; it is intentionally wider than just the bytes-flowing
1446 /// time so callers see the full first-use cost.
1447 ///
1448 /// `Some(ms)` when network bytes flowed (`bytes_downloaded > 0`);
1449 /// `None` for caller-supplied embedders (loader bypassed) and on
1450 /// full cache hits (no bytes flowed). For pure per-file network
1451 /// analysis, use the `DefaultEmbedderDownload` events on
1452 /// [`embedder_events`](Self::embedder_events) — each event carries
1453 /// the file's bytes + sha256 + cache path.
1454 pub embedder_download_ms: Option<u64>,
1455 /// Structured loader events (`dev/design/embedder.md` §7). Empty for
1456 /// caller-supplied embedders; populated from `LoadedWeights.events`
1457 /// for the Default path.
1458 pub embedder_events: Vec<EmbedderEvent>,
1459 /// Static identity capability (`dev/design/embedder.md` §0.6). True
1460 /// iff the live embedder identity is the bge-small default, which is
1461 /// the only identity that ships with the EU-5a2 mean-centering apply
1462 /// paths. `false` for `fathomdb-noop` and for any other
1463 /// caller-supplied identity. EU-5b's identity flip makes the Default
1464 /// path return `true` here.
1465 pub embedder_mean_centering_required: bool,
1466 /// Dynamic workspace state (`dev/design/embedder.md` §0.6). True iff
1467 /// `_fathomdb_embedder_profiles.mean_vec IS NOT NULL` for the default
1468 /// profile. EU-5a2 reads from the schema column added in migration
1469 /// step 10; the value is dimension-validated (§0.2) at open time
1470 /// and fails closed via `EmbedderIdentityMismatch` on drift.
1471 pub embedder_mean_vec_pinned: bool,
1472 /// 0.8.18 Slice 5 (#5 vector-equivalence probe, R-VEQ-6) — degraded-open
1473 /// observability. `true` iff the open-time #5 self-check re-embedded the 45
1474 /// committed probes and found a divergence beyond the frozen D4 floor (a
1475 /// Phase-1 mean-centered `embedding_bin` sign flip OR a Phase-2 un-centered
1476 /// L2 over `VECTOR_EQUIVALENCE_L2_EPSILON`). When `true`, `Engine::open`
1477 /// SUCCEEDED but every vector-dependent arm refuses at query time with
1478 /// `EngineError::VectorEquivalenceMismatch`; the text-only/FTS-only path stays
1479 /// serviceable. The state is RE-DERIVED at every open (the probe re-runs), so
1480 /// a reopen with a still-divergent backend stays degraded (never silently
1481 /// re-enables dense) and a reopen with a matching backend clears it.
1482 pub dense_disabled: bool,
1483 /// R-VEQ-6 — human-readable reason for `dense_disabled` (which representation
1484 /// tripped: P1 flip count or P2 L2). `None` when `dense_disabled == false`.
1485 pub dense_disabled_reason: Option<String>,
1486}
1487
1488#[derive(Debug)]
1489pub struct OpenedEngine {
1490 pub engine: Engine,
1491 pub report: OpenReport,
1492}
1493
1494/// EU-5b — loader-supplied open-time telemetry threaded into
1495/// `OpenReport.embedder_download_ms` and `OpenReport.embedder_events`.
1496#[derive(Clone, Debug)]
1497struct LoaderInfo {
1498 download_ms: Option<u64>,
1499 events: Vec<EmbedderEvent>,
1500}
1501
1502#[derive(Clone, Debug, Eq, PartialEq)]
1503pub struct WriteReceipt {
1504 /// The batch high-water cursor — the `write_cursor` of the last row written
1505 /// (also the engine's new `next_cursor`). Unchanged from 0.7.x.
1506 pub cursor: u64,
1507 /// G0 (Slice 15) — the per-row `write_cursor` of each row in the batch, 1:1
1508 /// with input order. This is the `write_cursor`-as-row-id identity carrier
1509 /// (HITL-accepted for 0.8.0; a dedicated `row_id` is deferred). For an
1510 /// N-row batch this is `[cursor-N+1, …, cursor]`.
1511 pub row_cursors: Vec<u64>,
1512 /// G8 (Slice 20 / F10) — count of edge endpoints in this batch that point at
1513 /// a non-existent **or superseded** canonical node. An endpoint is dangling
1514 /// when no **active** node (`superseded_at IS NULL`) carries its `logical_id`;
1515 /// `from_id` and `to_id` are probed independently, so one edge contributes 0,
1516 /// 1, or 2. This is **informational** (default FLAG-AND-COUNT: the batch
1517 /// commits regardless) and `0` whenever the batch committed no active edges.
1518 pub dangling_edge_endpoints: u64,
1519}
1520
1521/// Soft-fallback signal carried on hybrid `search` results.
1522///
1523/// Per `dev/design/retrieval.md` § Soft-fallback signal, this record is
1524/// present only when one non-essential branch could not contribute. Total
1525/// request failure is not expressed via this carrier.
1526#[derive(Clone, Debug, Eq, PartialEq)]
1527pub struct SoftFallback {
1528 pub branch: SoftFallbackBranch,
1529}
1530
1531/// Which retrieval branch produced a hit (or could not contribute).
1532///
1533/// `Vector` = ANN vector branch (node bodies); `Text` = node-body FTS branch;
1534/// `TextEdge` = edge-body hit (FTS via `search_index_edges` OR vector-projected
1535/// edge facts — both produce the same kind="edge_fact" row shape and share the
1536/// same downstream handling in `search_expand_in_tx`). `Vector`/`Text` also
1537/// used as soft-fallback signal when the respective branch is empty.
1538/// `GraphArm` = R3 (Slice 30) BFS-reachable node from the temporal fact-edge
1539/// graph arm. Owned by `dev/design/retrieval.md`.
1540#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1541pub enum SoftFallbackBranch {
1542 Vector,
1543 Text,
1544 /// G11 (Slice 15) — edge-body hit from `search_index_edges` FTS or from
1545 /// `vector_default` edge-fact projection. `kind = "edge_fact"` in both cases.
1546 TextEdge,
1547 /// R3 (Slice 30) — BFS-reachable node from the temporal fact-edge graph arm.
1548 /// Only present when `use_graph_arm = true`. Nodes in the graph arm were NOT
1549 /// in the initial vector/text fused result (newly-reached nodes only).
1550 GraphArm,
1551}
1552
1553/// A single structured search hit (G1 / AC-057a-clean).
1554///
1555/// Both retrieval branches emit this shape. `id` is a typed [`IdSpace`] — the
1556/// **permanent** caller-facing identity since C-2 (0.8.19 / TC-8), NOT the
1557/// interim `write_cursor: u64` the pre-0.8.19 releases carried and NOT an
1558/// interim carrier awaiting a later swap. The positional `write_cursor` field
1559/// below survives as engine-internal book-keeping and the SDK bindings do not
1560/// surface it. See the field docs on [`SearchHit::id`] / [`IdSpace`].
1561/// `score` is the **G9 RRF-fused** relevance (`Σ 1/(RRF_K + rank)` over the
1562/// branches that surfaced this body; higher = more relevant), optionally
1563/// recency-reweighted when the dedicated recency flag is on. Raw `vec_distance_l2`
1564/// and `bm25()` are fused on **rank**, never compared raw (they are not
1565/// comparable). `branch` tags which retrieval branch produced the representative
1566/// hit (vector-first when a body is surfaced by both).
1567///
1568/// `source_id` (G0 Phase-2 / BLOCK-2; generalised by TC-31 in 0.8.20 Slice 10a)
1569/// carries the source-document provenance of a hit — the identifier
1570/// [`Engine::erase_source`] consumes. It is populated on **every** hit path:
1571/// - **Node hits** (text/BM25F, vector, and the pre-step-12 legacy text
1572/// fallback) carry the **node's own** `canonical_nodes.source_id`.
1573/// - **Edge hits** (edge-FTS from `search_index_edges`, and edge-fact hits
1574/// hydrated by the vector arm) carry the **edge's own**
1575/// `canonical_edges.source_id`.
1576/// - **GraphArm** hits carry the **traversed edge's** `source_id` (the session
1577/// the fact-edge was extracted from) — unchanged by TC-31 — enabling
1578/// `doc_id_of` to resolve a graph-reached entity back to a gold session id.
1579///
1580/// Before TC-31 only the GraphArm branch populated this, which left
1581/// `erase_source` shipping with its argument unreachable from a text or vector
1582/// hit (0.8.19 also stopped surfacing `write_cursor` to the SDKs, removing the
1583/// only fallback route). It stays `Option<String>`: a row written before 0.8.20,
1584/// or a GOVERNED row deliberately spared by the step-21 backfill under the TC-11
1585/// pin, legitimately carries NULL at rest and must read back as `None` rather
1586/// than a fabricated value.
1587///
1588/// The field never participates in ranking, so result order and scores are
1589/// unaffected.
1590///
1591/// C-2 (0.8.19 / OPP-12 record-lifecycle Phase-1, TC-8) — the **id-space** of a
1592/// [`SearchHit::id`]. A closed, typed enum (NOT a magic-prefixed string) — the
1593/// C-2 binding ratified in the OPP-12 protocol:
1594/// - [`Logical`](IdSpaceKind::Logical) — `"l:"`, a governed/canonical node keyed
1595/// by its `logical_id` (the only lifecycle-addressable space).
1596/// - [`Content`](IdSpaceKind::Content) — `"h:"`, a doc-seeded/anonymous node
1597/// keyed by a content hash of its body (the dominant corpus hit class).
1598/// - [`Passage`](IdSpaceKind::Passage) — `"p:"`, a synthetic `rerank_passages`
1599/// hit keyed by the caller-supplied passage ordinal.
1600#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1601pub enum IdSpaceKind {
1602 /// `"l:"` — governed/canonical node (its `logical_id`).
1603 Logical,
1604 /// `"h:"` — doc-seeded/anonymous node (content hash of the body).
1605 Content,
1606 /// `"p:"` — synthetic rerank passage (caller-supplied ordinal).
1607 Passage,
1608}
1609
1610impl IdSpaceKind {
1611 /// The two-char id-space prefix (`"l:"` / `"h:"` / `"p:"`) used in the
1612 /// prefixed string form. Byte-identical to the pre-swap `derive_stable_id`
1613 /// tags so real-gold keying stays a no-op.
1614 #[must_use]
1615 pub fn prefix(self) -> &'static str {
1616 match self {
1617 Self::Logical => "l:",
1618 Self::Content => "h:",
1619 Self::Passage => "p:",
1620 }
1621 }
1622
1623 /// The lowercase discriminant (`"logical"` / `"content"` / `"passage"`)
1624 /// surfaced through the SDK bindings as the `IdSpace.space` field (mirrors
1625 /// how `SoftFallbackBranch` is surfaced as a `branch` string).
1626 #[must_use]
1627 pub fn as_str(self) -> &'static str {
1628 match self {
1629 Self::Logical => "logical",
1630 Self::Content => "content",
1631 Self::Passage => "passage",
1632 }
1633 }
1634}
1635
1636/// C-2 (0.8.19 / OPP-12 Phase-1, TC-8) — the typed, non-null, id-space-**total**
1637/// carrier for [`SearchHit::id`]. Subsumes the interim `write_cursor` id AND the
1638/// additive Cause-A `stable_id` field of prior releases: the `value` is the BARE
1639/// id (prefix stripped), and [`to_prefixed`](IdSpace::to_prefixed) reproduces the
1640/// pre-swap `stable_id` string byte-for-byte (`l:`/`h:` unchanged) so
1641/// cross-session real-gold keying continues on `id` as a true no-op.
1642///
1643/// Lifecycle-addressability is a type check consumed downstream by the
1644/// `transition`/`purge` verbs: only [`Logical`](IdSpaceKind::Logical) is
1645/// lifecycle-addressable; `Content`/`Passage` are total-but-not-addressable.
1646#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1647pub struct IdSpace {
1648 /// The typed id-space (`Logical`/`Content`/`Passage`).
1649 pub space: IdSpaceKind,
1650 /// The bare id value (id-space prefix stripped).
1651 pub value: String,
1652}
1653
1654impl IdSpace {
1655 /// A `Logical` (`"l:"`) id carrying `value` (a `logical_id`).
1656 pub fn logical(value: impl Into<String>) -> Self {
1657 Self { space: IdSpaceKind::Logical, value: value.into() }
1658 }
1659
1660 /// A `Content` (`"h:"`) id carrying `value` (a content hash).
1661 pub fn content(value: impl Into<String>) -> Self {
1662 Self { space: IdSpaceKind::Content, value: value.into() }
1663 }
1664
1665 /// A `Passage` (`"p:"`) id carrying `value` (a caller-supplied ordinal).
1666 pub fn passage(value: impl Into<String>) -> Self {
1667 Self { space: IdSpaceKind::Passage, value: value.into() }
1668 }
1669
1670 /// The prefixed string form (`{prefix}{value}`) — byte-identical to the
1671 /// pre-swap `derive_stable_id` output for `l:`/`h:`.
1672 #[must_use]
1673 pub fn to_prefixed(&self) -> String {
1674 format!("{}{}", self.space.prefix(), self.value)
1675 }
1676
1677 /// Parse the prefixed string form back into a typed `IdSpace`. Round-trip
1678 /// stable: `IdSpace::parse(&x.to_prefixed()) == Some(x)`. Only the FIRST
1679 /// two-char id-space prefix is stripped, so a value that itself contains
1680 /// `":"` round-trips unchanged. Returns `None` for an untagged string.
1681 #[must_use]
1682 pub fn parse(s: &str) -> Option<Self> {
1683 if let Some(v) = s.strip_prefix("l:") {
1684 Some(Self::logical(v))
1685 } else if let Some(v) = s.strip_prefix("h:") {
1686 Some(Self::content(v))
1687 } else {
1688 s.strip_prefix("p:").map(Self::passage)
1689 }
1690 }
1691}
1692
1693impl std::fmt::Display for IdSpace {
1694 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1695 write!(f, "{}{}", self.space.prefix(), self.value)
1696 }
1697}
1698
1699/// Derives `Clone, Debug, PartialEq` but **not `Eq`** — `score: f64` forbids
1700/// total equality.
1701#[derive(Clone, Debug, PartialEq)]
1702pub struct SearchHit {
1703 /// C-2 (0.8.19 / TC-8) — the typed, non-null, id-space-total hit id
1704 /// ([`IdSpace`]). Was the interim `write_cursor: u64` in prior releases; now
1705 /// carries the cross-session-stable key: `value` is the BARE (prefix-stripped)
1706 /// id, and [`to_prefixed`](IdSpace::to_prefixed) (== `{prefix}{value}`)
1707 /// reproduces the pre-swap `stable_id` byte-for-byte (the real-gold-keying
1708 /// no-op). Governed hits are `l:`, doc-seeded hits `h:`, synthetic
1709 /// passages `p:`. This is the caller-facing identity; the positional
1710 /// `write_cursor` below is engine-internal book-keeping.
1711 pub id: IdSpace,
1712 /// Engine-internal positional cursor (the value `id` carried before the C-2
1713 /// swap). Reassigned on every re-projection/re-ingest — NOT cross-session
1714 /// stable, NOT the caller-facing id. Retained because the engine still needs
1715 /// a positional cursor for its own book-keeping (vector rowid mapping, the
1716 /// `state='active'` filter lookups, RRF recency/importance reweight keys,
1717 /// telemetry `result_ids` keying, `search_expand` re-resolution). The SDK
1718 /// bindings do NOT surface it.
1719 pub write_cursor: u64,
1720 pub kind: String,
1721 pub body: String,
1722 pub score: f64,
1723 pub branch: SoftFallbackBranch,
1724 pub source_id: Option<String>,
1725 /// 0.8.5 (EXP-0) — per-candidate cross-encoder score `ce_norm =
1726 /// sigmoid(ce_logit) ∈ [0,1]`. `Some` ONLY for hits inside the reranked pool
1727 /// (the top `pool_n` when the CE model is loaded); `None` for the unreranked
1728 /// remainder, the `rerank_depth == 0` identity path, an empty list, and the
1729 /// no-CE-model soft-fallback. Additive + nullable: it never participates in
1730 /// ranking, so default-path ordering/scores stay byte-stable.
1731 pub ce_score: Option<f64>,
1732}
1733
1734/// G0 Phase-2 (E0a / BLOCK-1) — graph-arm frontier instrumentation. A
1735/// **side-channel** meter (deliberately NOT a `SearchResult`/`SearchHit` field —
1736/// byte stability) that proves whether the graph arm seeds a non-empty frontier.
1737/// Under the current doc-seeded path the frontier is empty (doc nodes carry
1738/// `logical_id = NULL`), so `seeds_resolved == 0` and `resolved_seed_rate == 0.0`
1739/// — this meter is the measurement that proves it (and, post-C1, the 0→>0 flip).
1740///
1741/// `resolved_seed_rate = seeds_resolved / seeds_considered`, with `0/0 → 0.0`.
1742#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1743pub struct GraphFrontierStats {
1744 /// Hits inspected as seed candidates (the `take(SEED_N)` window, skipping TextEdge).
1745 pub seeds_considered: u32,
1746 /// Seed candidates that resolved to an active `logical_id` (pushed onto the frontier).
1747 pub seeds_resolved: u32,
1748 /// Whether the BFS frontier was non-empty after seeding.
1749 pub frontier_nonempty: bool,
1750 /// Number of graph-arm `SearchHit`s emitted (reachable, not already in the two-arm result).
1751 pub graph_candidates_emitted: u32,
1752}
1753
1754impl GraphFrontierStats {
1755 /// `seeds_resolved / seeds_considered`, defined as `0.0` when nothing was considered.
1756 pub fn resolved_seed_rate(&self) -> f64 {
1757 if self.seeds_considered == 0 {
1758 0.0
1759 } else {
1760 f64::from(self.seeds_resolved) / f64::from(self.seeds_considered)
1761 }
1762 }
1763}
1764
1765/// Slice 30 (G2) — an active canonical node row returned by `read.get` /
1766/// `read.get_many`.
1767///
1768/// `logical_id` is the queried stable identity (echoed). `write_cursor` is the
1769/// interim id carrier (same column `SearchHit.id` carries). Only ACTIVE rows
1770/// (`superseded_at IS NULL`) are ever materialised into this shape; a missing or
1771/// superseded `logical_id` is a normal absence (`None`), never an error.
1772#[derive(Clone, Debug, Eq, PartialEq)]
1773pub struct NodeRecord {
1774 pub logical_id: String,
1775 pub kind: String,
1776 pub body: String,
1777 pub write_cursor: u64,
1778}
1779
1780/// 0.8.20 Slice 10b (R-20-RV / R-20-NV) — the **read view**: the single knob
1781/// that decides which `canonical_nodes` rows a read verb may see.
1782///
1783/// Every field is a *relaxation*: `ReadView::default()` is the STRICT view and
1784/// compiles to exactly the predicates the five read verbs carried before this
1785/// slice (`superseded_at IS NULL AND state = 'active'`), so the default read
1786/// path is behaviourally unchanged. Flags compose INDEPENDENTLY — each one
1787/// drops exactly one conjunct and no other.
1788///
1789/// The view is applied UNIFORMLY by [`Engine::read_get`],
1790/// [`Engine::read_get_many`], [`Engine::read_list`],
1791/// [`Engine::read_list_filter`] and [`Engine::graph_neighbors`] — and, inside
1792/// `graph_neighbors`, at EVERY position of EVERY direction's recursive CTE
1793/// (anchor, recursive join, final projection), so a relaxation cannot silently
1794/// apply on one traversal position and not another.
1795///
1796/// # World-time only
1797///
1798/// `valid_as_of` selects along the **world-time** (validity) axis only.
1799/// Transaction-time / `history_as_of` is explicitly OUT OF SCOPE — this type
1800/// deliberately has no way to ask "what did the database believe at time T".
1801#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1802pub struct ReadView {
1803 /// Relax `superseded_at IS NULL` — include superseded (historical) versions
1804 /// of a row, not just the current one. `false` (default) keeps the shipped
1805 /// current-version-only behaviour.
1806 ///
1807 /// On the point-lookup verbs ([`Engine::read_get`] /
1808 /// [`Engine::read_get_many`]) a `logical_id` can now match several rows;
1809 /// the slot resolves DETERMINISTICALLY to the highest `write_cursor` (the
1810 /// most recent version). Use [`Engine::read_list`] to enumerate history.
1811 pub include_superseded: bool,
1812
1813 /// Relax `state = 'active'` — include rows in a non-`active` lifecycle
1814 /// state (`pending` / `deleted` / `purged`). `false` (default) keeps the
1815 /// shipped active-only behaviour.
1816 pub include_inactive: bool,
1817
1818 /// Relax the validity-window predicate ENTIRELY — return rows whatever
1819 /// their `[valid_from, valid_until)` window, ignoring `valid_as_of`.
1820 /// `false` (default) filters to rows valid at the selected instant.
1821 ///
1822 /// Note this is a NO-OP on any row with an unbounded (NULL/NULL) window,
1823 /// which is every row that predates schema step 22.
1824 pub include_out_of_window: bool,
1825
1826 /// The instant (INTEGER epoch SECONDS, UTC) at which validity is evaluated.
1827 /// `None` (default) resolves to *now* at query time.
1828 ///
1829 /// This is the **`:now` seam**: whichever way it resolves, the instant is
1830 /// compiled as a BOUND PARAMETER, never a `datetime('now')` SQL literal —
1831 /// which is what makes node validity deterministically testable without
1832 /// clock games. (The shipped EDGE temporal filter still inlines
1833 /// `datetime('now')`; that path is untouched by this slice.)
1834 pub valid_as_of: Option<i64>,
1835}
1836
1837impl ReadView {
1838 /// The instant to bind for the validity predicate, or `None` when the view
1839 /// relaxes validity entirely (in which case no `:now` parameter is emitted
1840 /// and none must be bound).
1841 fn now_param(&self) -> Option<i64> {
1842 if self.include_out_of_window {
1843 return None;
1844 }
1845 Some(self.valid_as_of.unwrap_or_else(current_epoch_seconds))
1846 }
1847
1848 /// The existence conjunct for node-table `alias`. Each flag drops exactly
1849 /// one conjunct; the strict view reproduces the pre-slice predicate pair
1850 /// verbatim. Always begins with ` AND ` (or is empty), so every call site
1851 /// must already have a preceding `WHERE` predicate.
1852 fn existence_sql(&self, alias: &str) -> String {
1853 let mut sql = String::new();
1854 if !self.include_superseded {
1855 sql.push_str(&format!(" AND {alias}.superseded_at IS NULL"));
1856 }
1857 if !self.include_inactive {
1858 sql.push_str(&format!(" AND {alias}.state = 'active'"));
1859 }
1860 sql
1861 }
1862
1863 /// The validity conjunct for node-table `alias`, bound to positional
1864 /// parameter `?{now_idx}`. Empty when validity is relaxed.
1865 ///
1866 /// Encodes the HALF-OPEN window `[valid_from, valid_until)` with NULL
1867 /// meaning unbounded on that side — so a NULL/NULL row is valid at every
1868 /// instant and this conjunct never changes its visibility.
1869 fn validity_sql(&self, alias: &str, now_idx: usize) -> String {
1870 if self.include_out_of_window {
1871 return String::new();
1872 }
1873 format!(
1874 " AND ({alias}.valid_from IS NULL OR {alias}.valid_from <= ?{now_idx}) \
1875 AND ({alias}.valid_until IS NULL OR {alias}.valid_until > ?{now_idx})"
1876 )
1877 }
1878
1879 /// The full node predicate (existence + validity) for `alias`. This is the
1880 /// ONE function every read site calls, so no site can drift from another.
1881 fn node_sql(&self, alias: &str, now_idx: usize) -> String {
1882 format!("{}{}", self.existence_sql(alias), self.validity_sql(alias, now_idx))
1883 }
1884
1885 /// 0.8.20 Slice 15b fix-3 (F2) — resolve this view's validity instant ONCE
1886 /// and hand back a [`FrozenView`] that carries the resolved value.
1887 ///
1888 /// This is the ONLY constructor of a `FrozenView`, and therefore the only
1889 /// point on the search path where the wall clock is read.
1890 fn freeze(self) -> FrozenView {
1891 // TC-33: resolve the instant ONCE, unconditionally, and derive both
1892 // axes from it. `valid_as_of.unwrap_or_else(current_epoch_seconds)` is
1893 // exactly what `now_param()` computes, so the clock is read the same
1894 // number of times as before on every path that reads it at all.
1895 let resolved = self.valid_as_of.unwrap_or_else(current_epoch_seconds);
1896 let now = if self.include_out_of_window { None } else { Some(resolved) };
1897 FrozenView { view: self, now, edge_now: resolved }
1898 }
1899
1900 /// 0.8.20 Slice 15b fix-2 — the `search` path honours the VALIDITY axis of a
1901 /// `ReadView` and refuses the EXISTENCE axis. See [`Engine::search_view`] for
1902 /// why refusing beats silently ignoring.
1903 fn reject_existence_relaxation_on_search(&self) -> Result<(), EngineError> {
1904 let relaxed = match (self.include_superseded, self.include_inactive) {
1905 (true, true) => "include_superseded + include_inactive",
1906 (true, false) => "include_superseded",
1907 (false, true) => "include_inactive",
1908 (false, false) => return Ok(()),
1909 };
1910 Err(EngineError::InvalidArgument {
1911 msg: format!(
1912 "ReadView.{relaxed} is not supported on the search path; search hydrates from \
1913 projection indexes that are not version-complete, so only the validity axis \
1914 (valid_as_of / include_out_of_window) is honoured. Use read_list for history."
1915 ),
1916 })
1917 }
1918}
1919
1920/// 0.8.20 Slice 10b (R-20-NV) — one node that crossed a validity boundary
1921/// inside the interrogated interval, as reported by
1922/// [`Engine::crossed_boundary_since`].
1923///
1924/// A node can cross BOTH boundaries in the same interval (a window that opened
1925/// and closed inside it), so the two fields are independent `Option`s rather
1926/// than one enum.
1927#[derive(Clone, Debug, Eq, PartialEq)]
1928pub struct BoundaryCrossing {
1929 /// The node that crossed.
1930 pub node: NodeRecord,
1931 /// `Some(valid_from)` when the node BECAME VALID inside the interval.
1932 pub became_valid_at: Option<i64>,
1933 /// `Some(valid_until)` when the node BECAME INVALID inside the interval.
1934 pub became_invalid_at: Option<i64>,
1935}
1936
1937/// 0.8.20 Slice 15b fix-3 (F2) — a [`ReadView`] whose validity instant has
1938/// ALREADY been resolved, produced only by [`ReadView::freeze`].
1939///
1940/// R-20-NV requires `:now` to bind ONCE PER QUERY — not per row, and not per
1941/// ARM. The multi-arm search path made that easy to violate: each arm held a
1942/// `ReadView` and could call `now_param()`, which for the default view
1943/// (`valid_as_of == None`) reads the wall clock. Two arms, two instants, and a
1944/// query straddling a validity boundary gets nondeterministic membership.
1945///
1946/// The fix is TYPE-LEVEL rather than a comment asking future arms to behave:
1947/// the instant is resolved once at the top of `read_search_in_tx` and every arm
1948/// receives a `FrozenView`, which stores the resolved value in `now` and has NO
1949/// path back to the clock. An arm cannot re-resolve the instant because it
1950/// never holds anything that could — the failure mode is unreachable, not
1951/// merely discouraged.
1952#[derive(Clone, Copy, Debug)]
1953struct FrozenView {
1954 /// The underlying view — consulted for SQL SHAPE only (which conjuncts to
1955 /// emit), never to re-resolve the instant.
1956 view: ReadView,
1957 /// The instant resolved at freeze time. `None` ⇔ the view relaxes validity
1958 /// entirely, in which case no conjunct is emitted and nothing is bound.
1959 now: Option<i64>,
1960 /// TC-33 — the instant EDGE validity is evaluated at. Always present.
1961 ///
1962 /// The EXISTENCE-relaxation flag `include_out_of_window` belongs to the NODE
1963 /// validity axis and does NOT relax edge recency: an edge invalidated in the
1964 /// past stays excluded regardless. So this is the resolved instant even when
1965 /// `now` is `None`, and it is resolved from the SAME clock read.
1966 edge_now: i64,
1967}
1968
1969impl FrozenView {
1970 /// The instant to bind, resolved at freeze time. Unlike
1971 /// [`ReadView::now_param`] this is a stored value: calling it a second time
1972 /// cannot yield a different answer, and it never touches the clock.
1973 fn now_param(&self) -> Option<i64> {
1974 self.now
1975 }
1976
1977 /// TC-33 — the instant to bind for the EDGE-validity conjunct
1978 /// ([`edge_validity_sql`]). Frozen, like [`FrozenView::now_param`].
1979 ///
1980 /// Honouring `valid_as_of` here is what finally UNIFIES the node and edge
1981 /// temporal axes: step 22 recorded "the shipped EDGE path still inlines
1982 /// `datetime('now')`" as the reason they could not be unified. For the
1983 /// DEFAULT view (`valid_as_of == None`) this is the wall clock, i.e. exactly
1984 /// the pre-TC-33 behaviour.
1985 fn edge_now(&self) -> i64 {
1986 self.edge_now
1987 }
1988
1989 /// The validity conjunct — delegated to the one generator every read site
1990 /// shares, so the search arms cannot drift from the five read verbs.
1991 fn validity_sql(&self, alias: &str, now_idx: usize) -> String {
1992 self.view.validity_sql(alias, now_idx)
1993 }
1994}
1995
1996/// 0.8.20 Slice 15b fix-3 (F2) — how many times [`current_epoch_seconds`] has
1997/// been called in this process. Test-only observation; see
1998/// [`clock_reads_for_test`].
1999static CLOCK_READS: AtomicU64 = AtomicU64::new(0);
2000
2001/// Test seam — the process-wide count of wall-clock reads on the validity path.
2002/// Kept OFF the governed surface (`#[doc(hidden)]`, `_for_test`), mirroring the
2003/// sanctioned `set_vector_stage_only_for_test` / `vector_phase1_sql_for_test`
2004/// pattern; it is never re-exported from the `fathomdb` facade.
2005///
2006/// The counter is PROCESS-WIDE, so a test asserting on a delta must hold a
2007/// lock that excludes every other clock-reading test in its binary (test
2008/// binaries are separate processes, so only intra-binary contention matters).
2009/// `slice15b_search_validity_recall.rs` does this with a file-local mutex.
2010#[doc(hidden)]
2011#[must_use]
2012pub fn clock_reads_for_test() -> u64 {
2013 CLOCK_READS.load(Ordering::Relaxed)
2014}
2015
2016/// Wall-clock now as INTEGER epoch SECONDS (UTC), saturating at 0 before the
2017/// Unix epoch. The single place the node-validity path reads the clock — and it
2018/// is read in RUST, then BOUND, never inlined into SQL as `datetime('now')`.
2019fn current_epoch_seconds() -> i64 {
2020 // 0.8.20 Slice 15b fix-3 (F2) — meter every wall-clock read on the validity
2021 // path. R-20-NV requires `:now` to bind ONCE PER QUERY (not per row, not per
2022 // ARM): if two arms of one query each resolve *now*, a query that straddles
2023 // a validity boundary can have its arms disagree about which side they are
2024 // on. That is invisible to a result-shape assertion and unreachable by a
2025 // deterministic test — you cannot assert on a race. Counting the reads makes
2026 // the property testable WITHOUT racing the clock, and keeps failing for any
2027 // arm added later that re-reads it. `Relaxed` is sufficient: the counter is
2028 // an observation, never a synchronization point.
2029 CLOCK_READS.fetch_add(1, Ordering::Relaxed);
2030 std::time::SystemTime::now()
2031 .duration_since(std::time::UNIX_EPOCH)
2032 .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
2033 .unwrap_or(0)
2034}
2035
2036/// TC-33 — the edge-validity conjunct, bound to positional parameter
2037/// `?{now_idx}`. THE one generator for "is this edge valid at `:now`", so no
2038/// read site can drift from another (the same discipline
2039/// [`ReadView::validity_sql`] applies to node validity).
2040///
2041/// An edge is valid at `t` iff it has no invalid-time, or its invalid-time is
2042/// strictly in the future. `t_invalid` is INTEGER epoch seconds since step 23,
2043/// so this is a direct integer comparison — no `datetime()` conversion per row.
2044///
2045/// **`:now` is a BOUND PARAMETER, never `datetime('now')`.** Before TC-33 every
2046/// edge read site inlined `datetime('now')`, which made the predicate
2047/// non-deterministic, untestable, and re-evaluated per row; step 22's comment
2048/// flagged that as the reason node and edge validity could not be unified. They
2049/// are unified now.
2050///
2051/// Always begins with ` AND `, so every call site must already have a preceding
2052/// `WHERE` predicate.
2053fn edge_validity_sql(alias: &str, now_idx: usize) -> String {
2054 format!(" AND ({alias}.t_invalid IS NULL OR {alias}.t_invalid > ?{now_idx})")
2055}
2056
2057/// TC-33 — parse one ISO-8601 timestamp to INTEGER epoch seconds using SQLite's
2058/// own date parser, via a BOUND parameter.
2059///
2060/// Returns `None` when SQLite cannot resolve the value — `strftime` yields SQL
2061/// NULL for junk (`'not a date'`, `''`, `'2020-13-45T99:99:99Z'`, a bare epoch
2062/// string, whitespace-padded input, non-ASCII digits) and a digit string for
2063/// anything it understands.
2064///
2065/// **Why SQLite and not a date crate:** there is no `chrono`/`time` dependency
2066/// anywhere in the workspace, and HITL directed this spelling rather than adding
2067/// one. The value is BOUND, never interpolated.
2068///
2069/// **This does not violate the inline-clock rule.** That rule forbids
2070/// `datetime('now')` / `strftime('%s','now')` — an inline CLOCK. Parsing a bound
2071/// user value is deterministic and reads no clock. The current instant still
2072/// comes from the bound `:now` seam ([`current_epoch_seconds`]).
2073///
2074/// The `CAST` matters: `strftime('%s', ...)` returns TEXT (a digit string), not
2075/// an integer, and the column is `typeof(...) = 'integer'`-checked.
2076///
2077/// # TC-33 fix-5 — strict ISO-8601 SHAPE gate before delegating to SQLite
2078///
2079/// `strftime('%s', ?)` alone is NOT an ISO-8601 validator: SQLite's date parser
2080/// is MORE lenient than the declared wire contract. A bare number is read as a
2081/// **Julian day** (`strftime('%s','2451545.0')` → `946728000`, i.e. year 2000)
2082/// and `strftime('%s','0')` resolves to a pre-year-0000 epoch — so non-ISO input
2083/// was ACCEPTED and stored as an unrelated instant despite the "hard-reject
2084/// ISO-8601" contract HITL ratified (2026-07-21). [`is_iso8601_shape`] runs
2085/// FIRST and returns `None` for anything that is not a strict ISO-8601
2086/// date/datetime shape, so the existing hard-reject path fires. The shape gate
2087/// does NOT replace SQLite's calendar math — a shape-valid but impossible date
2088/// (`2025-13-45T00:00:00Z`) still `None`s out via `strftime` and hard-rejects.
2089///
2090/// # TC-47 — the calendar-DATE ROUND-TRIP backstop (keystone terminal codex P2)
2091///
2092/// The shape gate checks FORMAT, not CALENDAR VALIDITY, and `strftime('%s', ?)`
2093/// does NOT fully validate the calendar: it **rolls over an impossible DAY**
2094/// rather than returning NULL — `strftime('%s','2025-02-30T00:00:00Z')` yields
2095/// the epoch for `2025-03-02`, and `2025-04-31` yields `2025-05-01`. So a
2096/// shape-valid Feb-30 would parse to a DIFFERENT instant than the provider
2097/// supplied, bypassing the hard-reject contract. (An impossible MONTH like
2098/// `2025-13-01`, and impossible TIMES like `25:00:00` / `:60` / `:61`, already
2099/// NULL out; only impossible DAYS roll over — that is the sole residue.)
2100///
2101/// The `WHERE` clause is the round-trip: the literal calendar DATE component of
2102/// the input (`substr(?1, 1, 10)` — the `YYYY-MM-DD` the shape gate guarantees is
2103/// present) must survive SQLite's own calendar math UNCHANGED. If it rolled over,
2104/// `strftime('%Y-%m-%d', substr(?1,1,10))` differs from the literal substring and
2105/// the `WHERE` yields zero rows => `query_row` -> `QueryReturnedNoRows` -> `None`
2106/// => the existing hard-reject fires. This is a superset of the shape gate: it
2107/// also rejects the TC-44 Julian string `2451545.0` (its `substr(1,10)` renders
2108/// to `2000-01-01`, not itself).
2109///
2110/// **Why the DATE component and not the raw string or the UTC-rendered instant:**
2111/// a raw-string or `unixepoch`-rendered comparison would FALSE-REJECT valid
2112/// equivalent forms. `Z` vs `+00:00`, a non-UTC offset like `+05:00`, date-only,
2113/// and fractional seconds are all valid and store the correct (offset-shifted)
2114/// epoch — but a UTC re-render shifts the wall clock, so its date can differ from
2115/// the input's literal date. Comparing ONLY the literal DATE field is
2116/// tz-INVARIANT (the offset never alters the input's own `YYYY-MM-DD` text) while
2117/// still catching every DAY rollover, because the rollover happens in the
2118/// calendar math BEFORE any offset is applied. **Pure SQL — no date crate.**
2119fn iso8601_to_epoch_seconds(connection: &Connection, raw: &str) -> Option<i64> {
2120 if !is_iso8601_shape(raw) {
2121 return None;
2122 }
2123 connection
2124 .query_row(
2125 "SELECT CAST(strftime('%s', ?1) AS INTEGER) \
2126 WHERE strftime('%Y-%m-%d', substr(?1, 1, 10)) IS substr(?1, 1, 10)",
2127 params![raw],
2128 |r| r.get::<_, Option<i64>>(0),
2129 )
2130 .ok()
2131 .flatten()
2132}
2133
2134/// TC-33 fix-5 — strict ISO-8601 date/datetime SHAPE gate. Hand-rolled on ASCII
2135/// bytes (NO new dependency: the workspace has no `chrono`/`time`, and `regex`
2136/// is only a transitive dep of `jsonschema`, not a direct one — adding either as
2137/// a direct dep would violate the "no new dependency" constraint).
2138///
2139/// Accepts EXACTLY:
2140/// - `YYYY-MM-DD` (date only); optionally followed by
2141/// - a `T` **or** a single space separator, then `HH:MM:SS`; optionally followed
2142/// by `.fff` fractional seconds (one or more digits); optionally followed by
2143/// a zone: `Z`, or `±HH:MM`, or `±HHMM`.
2144///
2145/// Rejects bare numbers (`0`, `2451545.0`), partial junk, whitespace, non-ASCII
2146/// digits, and anything with trailing characters. All digit positions require
2147/// ASCII `0..=9` (`u8::is_ascii_digit`), so non-ASCII digit look-alikes cannot
2148/// slip through. This is a SHAPE check only — calendar validity (e.g. month 13,
2149/// day 45) is still enforced by SQLite's `strftime` after this gate passes.
2150fn is_iso8601_shape(s: &str) -> bool {
2151 let b = s.as_bytes();
2152 let d = |c: u8| c.is_ascii_digit();
2153
2154 // Date: YYYY-MM-DD (exactly 10 bytes).
2155 if b.len() < 10 {
2156 return false;
2157 }
2158 if !(d(b[0])
2159 && d(b[1])
2160 && d(b[2])
2161 && d(b[3])
2162 && b[4] == b'-'
2163 && d(b[5])
2164 && d(b[6])
2165 && b[7] == b'-'
2166 && d(b[8])
2167 && d(b[9]))
2168 {
2169 return false;
2170 }
2171 if b.len() == 10 {
2172 return true; // date-only
2173 }
2174
2175 // Separator (`T` or a single space) + time HH:MM:SS (indices 10..=18).
2176 if b[10] != b'T' && b[10] != b' ' {
2177 return false;
2178 }
2179 if b.len() < 19 {
2180 return false;
2181 }
2182 if !(d(b[11])
2183 && d(b[12])
2184 && b[13] == b':'
2185 && d(b[14])
2186 && d(b[15])
2187 && b[16] == b':'
2188 && d(b[17])
2189 && d(b[18]))
2190 {
2191 return false;
2192 }
2193
2194 let mut i = 19;
2195
2196 // Optional fractional seconds `.fff` (one or more digits).
2197 if i < b.len() && b[i] == b'.' {
2198 i += 1;
2199 let start = i;
2200 while i < b.len() && d(b[i]) {
2201 i += 1;
2202 }
2203 if i == start {
2204 return false; // `.` with no digits
2205 }
2206 }
2207
2208 // Optional zone.
2209 if i == b.len() {
2210 return true; // no zone
2211 }
2212 match b[i] {
2213 b'Z' => i += 1,
2214 b'+' | b'-' => {
2215 i += 1;
2216 // `HH`
2217 if i + 2 > b.len() || !d(b[i]) || !d(b[i + 1]) {
2218 return false;
2219 }
2220 i += 2;
2221 // `:MM` or `MM`
2222 if i < b.len() && b[i] == b':' {
2223 i += 1;
2224 }
2225 if i + 2 > b.len() || !d(b[i]) || !d(b[i + 1]) {
2226 return false;
2227 }
2228 i += 2;
2229 }
2230 _ => return false,
2231 }
2232
2233 i == b.len() // no trailing junk
2234}
2235
2236/// TC-33 — render INTEGER epoch seconds back to an ISO-8601 UTC string for the
2237/// BYO-LLM wire. The exact inverse of [`iso8601_to_epoch_seconds`].
2238///
2239/// Storage and the governed SDK are epoch seconds, but the harness protocols
2240/// (`fathomdb.extract.v1` and the consolidation harness) carry ISO-8601 — LLMs
2241/// reason about dates as text, and pushing epoch integers onto them would make
2242/// the wire hostile to the very providers it exists to serve. So the boundary
2243/// converts in BOTH directions and the representation split stays a boundary
2244/// concern rather than leaking into the protocol.
2245fn epoch_seconds_to_iso8601(connection: &Connection, epoch: i64) -> Option<String> {
2246 connection
2247 .query_row("SELECT strftime('%Y-%m-%dT%H:%M:%SZ', ?1, 'unixepoch')", params![epoch], |r| {
2248 r.get::<_, Option<String>>(0)
2249 })
2250 .ok()
2251 .flatten()
2252}
2253
2254/// TC-33 fix-1 — the inclusive epoch-seconds range SQLite's
2255/// `strftime(..., 'unixepoch')` can render back to ISO-8601. SQLite's date
2256/// functions cover years 0000..=9999 ONLY, so:
2257/// - `MIN` = `0000-01-01T00:00:00Z`
2258/// - `MAX` = `9999-12-31T23:59:59Z`
2259///
2260/// TC-33 fix-5 makes these the ACTUAL rejection predicate (a numeric
2261/// `[MIN, MAX]` bounds check), not just message text. Renderability was too
2262/// weak: `strftime(..., 'unixepoch')` renders a below-`MIN` value like
2263/// `-62_167_219_201` to `-001-12-31T23:59:59Z` (NON-NULL), so a pre-year-0000
2264/// epoch slipped the renderability guard even though it is outside the declared
2265/// years 0000..=9999. Both bounds are verified against SQLite to correspond
2266/// EXACTLY to the first/last renderable instant:
2267/// `strftime('%Y-%m-%dT%H:%M:%SZ', MIN, 'unixepoch') = 0000-01-01T00:00:00Z` and
2268/// `= 9999-12-31T23:59:59Z` for `MAX` (`MAX+1` and `MIN-1` are the first
2269/// out-of-range instants).
2270const MIN_RENDERABLE_EPOCH: i64 = -62_167_219_200; // 0000-01-01T00:00:00Z
2271const MAX_RENDERABLE_EPOCH: i64 = 253_402_300_799; // 9999-12-31T23:59:59Z
2272
2273/// TC-33 fix-1 — reject an edge epoch that SQLite cannot render back to
2274/// ISO-8601, at the governed write boundary, so it is UNSTORABLE.
2275///
2276/// # Why this is the primary layer
2277///
2278/// Storage and `PreparedWrite::Edge` carry INTEGER epoch seconds and accept an
2279/// arbitrary `i64`. The consolidation path renders each candidate's
2280/// `t_valid`/`t_invalid` to ISO-8601 for the LLM via `strftime(..., 'unixepoch')`,
2281/// which only spans years 0000..=9999. An epoch outside that range renders to
2282/// NULL, and the render site would then send a silent `null` for a timestamp
2283/// that is actually stored NON-NULL — the OUTBOUND twin of the fail-open TC-33
2284/// removes. A `null` `t_invalid` reads as "still valid", and the consolidation
2285/// reference stub echoes a winner's `t_valid` straight back as the verdict's
2286/// `t_invalid`, so the `null` round-trips through the inbound normaliser as
2287/// "still valid": an invalidated edge silently resurrected.
2288///
2289/// Inbound ISO normalisation can never MINT such an epoch (a 4-digit-year ISO
2290/// string maxes at 9999), so the governed integer surface is the only ingress —
2291/// which is exactly where this guard sits. Like the inbound
2292/// [`normalize_extractor_timestamp`] hard-reject, it is a typed
2293/// [`EngineError::InvalidArgument`] naming the offending value and the bound,
2294/// never a silent coercion.
2295///
2296/// ⚠ It no longer mirrors the `validate_write` Node branch's
2297/// `valid_from >= valid_until` refusal: decision #18 (0.8.20 Slice 22) moved
2298/// THAT refusal onto the message-less [`EngineError::WriteValidation`] unit
2299/// variant, which carries no value at all. The two refusals are deliberately in
2300/// different families now — a malformed submitted write SHAPE is
2301/// `WriteValidation`; an out-of-domain scalar on this render path stays
2302/// `InvalidArgument`. Do not restate them as one pattern.
2303fn reject_unrenderable_edge_epoch(field: &str, value: Option<i64>) -> Result<(), EngineError> {
2304 // TC-33 fix-5 — an explicit numeric MIN/MAX bounds check, NOT a renderability
2305 // test. `strftime(..., 'unixepoch')` renders a below-`MIN` epoch (e.g.
2306 // `-62_167_219_201`, year -0001) to a NON-NULL string, so a renderability
2307 // guard would let pre-year-0000 values through even though they are outside
2308 // the declared years 0000..=9999. No `Connection` is needed now that the
2309 // predicate is pure integer arithmetic.
2310 if let Some(ts) = value {
2311 if !(MIN_RENDERABLE_EPOCH..=MAX_RENDERABLE_EPOCH).contains(&ts) {
2312 return Err(EngineError::InvalidArgument {
2313 msg: format!(
2314 "edge field `{field}` = {ts} is outside the epoch-seconds range SQLite can \
2315 render to ISO-8601 ([{MIN_RENDERABLE_EPOCH}, {MAX_RENDERABLE_EPOCH}], i.e. \
2316 years 0000..=9999). REJECTED rather than stored: such an epoch renders to a \
2317 silent NULL (or a nonsensical out-of-range instant) on the consolidation \
2318 wire, and a NULL `t_invalid` reads as \"still valid\" — resurrecting an \
2319 invalidated edge."
2320 ),
2321 });
2322 }
2323 }
2324 Ok(())
2325}
2326
2327/// The JSON type name of `value`, for diagnosing a mistyped extractor field.
2328fn json_type_name(value: &Value) -> &'static str {
2329 match value {
2330 Value::Null => "null",
2331 Value::Bool(_) => "boolean",
2332 Value::Number(_) => "number",
2333 Value::String(_) => "string",
2334 Value::Array(_) => "array",
2335 Value::Object(_) => "object",
2336 }
2337}
2338
2339/// TC-33 — normalise one timestamp arriving on the **BYO-LLM extractor
2340/// boundary** (`fathomdb.extract.v1`) into the INTEGER epoch seconds the storage
2341/// and governed-SDK layers use. **HARD-REJECTS** anything it cannot normalise.
2342///
2343/// This is the layering boundary HITL ratified on 2026-07-21:
2344/// - the **extractor wire format stays ISO-8601 strings** — LLMs emit text, and
2345/// this function is the one place that changes;
2346/// - **storage and the governed SDK surface are INTEGER epoch seconds.**
2347///
2348/// # Why rejection, not coercion — fail-open is the defect
2349///
2350/// A NULL `t_invalid` means **"still valid"**. So any path that turns an
2351/// unparseable timestamp into NULL silently **resurrects an invalidated edge**.
2352/// Two distinct fail-opens are closed here:
2353///
2354/// 1. **Malformed strings.** Previously NOTHING parsed or validated these; junk
2355/// went verbatim into the INSERT. Under the old TEXT column it then failed
2356/// CLOSED by accident (`datetime('junk')` → NULL ⇒ the read disjunct is
2357/// falsy ⇒ the row vanished). Under INTEGER that polarity would INVERT.
2358/// 2. **Non-string JSON — a fail-open that PREDATES TC-33.** The old site read
2359/// `edge.get("t_invalid").and_then(|v| v.as_str())`, and `as_str()` returns
2360/// `None` for a JSON number/bool/object. So `"t_invalid": 1710000000` — a
2361/// plausible mistake, and exactly the epoch form storage now uses — had its
2362/// invalidation SILENTLY DISCARDED and the edge stored as "still valid".
2363///
2364/// `None`/JSON `null`/absent is the ONLY sanctioned way to say "unknown"; it
2365/// maps to `Ok(None)` and keeps the NULL-means-still-valid semantic.
2366///
2367/// Refuses with a typed [`EngineError::InvalidArgument`] CARRYING the offending
2368/// value, so a caller can see what was rejected.
2369///
2370/// ⚠ This is NOT the same pattern as the `validate_write` `Node` branch's
2371/// `valid_from >= valid_until` check, which that comment used to cite: decision
2372/// #18 (0.8.20 Slice 22) moved that refusal onto the message-less
2373/// [`EngineError::WriteValidation`] unit variant, which carries **no value at
2374/// all**. Both the family AND the carry-the-value property differ.
2375fn normalize_extractor_timestamp(
2376 connection: &Connection,
2377 field: &str,
2378 raw: Option<&Value>,
2379) -> Result<Option<i64>, EngineError> {
2380 match raw {
2381 None | Some(Value::Null) => Ok(None),
2382 Some(Value::String(text)) => match iso8601_to_epoch_seconds(connection, text) {
2383 Some(epoch) => Ok(Some(epoch)),
2384 None => Err(EngineError::InvalidArgument {
2385 msg: format!(
2386 "extractor edge field `{field}` must be a valid, calendar-real ISO-8601 \
2387 timestamp; got {text:?}, which either `strftime('%s', ?)` resolves to NULL \
2388 or fails the calendar round-trip (a shape-valid but impossible DAY like \
2389 `2025-02-30` that SQLite would silently ROLL OVER to a different instant). \
2390 REJECTED rather than stored: a NULL `t_invalid` reads as \"still valid\" and \
2391 a rolled-over date stores the WRONG instant — both breach the hard-reject \
2392 contract. Use JSON null for \"unknown\"."
2393 ),
2394 }),
2395 },
2396 Some(other) => Err(EngineError::InvalidArgument {
2397 msg: format!(
2398 "extractor edge field `{field}` must be an ISO-8601 string or JSON null; got a \
2399 JSON {kind} ({other}). The `fathomdb.extract.v1` wire format carries ISO-8601 at \
2400 this boundary — INTEGER epoch seconds are the STORAGE representation, not the \
2401 wire one. REJECTED rather than coerced to NULL, which reads as \"still valid\".",
2402 kind = json_type_name(other)
2403 ),
2404 }),
2405 }
2406}
2407
2408/// Slice 30 (G3) — one `operational_mutations` row returned by `read.collection`
2409/// / `read.mutations`. `id` is the autoincrement PK (the after-id cursor key).
2410#[derive(Clone, Debug, Eq, PartialEq)]
2411pub struct OpStoreRow {
2412 pub id: i64,
2413 pub collection: String,
2414 pub record_key: String,
2415 pub op_kind: String,
2416 pub payload: String,
2417 pub schema_id: Option<String>,
2418 pub write_cursor: u64,
2419}
2420
2421/// Hybrid `search` result. `results` carries structured [`SearchHit`]s in
2422/// vector-first, dedup-on-body order. Derives `Clone, Debug, PartialEq` but
2423/// **not `Eq`** — each hit carries a `score: f64`.
2424#[derive(Clone, Debug, PartialEq)]
2425// 0.8.8 EXP-OBS (field-set ratification): non_exhaustive so future additive fields
2426// (e.g. the deferred QueryTrace.timings_ms, Q3) are non-breaking. All construction
2427// is in-crate (engine + tests); external crates read fields only.
2428#[non_exhaustive]
2429pub struct SearchResult {
2430 pub projection_cursor: u64,
2431 pub soft_fallback: Option<SoftFallback>,
2432 pub results: Vec<SearchHit>,
2433 /// 0.8.8 EXP-OBS (Slice 5) — opt-in retrieval explanation **sidecar**.
2434 /// `Some` ONLY on the `search_explained` path; `None` for every default
2435 /// (`explain=false`) search, so `results` + `projection_cursor` stay
2436 /// byte-identical to the pre-0.8.8 shape (R-OBS-2 zero-cost contract,
2437 /// HITL-ratified sidecar carrier — see
2438 /// `dev/design/0.8.8-explain-and-telemetry-adr.md` §A.2). Field-set is
2439 /// PROPOSED/ratification-pending; additive inside `Explanation` so later
2440 /// amendments do not reshape `SearchResult`/`SearchHit`.
2441 pub explanation: Option<Explanation>,
2442}
2443
2444/// 0.8.8 EXP-OBS (Slice 5) — the opt-in retrieval explanation payload returned
2445/// behind `search_explained` (the `explain=true` surface). Built from the
2446/// engine's OWN fusion/rerank machinery (`fuse_three_arms` per-arm ranks,
2447/// `ce_rerank` blend components) — no parallel machinery (R-OBS-3). Carries a
2448/// query-level [`QueryTrace`] plus a per-hit breakdown parallel to (and in the
2449/// same order as) `SearchResult.results`.
2450///
2451/// Derives `Clone, Debug, PartialEq` but **not `Eq`** — scores are `f64`.
2452#[derive(Clone, Debug, PartialEq)]
2453#[non_exhaustive] // 0.8.8 field-set ratification — additive-safe sidecar
2454pub struct Explanation {
2455 pub trace: QueryTrace,
2456 pub per_hit: Vec<PerHitExplain>,
2457}
2458
2459/// 0.8.8 EXP-OBS (Slice 5) — query-level retrieval trace. Reuses the existing
2460/// `search_reranked` knobs + the active embedder identity; timings are coarse
2461/// per-stage wall-clock (monotonic) captured only on the explain path.
2462#[derive(Clone, Debug, PartialEq)]
2463// 0.8.8 field-set ratification — HARD: leaf absorbs the deferred `timings_ms` (Q3)
2464// and any future trace field without a contract break.
2465#[non_exhaustive]
2466pub struct QueryTrace {
2467 /// Query LENGTH only (chars) — never the query text (privacy; ADR §C).
2468 pub query_chars: u32,
2469 /// Final result limit (`SEARCH_RERANK_LIMIT`-derived `final_limit`).
2470 pub k: u32,
2471 pub rerank_depth: u32,
2472 pub pool_n: u32,
2473 pub alpha: f64,
2474 pub use_graph_arm: bool,
2475 /// Recency reweight (the dedicated G12 flag) was applied.
2476 pub recency: bool,
2477 /// Active embedder identity `name@revision` (+ dim), or empty when none.
2478 pub embedder_id: String,
2479 /// The CE cross-encoder actually reranked the pool (model loaded + depth>0).
2480 pub ce_active: bool,
2481 /// Per-arm input hit counts (pre-fusion).
2482 pub vector_hits: u32,
2483 pub text_hits: u32,
2484 pub graph_hits: u32,
2485}
2486
2487/// 0.8.8 EXP-OBS (Slice 5) — per-hit provenance + score breakdown. One entry per
2488/// returned `SearchHit`, same order. `*_rank` is the 0-based rank the hit's body
2489/// held in that arm's pre-fusion list (`None` = absent from that arm).
2490///
2491/// Derives `Clone, Debug, PartialEq` but **not `Eq`** — scores are `f64`.
2492#[derive(Clone, Debug, PartialEq)]
2493// 0.8.8 field-set ratification — HARD: leaf absorbs future arms / score components.
2494#[non_exhaustive]
2495pub struct PerHitExplain {
2496 /// The hit's engine-internal positional `write_cursor` (the pre-C-2
2497 /// `SearchHit.id`). Post-0.8.19 the caller-facing `SearchHit.id` is a typed
2498 /// [`IdSpace`]; this field keeps carrying the positional cursor so the explain
2499 /// sidecar cross-references the telemetry `result_ids` space. Correlate a
2500 /// `PerHitExplain` to its `SearchHit` by position (both lists are 1:1, same
2501 /// order).
2502 pub id: u64,
2503 /// Winning arm after RRF dedup (vector-first), == `SearchHit.branch`.
2504 pub arm: SoftFallbackBranch,
2505 pub vector_rank: Option<u32>,
2506 pub text_rank: Option<u32>,
2507 pub graph_rank: Option<u32>,
2508 /// Raw RRF fused score AFTER recency reweight, BEFORE CE blend (the value
2509 /// `ce_rerank` normalizes). Faithful to the engine computation — downstream
2510 /// may normalize. (ADR §A.4 Q1: raw exposed; normalization deferred.)
2511 pub fused_score: f64,
2512 /// In-pool cross-encoder score `sigmoid(ce_logit) ∈ [0,1]`, == the returned
2513 /// `SearchHit.ce_score`; `None` outside the reranked pool / no-CE path.
2514 pub ce_score: Option<f64>,
2515 /// Final blended score, == the returned `SearchHit.score`.
2516 pub blended: f64,
2517 /// 0.8.16 Slice 5 / F9 — the node `importance` scalar applied to this hit's
2518 /// fused contribution when the importance reweight is ON, else the raw stored
2519 /// value. `None` = never assigned (graceful-absent, ranks NEUTRAL). Additive
2520 /// (`#[non_exhaustive]` leaf absorbs the new score component).
2521 pub importance: Option<f64>,
2522 /// 0.8.16 Slice 5 / F9 — the edge `confidence` scalar applied to this hit's
2523 /// graph-arm contribution when the importance reweight is ON, else the raw
2524 /// stored value. `None` for node hits / edges without a confidence
2525 /// (graceful-absent, ranks NEUTRAL).
2526 pub confidence: Option<f64>,
2527}
2528
2529// ===== G4 filter grammar types (Slice 35) ===============================
2530
2531/// G4 (Slice 35) — scalar value for [`Predicate`] comparisons.
2532///
2533/// Shared vocabulary with G10 — defined once at the `fathomdb-engine` crate
2534/// root so reserved-gap 37 (full G4↔G10 unification) can import it without a
2535/// path change. Derives `Clone, Debug, PartialEq` per the ADR contract
2536/// (D-F1 exhaustiveness: exactly `{Text, Integer, Bool}`).
2537#[derive(Clone, Debug, PartialEq)]
2538pub enum ScalarValue {
2539 Text(String),
2540 Integer(i64),
2541 Bool(bool),
2542}
2543
2544/// G4 (Slice 35) — comparison operator for [`Predicate::JsonPathCompare`].
2545///
2546/// Shared vocabulary (same crate-root export as `ScalarValue`). Closed
2547/// enum: `{Gt, Gte, Lt, Lte}` per D-F1. Derives `Clone, Debug, PartialEq`.
2548#[derive(Clone, Debug, PartialEq)]
2549pub enum ComparisonOp {
2550 Gt,
2551 Gte,
2552 Lt,
2553 Lte,
2554}
2555
2556/// Allowed JSON paths for [`Predicate`] constructors. The SQL compilation in
2557/// [`Engine::read_list`] uses the **allowlist constant** (a server-side literal),
2558/// never the caller-supplied string, so only paths in this set reach
2559/// `json_extract`. Callers receive [`EngineError::InvalidFilter`] for any
2560/// non-allowlisted path — no passthrough, no panic.
2561///
2562/// To extend: add an entry here. No API change is needed; the constructor
2563/// accepts the new path string once it appears in this array.
2564const PREDICATE_PATH_ALLOWLIST: &[&str] =
2565 &["$.status", "$.priority", "$.tags", "$.kind", "$.created_at", "$.action_kind"];
2566
2567/// G4 (Slice 35) — closed typed predicate for [`Engine::read_list`] filter.
2568///
2569/// Exactly two variants per ADR D-F1 (`{JsonPathEq, JsonPathCompare}`).
2570/// The fused variants (`JsonPathFused*`) and all `*_unchecked` builders are
2571/// explicitly EXCLUDED (ADR D-F2). Use the validated constructors
2572/// [`Predicate::json_path_eq`] / [`Predicate::json_path_compare`]; they
2573/// enforce the path allowlist at construction time.
2574///
2575/// Multiple predicates in [`Engine::read_list`] are combined by implicit AND
2576/// (D-F5). Compilation target: `json_extract(body, '$.field') <op> ?` with
2577/// a bound parameter (never interpolated — injection-safe per D-F4).
2578#[derive(Clone, Debug, PartialEq)]
2579pub enum Predicate {
2580 /// `json_extract(body, path) = ?` (equality).
2581 JsonPathEq { path: String, value: ScalarValue },
2582 /// `json_extract(body, path) <op> ?` (inequality).
2583 JsonPathCompare { path: String, op: ComparisonOp, value: ScalarValue },
2584}
2585
2586impl Predicate {
2587 /// Construct a `JsonPathEq` predicate with allowlist validation.
2588 ///
2589 /// Returns [`EngineError::InvalidFilter`] if `path` is not in
2590 /// [`PREDICATE_PATH_ALLOWLIST`]; never panics on bad input.
2591 pub fn json_path_eq(path: impl Into<String>, value: ScalarValue) -> Result<Self, EngineError> {
2592 let path = path.into();
2593 if !PREDICATE_PATH_ALLOWLIST.contains(&path.as_str()) {
2594 return Err(EngineError::InvalidFilter {
2595 reason: format!("path '{path}' is not in the predicate path allowlist"),
2596 });
2597 }
2598 Ok(Self::JsonPathEq { path, value })
2599 }
2600
2601 /// Construct a `JsonPathCompare` predicate with allowlist validation.
2602 ///
2603 /// Returns [`EngineError::InvalidFilter`] if `path` is not in
2604 /// [`PREDICATE_PATH_ALLOWLIST`]; never panics on bad input.
2605 pub fn json_path_compare(
2606 path: impl Into<String>,
2607 op: ComparisonOp,
2608 value: ScalarValue,
2609 ) -> Result<Self, EngineError> {
2610 let path = path.into();
2611 if !PREDICATE_PATH_ALLOWLIST.contains(&path.as_str()) {
2612 return Err(EngineError::InvalidFilter {
2613 reason: format!("path '{path}' is not in the predicate path allowlist"),
2614 });
2615 }
2616 Ok(Self::JsonPathCompare { path, op, value })
2617 }
2618
2619 /// Return the validated path string for use in SQL compilation.
2620 /// This always returns a path that is in `PREDICATE_PATH_ALLOWLIST`.
2621 fn path(&self) -> &str {
2622 match self {
2623 Self::JsonPathEq { path, .. } => path.as_str(),
2624 Self::JsonPathCompare { path, .. } => path.as_str(),
2625 }
2626 }
2627
2628 /// Compile this predicate to a SQL WHERE clause fragment.
2629 /// The path is validated at construction time and is always an allowlist
2630 /// constant — never the raw caller-supplied string.
2631 fn to_sql_clause(&self, param_idx: usize) -> String {
2632 // The path is already validated against the allowlist at construction.
2633 // We use the allowlist entry (the stored path) directly as a SQL literal.
2634 // The VALUE is always a bound `?` parameter (injection-safe).
2635 //
2636 // Type guards prevent cross-type matches caused by SQLite's json_extract
2637 // coercing JSON booleans to integer 1/0:
2638 // - Bool predicates: AND json_type IN ('true', 'false') — exclude integers
2639 // - Integer predicates: AND json_type = 'integer' — exclude booleans
2640 // Text predicates need no guard: json_extract returns TEXT for strings and
2641 // the coercion never conflates TEXT with integer/bool.
2642 let path = self.path();
2643 match self {
2644 Self::JsonPathEq { value, .. } => match value {
2645 ScalarValue::Bool(_) => format!(
2646 "json_extract(body, '{path}') = ?{param_idx} \
2647 AND json_type(body, '{path}') IN ('true', 'false')"
2648 ),
2649 ScalarValue::Integer(_) => format!(
2650 "json_extract(body, '{path}') = ?{param_idx} \
2651 AND json_type(body, '{path}') = 'integer'"
2652 ),
2653 ScalarValue::Text(_) => {
2654 format!("json_extract(body, '{path}') = ?{param_idx}")
2655 }
2656 },
2657 Self::JsonPathCompare { op, value, .. } => {
2658 let op_str = match op {
2659 ComparisonOp::Gt => ">",
2660 ComparisonOp::Gte => ">=",
2661 ComparisonOp::Lt => "<",
2662 ComparisonOp::Lte => "<=",
2663 };
2664 match value {
2665 ScalarValue::Bool(_) => format!(
2666 "json_extract(body, '{path}') {op_str} ?{param_idx} \
2667 AND json_type(body, '{path}') IN ('true', 'false')"
2668 ),
2669 ScalarValue::Integer(_) => format!(
2670 "json_extract(body, '{path}') {op_str} ?{param_idx} \
2671 AND json_type(body, '{path}') = 'integer'"
2672 ),
2673 ScalarValue::Text(_) => format!(
2674 "json_extract(body, '{path}') {op_str} ?{param_idx} \
2675 AND json_type(body, '{path}') = 'text'"
2676 ),
2677 }
2678 }
2679 }
2680 }
2681
2682 /// Bind the value of this predicate as a rusqlite parameter.
2683 fn bind_value(&self) -> rusqlite::types::Value {
2684 let value = match self {
2685 Self::JsonPathEq { value, .. } => value,
2686 Self::JsonPathCompare { value, .. } => value,
2687 };
2688 match value {
2689 ScalarValue::Text(s) => rusqlite::types::Value::Text(s.clone()),
2690 ScalarValue::Integer(i) => rusqlite::types::Value::Integer(*i),
2691 ScalarValue::Bool(b) => rusqlite::types::Value::Integer(i64::from(*b)),
2692 }
2693 }
2694}
2695
2696// ===== Slice 20 (G5/G6) — graph traversal types =========================
2697
2698/// Slice 20 (G5) — direction of graph traversal for
2699/// [`Engine::graph_neighbors`] / [`Engine::search_expand`].
2700///
2701/// `Outgoing` follows edges where the root is the `from_id` (source).
2702/// `Incoming` follows edges where the root is the `to_id` (target).
2703/// `Both` follows edges in either direction.
2704#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2705pub enum TraversalDirection {
2706 Outgoing,
2707 Incoming,
2708 Both,
2709}
2710
2711/// Slice 20 (G6) — result of [`Engine::search_expand`]: initial search hits
2712/// plus nodes reached by bounded BFS expansion that are not already in the
2713/// search hit set.
2714#[derive(Clone, Debug)]
2715pub struct SearchExpandResult {
2716 /// Original RRF-scored search results (G1+G9 hybrid).
2717 pub search_hits: Vec<SearchHit>,
2718 /// Nodes reached by graph traversal but NOT already in `search_hits`.
2719 /// Each entry is `(node, hop_count)` where `hop_count` is the BFS depth
2720 /// from the nearest search hit that reached this node.
2721 pub expanded: Vec<(NodeRecord, u32)>,
2722 /// Deduplicated union of all logical_ids (search hits first, then expanded).
2723 pub all_logical_ids: Vec<String>,
2724}
2725
2726/// G10 — closed metadata filter for [`Engine::search_filtered`] (Slice 10).
2727///
2728/// All fields are optional; a `None` field imposes no constraint, and an
2729/// all-`None` filter (or `None` filter) is the unfiltered path whose phase-1 SQL
2730/// is byte-identical to 0.7.2. This is a **closed struct**, not an open filter
2731/// DSL (ADR-0.8.0-agent-memory-retrieval-and-identity Q1); the filter-grammar /
2732/// `list` decision stays a later-slice concern.
2733///
2734/// `created_after` is a `created_at >= bound` lower bound in unix seconds.
2735/// `status` is wired through to the vec0 `status` metadata column. vec0 TEXT
2736/// metadata columns are **NOT NULL-able**, so the "no real population yet" state
2737/// is an **empty-string sentinel** `''` (a forced deviation from the planned
2738/// "NULL plumbing"; a real population source is reserved-gap candidate 13). A
2739/// `status = Some("open")`-style filter therefore prunes every row until that
2740/// population slice lands.
2741// 0.8.20 Slice 15e fix-2 (Finding 2) — `#[non_exhaustive]`: the `attributes`
2742// field was added additively in 0.8.20. Marking the struct non-exhaustive means
2743// EXTERNAL crates can no longer use a struct literal `SearchFilter { .. }` and
2744// must go through `..Default::default()` (or a constructor), so a FUTURE field
2745// add is not a source break for them. Internal (in-workspace) construction is
2746// unaffected — `#[non_exhaustive]` only constrains other crates — and every
2747// in-crate literal already spreads `..Default::default()`. Governed-surface
2748// status: PROPOSED / NOT SIGNED.
2749#[derive(Clone, Debug, Default, Eq, PartialEq)]
2750#[non_exhaustive]
2751pub struct SearchFilter {
2752 pub source_type: Option<String>,
2753 pub kind: Option<String>,
2754 pub created_after: Option<i64>,
2755 pub status: Option<String>,
2756 /// 0.8.20 Slice 15e (R-20-PR, ADR-0.8.11 D3) — declared-`filterable`-attribute
2757 /// equality predicates, each `(attribute_name, value)`. Lowered into the
2758 /// **indexed pre-KNN** vec0 metadata column `attr_<hex>` by
2759 /// [`vector_filter_clause`] (NOT a post-KNN `json_extract`). Empty ⇒ the
2760 /// byte-identical unfiltered path is preserved. `attribute_name` is the
2761 /// registry projection name; the encoded column is derived by
2762 /// [`attr_vec0_column`].
2763 pub attributes: Vec<(String, String)>,
2764}
2765
2766impl SearchFilter {
2767 /// True when no field constrains the search — equivalent to `None`. Used to
2768 /// keep the unfiltered code path (and its byte-identical SQL) on the
2769 /// all-`None` struct.
2770 fn is_unfiltered(&self) -> bool {
2771 self.source_type.is_none()
2772 && self.kind.is_none()
2773 && self.created_after.is_none()
2774 && self.status.is_none()
2775 && self.attributes.is_empty()
2776 }
2777}
2778
2779// ===== 0.8.11 Slice 40 (#17) — unified filter grammar (G4 + G10) =========
2780
2781/// 0.8.11 Slice 40 (#17) — a single closed `FilterTerm` of the **unified**
2782/// filter grammar (ADR-0.8.11-filter-grammar-unification, Option A; closes
2783/// reserved-gap 37). Exactly **five** variants: the four G10 shorthand metadata
2784/// fields (`SourceType`/`Kind`/`CreatedAfter`/`Status`) plus the general G4
2785/// json-path [`Predicate`] (`Json`). The shorthand fields are dedicated typed
2786/// variants — NOT `Json(Predicate)` over `$.source_type` etc. — precisely so the
2787/// vec0 search backend can lower them to the *indexed* pre-KNN metadata columns
2788/// while typed-rejecting an arbitrary `Json` term (D3: no demotion to post-KNN
2789/// `json_extract`).
2790///
2791/// The grammar stays **closed** (inherits ADR-0.8.0 D-F1/D-F2/D-F4/D-F5): no
2792/// DSL, no caller SQL, no `JsonPathFused*`, no `*_unchecked`, no OR/nesting
2793/// (implicit AND only); `Json` terms are built ONLY via the validated
2794/// [`Predicate::json_path_eq`] / [`Predicate::json_path_compare`] constructors
2795/// (path allowlist enforced at construction). The shipped `ScalarValue` /
2796/// `ComparisonOp` / `Predicate` vocabulary is reused verbatim — no new grammar.
2797#[derive(Clone, Debug, PartialEq)]
2798pub enum FilterTerm {
2799 /// vec0 partition-key metadata column `source_type` (pre-KNN). On
2800 /// `read.list` it **constant-folds** against `resolve_source_type(kind)`
2801 /// (the column does not exist in `canonical_nodes`).
2802 SourceType(String),
2803 /// `kind` — the vec0 metadata column (pre-KNN). On `read.list` it
2804 /// constant-folds against the partition `kind` argument (D1 impl decision:
2805 /// constant-fold, the simpler total option vs a redundant column clause).
2806 Kind(String),
2807 /// `created_at >= bound` (unix seconds). vec0 metadata column (pre-KNN);
2808 /// lowers to `json_extract(body,'$.created_at') >= ?` on `read.list`.
2809 CreatedAfter(i64),
2810 /// vec0 metadata column `status` (pre-KNN); lowers to
2811 /// `json_extract(body,'$.status') = ?` on `read.list`.
2812 Status(String),
2813 /// The general G4 json-path predicate (unchanged shipped grammar). Resolves
2814 /// **only** on the `read.list` (canonical_nodes) backend; **typed-rejected**
2815 /// on `search_filtered` because it would require a post-KNN `json_extract`
2816 /// that defeats the indexed pre-KNN filter (D3 no-demotion guarantee).
2817 Json(Predicate),
2818}
2819
2820/// 0.8.11 Slice 40 (#17) — the unified closed `Filter` contract. ONE superset
2821/// type with implicit-AND [`FilterTerm`]s, dispatched to one of **two** internal
2822/// compilation backends (Option A — the TYPE unifies, the COMPILATION
2823/// dispatches): the vec0-metadata indexed pre-KNN `WHERE` for `search_filtered`,
2824/// and `json_extract` over `canonical_nodes.body` for `read.list`. The shipped
2825/// `SearchFilter` (G10) and `Predicate` lists (G4) re-express as sugar that
2826/// lowers into this type (D4); the `filter=None` byte-identical-0.7.2-SQL pin is
2827/// preserved because the vec0 lowering routes back through the shipped
2828/// `vector_filter_clause` compilation verbatim.
2829#[derive(Clone, Debug, Default, PartialEq)]
2830pub struct Filter {
2831 /// AND-combined terms (implicit AND, inherits D-F5). Empty = unfiltered.
2832 pub terms: Vec<FilterTerm>,
2833}
2834
2835impl From<&SearchFilter> for Filter {
2836 /// D4 sugar lowering — the shipped G10 [`SearchFilter`] re-expressed as the
2837 /// unified [`Filter`]. Field → term in the **canonical** order
2838 /// (`source_type`, `kind`, `created_after`, `status`) so the round-trip back
2839 /// to a `SearchFilter` (and thus the produced vec0 SQL) is byte-identical.
2840 fn from(sf: &SearchFilter) -> Self {
2841 let mut terms = Vec::new();
2842 if let Some(s) = &sf.source_type {
2843 terms.push(FilterTerm::SourceType(s.clone()));
2844 }
2845 if let Some(k) = &sf.kind {
2846 terms.push(FilterTerm::Kind(k.clone()));
2847 }
2848 if let Some(c) = sf.created_after {
2849 terms.push(FilterTerm::CreatedAfter(c));
2850 }
2851 if let Some(s) = &sf.status {
2852 terms.push(FilterTerm::Status(s.clone()));
2853 }
2854 Filter { terms }
2855 }
2856}
2857
2858impl Filter {
2859 /// Backend dispatch for `search_filtered` (vec0 — indexed pre-KNN). Lowers
2860 /// the metadata subset `{SourceType, Kind, CreatedAfter, Status}` back into a
2861 /// [`SearchFilter`] (which the shipped `vector_filter_clause` compiles to the
2862 /// pre-KNN `WHERE`), and **typed-rejects** a [`FilterTerm::Json`] term with
2863 /// [`EngineError::InvalidFilter`] — the explicit no-demotion guarantee (D3).
2864 /// Field-by-variant assignment makes the output canonical-order-independent
2865 /// of `terms` ordering (hand-built router filters included). A later
2866 /// duplicate metadata term overwrites the earlier (last-wins).
2867 pub fn to_search_filter(&self) -> Result<SearchFilter, EngineError> {
2868 let mut sf = SearchFilter::default();
2869 for term in &self.terms {
2870 match term {
2871 FilterTerm::SourceType(s) => sf.source_type = Some(s.clone()),
2872 FilterTerm::Kind(k) => sf.kind = Some(k.clone()),
2873 FilterTerm::CreatedAfter(c) => sf.created_after = Some(*c),
2874 FilterTerm::Status(s) => sf.status = Some(s.clone()),
2875 FilterTerm::Json(_) => {
2876 return Err(EngineError::InvalidFilter {
2877 reason: "arbitrary json-path predicate not supported on search_filtered; \
2878 it would require a post-KNN json_extract that defeats the \
2879 indexed pre-KNN filter (ADR-0.8.11 D3 no-demotion guarantee)"
2880 .to_string(),
2881 });
2882 }
2883 }
2884 }
2885 Ok(sf)
2886 }
2887
2888 /// Backend dispatch for `read.list` (canonical_nodes — `json_extract`). The
2889 /// full set resolves here. Returns:
2890 /// - `Ok(Some(preds))` — the implicit-AND [`Predicate`] list to run; or
2891 /// - `Ok(None)` — a constant-folded **guaranteed-empty** result (a `Kind` or
2892 /// `SourceType` term that cannot match this partition), so the caller
2893 /// returns an empty `Vec` without touching SQL; or
2894 /// - `Err(InvalidFilter)` — a non-allowlisted path (defense-in-depth; the
2895 /// shorthand lowerings only ever use allowlisted paths).
2896 ///
2897 /// Lowering (D3): `Json(p)` → `p`; `Status(s)` →
2898 /// `json_path_eq("$.status", Text(s))`; `CreatedAfter(b)` →
2899 /// `json_path_compare("$.created_at", Gte, Integer(b))`; `Kind(k)` →
2900 /// constant-fold vs the partition `kind` arg (no-op if equal, empty if not);
2901 /// `SourceType(s)` → constant-fold vs `resolve_source_type(kind)` (no-op if
2902 /// equal, empty otherwise — the column does not exist in `body`).
2903 fn lower_for_read_list(&self, kind: &str) -> Result<Option<Vec<Predicate>>, EngineError> {
2904 let mut preds = Vec::new();
2905 for term in &self.terms {
2906 match term {
2907 FilterTerm::Json(p) => preds.push(p.clone()),
2908 FilterTerm::Status(s) => {
2909 preds.push(Predicate::json_path_eq("$.status", ScalarValue::Text(s.clone()))?);
2910 }
2911 FilterTerm::CreatedAfter(b) => {
2912 preds.push(Predicate::json_path_compare(
2913 "$.created_at",
2914 ComparisonOp::Gte,
2915 ScalarValue::Integer(*b),
2916 )?);
2917 }
2918 FilterTerm::Kind(k) => {
2919 // Constant-fold vs the partition argument (D1 impl decision).
2920 if k != kind {
2921 return Ok(None);
2922 }
2923 }
2924 FilterTerm::SourceType(s) => {
2925 // source_type is NOT a canonical_nodes column; it is a pure
2926 // function of `kind`. Constant-fold (D2/D3).
2927 match resolve_source_type(kind) {
2928 Ok(resolved) if resolved == s.as_str() => {}
2929 _ => return Ok(None),
2930 }
2931 }
2932 }
2933 }
2934 Ok(Some(preds))
2935 }
2936
2937 /// 0.8.11 Slice 40 — test seam: expose the vec0 backend dispatch so the
2938 /// unification suite can pin the typed-rejection (RED→GREEN) and that a
2939 /// metadata-only Filter lowers losslessly. Returns the lowered
2940 /// [`SearchFilter`] (or `InvalidFilter` for a `Json` term).
2941 #[doc(hidden)]
2942 pub fn to_search_filter_for_test(&self) -> Result<SearchFilter, EngineError> {
2943 self.to_search_filter()
2944 }
2945
2946 /// 0.8.11 Slice 40 — test seam: expose the `read.list` backend lowering so
2947 /// the unification suite can pin total dispatch incl. the `SourceType`/`Kind`
2948 /// constant-folds. `Ok(None)` == constant-folded-empty.
2949 #[doc(hidden)]
2950 pub fn lower_for_read_list_for_test(
2951 &self,
2952 kind: &str,
2953 ) -> Result<Option<Vec<Predicate>>, EngineError> {
2954 self.lower_for_read_list(kind)
2955 }
2956}
2957
2958/// G11 (Slice 15) — a document sent to a BYO-LLM extraction harness via
2959/// [`Engine::ingest_with_extractor`].
2960#[derive(Clone, Debug)]
2961pub struct ExtractDocument {
2962 /// Stable opaque identifier for this document. Used as `source_id` on
2963 /// ingested edges and for provenance tracking.
2964 pub source_doc_id: String,
2965 /// Full text body of the document to extract entities and relationships from.
2966 pub body: String,
2967}
2968
2969/// G11 (Slice 15) — receipt returned by [`Engine::ingest_with_extractor`].
2970#[derive(Clone, Debug, Default)]
2971pub struct IngestWithExtractorReceipt {
2972 /// Number of `canonical_nodes` rows written (new entity insertions; skipped
2973 /// for entities that already have a matching active logical_id).
2974 pub nodes_written: u64,
2975 /// Number of `canonical_edges` rows written (new fact-edge insertions;
2976 /// superseded prior edges are ALSO counted as rows written).
2977 pub edges_written: u64,
2978 /// Number of documents processed (including no-facts documents).
2979 pub docs_processed: u64,
2980}
2981
2982/// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — one (subject-entity, relation) axis to
2983/// consolidate via [`Engine::consolidate_with_provider`]. FathomDB assembles the
2984/// competing fact-edge cluster for this axis DETERMINISTICALLY (CPU-only, no
2985/// LLM) by querying active `canonical_edges` where `from_id = subject_logical_id`
2986/// AND `kind = relation`.
2987#[derive(Clone, Debug)]
2988pub struct ConsolidateAxis {
2989 /// Stable `logical_id` of the subject entity (edge `from_id`).
2990 pub subject_logical_id: String,
2991 /// The relation/edge `kind` whose competing fact-edges form the cluster.
2992 pub relation: String,
2993}
2994
2995/// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — one competing fact-edge in a candidate
2996/// cluster sent to the consolidation harness. Assembled deterministically from
2997/// `canonical_edges`; sent to the harness as the request payload; the harness's
2998/// verdict references edges back by `edge_ref` (the edge's stable `logical_id`).
2999#[derive(Clone, Debug)]
3000pub struct ConsolidateCandidateEdge {
3001 /// The edge's stable `logical_id` — the ref the harness uses in its verdict.
3002 pub edge_ref: String,
3003 /// The fact/relationship text (never rewritten by consolidation — §2.1).
3004 pub body: Option<String>,
3005 /// Event valid-time as INTEGER epoch seconds (UTC), if known.
3006 ///
3007 /// TC-33: epoch seconds, NOT ISO-8601. ISO-8601 lives only on the BYO-LLM
3008 /// extractor wire; `normalize_extractor_timestamp` is the one boundary.
3009 pub t_valid: Option<i64>,
3010 /// Event invalid-time as INTEGER epoch seconds (UTC), if already
3011 /// invalidated. `None` = still valid.
3012 pub t_invalid: Option<i64>,
3013 /// Extraction confidence ∈ [0.0, 1.0], if known.
3014 pub confidence: Option<f64>,
3015 /// Provenance: originating document id.
3016 pub source_doc_id: Option<String>,
3017 /// Provenance: extractor model id from the original BYO-LLM ingest.
3018 pub extractor_model_id: Option<String>,
3019}
3020
3021/// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — receipt returned by
3022/// [`Engine::consolidate_with_provider`]. Consolidation records supersession /
3023/// recency METADATA only (§2.1): edge bodies are never rewritten and no row is
3024/// ever deleted, so these counts describe metadata transitions, not content
3025/// changes.
3026#[derive(Clone, Debug, Default)]
3027pub struct ConsolidateReceipt {
3028 /// Number of (subject, relation) axes with a non-empty cluster that were
3029 /// dispatched to the harness.
3030 pub clusters_processed: u64,
3031 /// Number of candidate edges presented across all clusters.
3032 pub edges_examined: u64,
3033 /// Number of edges the harness ruled `keep` (no metadata change).
3034 pub edges_kept: u64,
3035 /// Number of edges the harness ruled `invalidate` (t_invalid set; row + body
3036 /// preserved).
3037 pub edges_invalidated: u64,
3038 /// Number of edges the harness ruled `supersede`/`merge` (marked superseded
3039 /// via the existing G0 tombstone column; row + body preserved).
3040 pub edges_superseded: u64,
3041}
3042
3043/// 0.8.20 Slice 5c (R-20-E3) — the provenance of a canonical row: which source
3044/// document it is attributable to, and therefore what `excise_source` must erase
3045/// when that source is withdrawn.
3046///
3047/// **Why a newtype and not `Option<String>`.** Erasure runs through provenance:
3048/// a row whose `source_id` is NULL is reachable by NO `excise_source` call and
3049/// is therefore **un-erasable**. Before 0.8.20 the public `PreparedWrite`
3050/// carried `source_id: Option<String>`, so a caller could express "no
3051/// provenance" and silently create such a row. A *runtime* rejection would not
3052/// have closed this: the facade crate re-exports `PreparedWrite` and
3053/// `Engine::write` is `pub`, so a caller can build the value directly and skip
3054/// any validation the engine performs. Replacing the field's type is what makes
3055/// the absence of provenance **inexpressible** rather than merely rejected —
3056/// the guarantee is enforced by `rustc`, not by a branch. `tests/ui/` in the
3057/// facade crate holds the compile-fail witness.
3058///
3059/// **This is a BREAKING change**, shipped ON by default as part of the 0.8.20
3060/// coordinated breaking-pair release. There is deliberately no compatibility
3061/// shim and no deprecation window: a shim would re-open the hole it closes.
3062///
3063/// **Reserved namespace.** Ids beginning with `_` belong to the engine and are
3064/// rejected by [`SourceId::new`]. Two are currently minted internally:
3065///
3066/// * [`SourceId::ENGINE_PREFIX`] (`_engine:`) — rows the engine derives for
3067/// itself (EXP-S coverage/graph substrate rows), which never pass through
3068/// `PreparedWrite` (design §4 item 6).
3069/// * [`SourceId::LEGACY_PRE_0_8_20`] (`_legacy:pre-0.8.20`) — stamped by schema
3070/// migration step 21 onto pre-0.8.20 rows that were stored with NULL
3071/// provenance, so they become erasable (R-20-E8). **Gated to UNGOVERNED rows
3072/// only** (`logical_id IS NULL`); a governed row keeps NULL `source_id` and
3073/// stays `purge`-addressable by its `logical_id` (TC-11 pin).
3074///
3075/// **`source_id` must not be PII.** It survives the erasure it authorises: the
3076/// `excise_source` audit row in `operational_mutations` records it verbatim, and
3077/// while 0.8.20 makes that audit row durable (design §2 defect D-A) the rule was
3078/// always that the handle you erase BY must not itself be the thing needing
3079/// erasure. Use an opaque document id, not an email address.
3080#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
3081pub struct SourceId(String);
3082
3083impl SourceId {
3084 /// Reserved prefix for engine-derived rows (design §4 item 6).
3085 pub const ENGINE_PREFIX: &'static str = "_engine:";
3086
3087 /// Reserved provenance stamped by schema migration step 21 onto pre-0.8.20
3088 /// UNGOVERNED rows that were stored with NULL provenance (R-20-E8).
3089 pub const LEGACY_PRE_0_8_20: &'static str = "_legacy:pre-0.8.20";
3090
3091 /// The single public constructor. Rejects the two ways a caller could
3092 /// express "effectively no provenance":
3093 ///
3094 /// * an empty or whitespace-only id — it names no source, and
3095 /// `excise_source` already refuses the empty string, so such a row would
3096 /// be un-erasable in practice;
3097 /// * an id in the engine's reserved `_`-prefixed namespace — a caller who
3098 /// could mint `_legacy:pre-0.8.20` could hide rows among the migration's
3099 /// back-filled ones, or mint `_engine:` rows that read as engine
3100 /// substrate.
3101 ///
3102 /// # Errors
3103 ///
3104 /// [`EngineError::WriteValidation`] for either rejection above.
3105 pub fn new(id: impl Into<String>) -> Result<Self, EngineError> {
3106 let id = id.into();
3107 if id.trim().is_empty() || id.starts_with('_') {
3108 return Err(EngineError::WriteValidation);
3109 }
3110 Ok(Self(id))
3111 }
3112
3113 /// Mint a reserved `_engine:*` provenance for an engine-derived row. Crate
3114 /// -internal by construction: the reserved namespace is exactly what
3115 /// [`SourceId::new`] refuses, so a caller cannot reach this spelling.
3116 pub(crate) fn engine_derived(role: &str) -> Self {
3117 Self(format!("{}{role}", Self::ENGINE_PREFIX))
3118 }
3119
3120 /// The on-disk `source_id` text.
3121 #[must_use]
3122 pub fn as_str(&self) -> &str {
3123 &self.0
3124 }
3125
3126 /// Consume into the owned on-disk text.
3127 #[must_use]
3128 pub fn into_string(self) -> String {
3129 self.0
3130 }
3131}
3132
3133impl AsRef<str> for SourceId {
3134 fn as_ref(&self) -> &str {
3135 &self.0
3136 }
3137}
3138
3139impl Display for SourceId {
3140 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
3141 f.write_str(&self.0)
3142 }
3143}
3144
3145impl TryFrom<String> for SourceId {
3146 type Error = EngineError;
3147
3148 fn try_from(value: String) -> Result<Self, Self::Error> {
3149 Self::new(value)
3150 }
3151}
3152
3153impl TryFrom<&str> for SourceId {
3154 type Error = EngineError;
3155
3156 fn try_from(value: &str) -> Result<Self, Self::Error> {
3157 Self::new(value)
3158 }
3159}
3160
3161/// Batch input shape for [`Engine::write`].
3162///
3163/// Marked `#[non_exhaustive]` per ADR-0.6.0-prepared-write-shape; new
3164/// entity variants land in 0.6.x without a major bump. Adding fields to
3165/// existing variants remains a binding-coordination change.
3166#[non_exhaustive]
3167#[derive(Clone, Debug, PartialEq)]
3168pub enum PreparedWrite {
3169 Node {
3170 kind: String,
3171 body: String,
3172 /// REQ-026 / AC-028 / AC-042 recovery seam, made **structurally
3173 /// mandatory** in 0.8.20 (R-20-E3). Was `Option<String>`; a `None`
3174 /// landed NULL on disk and produced a row no `excise_source` call could
3175 /// reach. See [`SourceId`] for why the fix is a type change rather than
3176 /// a validation check.
3177 source_id: SourceId,
3178 /// G0 (Slice 15) — stable cross-re-ingestion identity. `Some(id)`
3179 /// makes this write a transaction-time supersession of the prior
3180 /// active version of `(logical_id, kind)` (tombstone-then-insert).
3181 /// `None` is the legacy/own-identity default: a plain insert with a
3182 /// NULL `logical_id` (NULL-safe — never collides with other NULLs).
3183 logical_id: Option<String>,
3184 /// OPP-12 Phase-1 (0.8.19 Slice 5) — the create-time existence state.
3185 /// `InitialState::Active` (the [`Default`]) is the back-compat default and
3186 /// lands `state = 'active'` on disk (value-identical to the migration
3187 /// step-20 column DEFAULT). `InitialState::Pending` creates a quarantined
3188 /// node excluded from default retrieval. A `deleted`/`purged` node is
3189 /// UNREPRESENTABLE at create time (the [`InitialState`] type is the typed
3190 /// rejection) — those states are reachable only via the Slice-10
3191 /// `transition`/`purge` verbs.
3192 state: InitialState,
3193 /// OPP-12 Phase-1 (0.8.19 Slice 5) — advisory cause for the create-time
3194 /// `state` (e.g. the quarantine cause for a `pending` node), stored
3195 /// verbatim in `canonical_nodes.reason`. Engine never interprets it. `None`
3196 /// lands NULL (the back-compat default).
3197 reason: Option<String>,
3198 /// 0.8.20 Slice 15b (TC-34) — world-time validity window, INCLUSIVE lower
3199 /// bound, INTEGER epoch SECONDS UTC. `None` lands NULL = unbounded below.
3200 ///
3201 /// Slice 10b added the `valid_from`/`valid_until` columns, the [`ReadView`]
3202 /// validity predicate and [`Engine::crossed_boundary_since`] but NO writer,
3203 /// so a window could only be authored with raw SQL. These two fields are
3204 /// that writer. They are deliberately FIELDS rather than a new verb,
3205 /// exactly as [`PreparedWrite::Edge`] already carries `t_valid`/`t_invalid`:
3206 /// the governed command surface is unchanged.
3207 ///
3208 /// The pair is validated together — see `valid_until`.
3209 valid_from: Option<i64>,
3210 /// 0.8.20 Slice 15b (TC-34) — world-time validity window, EXCLUSIVE upper
3211 /// bound, INTEGER epoch SECONDS UTC. `None` lands NULL = unbounded above.
3212 ///
3213 /// The window is half-open `[valid_from, valid_until)`, matching the read
3214 /// predicate in `ReadView::validity_sql` exactly. Because it is half-open,
3215 /// a pair with `valid_from >= valid_until` describes an EMPTY window that no
3216 /// instant can ever satisfy — so [`Engine::write`] refuses it with
3217 /// [`EngineError::WriteValidation`] rather than storing a row that no
3218 /// default read could ever return. A ONE-SIDED window is never empty and is
3219 /// never refused, however extreme its single bound.
3220 ///
3221 /// **BREAKING (0.8.20 Slice 22, decision #18).** This refusal used to be
3222 /// [`EngineError::InvalidArgument`] NAMING both bounds. It is now the
3223 /// message-less `WriteValidation` unit variant — the one family the
3224 /// taxonomy of record assigns to a malformed submitted write SHAPE — so
3225 /// **the offending bounds are no longer carried in the error**. A caller
3226 /// that parsed them out must validate the pair before calling.
3227 valid_until: Option<i64>,
3228 },
3229 Edge {
3230 kind: String,
3231 from: String,
3232 to: String,
3233 /// REQ-026 / AC-028 / AC-042 recovery seam — see Node. Structurally
3234 /// mandatory since 0.8.20 (R-20-E3).
3235 source_id: SourceId,
3236 /// G0 (Slice 15) — see Node. Supersession semantics are identical on
3237 /// edges (keyed by `(logical_id, kind)`).
3238 logical_id: Option<String>,
3239 /// G11 (Slice 15) — the fact/relationship text. When `Some`, triggers
3240 /// FTS projection into `search_index_edges` and vector projection via
3241 /// the projection scheduler (kind `"edge_fact"`). Also triggers
3242 /// invalidate-not-accumulate on `(from_id, to_id, kind)`.
3243 body: Option<String>,
3244 /// G11 (Slice 15) — event valid-time. NULL = unknown / still valid.
3245 ///
3246 /// **TC-33 (HITL-RATIFIED 2026-07-21): INTEGER epoch seconds (UTC), not
3247 /// ISO-8601.** This is the GOVERNED SDK WRITE SURFACE, which carries the
3248 /// same representation as storage. ISO-8601 survives ONLY on the BYO-LLM
3249 /// extractor wire (`fathomdb.extract.v1`), where
3250 /// `normalize_extractor_timestamp` converts it with hard rejection.
3251 t_valid: Option<i64>,
3252 /// G11 (Slice 15) — event invalid-time. NULL = still valid.
3253 ///
3254 /// **TC-33: INTEGER epoch seconds (UTC)** — see `t_valid`. The
3255 /// NULL-means-still-valid semantic is load-bearing and unchanged, which
3256 /// is why the schema pins the type with a `typeof` CHECK rather than
3257 /// `NOT NULL`.
3258 t_invalid: Option<i64>,
3259 /// G11 (Slice 15) — extraction confidence ∈ [0.0, 1.0]. NULL for
3260 /// non-BYO-LLM-ingested edges.
3261 confidence: Option<f64>,
3262 /// G11 (Slice 15) — opaque model/provider id from the BYO-LLM harness
3263 /// `ready.model` field. NULL for non-BYO-LLM edges.
3264 extractor_model_id: Option<String>,
3265 /// R3 (Slice 30, SCHEMA-GATE-1, HITL-SIGNED 2026-06-13) — set when the
3266 /// ELPS extractor defaulted this edge's `t_valid` to `created_at` rather
3267 /// than deriving it from the document text. Such edges have untrustworthy
3268 /// event times and are excluded from graph-arm BFS temporal queries.
3269 /// `None`/`false` = not a fallback; `Some(true)` = fallback.
3270 temporal_fallback: Option<bool>,
3271 },
3272 OpStore {
3273 collection: String,
3274 record_key: String,
3275 schema_id: Option<String>,
3276 body: String,
3277 },
3278 AdminSchema {
3279 name: String,
3280 kind: String,
3281 schema_json: String,
3282 retention_json: String,
3283 },
3284}
3285
3286/// EXP-S (0.8.14 Slice 5, D1) — structural-role tag for a canonical row.
3287///
3288/// A SEPARATE axis from the doc-type `kind` (email/article/paper/meeting/
3289/// note/todo/doc/edge_fact): `row_kind` describes *what structural role* a row
3290/// plays in the "one store, many indexes" substrate, not what document type it
3291/// carries. Stored in `canonical_nodes.row_kind` (schema migration step 16).
3292///
3293/// `Leaf` is the default (a normal record; every existing/normal write is a
3294/// leaf — back-compat preserving). `Coverage` = coverage/summary rows;
3295/// `Graph` = graph structural rows. Engine-internal in 0.8.14 — there is NO
3296/// public Py/TS SDK surface for `row_kind` this release (`Leaf` for all normal
3297/// writes; `Coverage`/`Graph` are set only by internal paths). Cross-binding
3298/// parity (X1) is a Slice-40 concern.
3299#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3300pub enum RowKind {
3301 Leaf,
3302 Coverage,
3303 Graph,
3304}
3305
3306impl RowKind {
3307 /// On-disk `canonical_nodes.row_kind` spelling. Must match the migration
3308 /// step-16 `DEFAULT 'leaf'` and the schema vocabulary (D1).
3309 #[must_use]
3310 pub fn as_str(self) -> &'static str {
3311 match self {
3312 RowKind::Leaf => "leaf",
3313 RowKind::Coverage => "coverage",
3314 RowKind::Graph => "graph",
3315 }
3316 }
3317}
3318
3319/// OPP-12 record-lifecycle Phase-1 (0.8.19 Slice 5) — the existence axis.
3320///
3321/// One mutually-exclusive typed enum stored as TEXT in the `canonical_nodes.state`
3322/// column (schema migration step-20). Semantics (design §2 / plan §1):
3323/// `Pending` = present + versioned but NOT admitted to default retrieval
3324/// (quarantine / promotion gate);
3325/// `Active` = admitted to default retrieval (the shipped-corpus default);
3326/// `Deleted` = soft-deleted, retained + recoverable, excluded from default
3327/// reads, stays indexed behind the flag;
3328/// `Purged` = terminal, physically erased.
3329/// `Deleted`/`Purged` are reachable only through the Phase-2/Slice-10
3330/// `transition`/`purge` verbs — they can NEVER be a create-time state (see
3331/// [`InitialState`]).
3332#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3333pub enum LifecycleState {
3334 Pending,
3335 Active,
3336 Deleted,
3337 Purged,
3338}
3339
3340impl LifecycleState {
3341 /// On-disk `canonical_nodes.state` spelling. Must match the migration step-20
3342 /// `DEFAULT 'active'` and the `state = 'active'` default-read exclusion.
3343 #[must_use]
3344 pub fn as_str(self) -> &'static str {
3345 match self {
3346 LifecycleState::Pending => "pending",
3347 LifecycleState::Active => "active",
3348 LifecycleState::Deleted => "deleted",
3349 LifecycleState::Purged => "purged",
3350 }
3351 }
3352
3353 /// Parse the on-disk spelling back into the typed enum. `None` for any value
3354 /// outside the closed vocabulary (a corrupt/foreign `state`).
3355 #[must_use]
3356 pub fn from_str_opt(value: &str) -> Option<Self> {
3357 match value {
3358 "pending" => Some(LifecycleState::Pending),
3359 "active" => Some(LifecycleState::Active),
3360 "deleted" => Some(LifecycleState::Deleted),
3361 "purged" => Some(LifecycleState::Purged),
3362 _ => None,
3363 }
3364 }
3365
3366 /// OPP-12 Phase-1 (0.8.19 Slice 10) — the target states legally reachable from
3367 /// `self` via the `transition` VERB (design §2 legal-transition table). This
3368 /// is the verb-specific enumeration reported by `IllegalTransitionError.legal`:
3369 /// `Pending` → `[Active, Deleted]` (promote / reject)
3370 /// `Active` → `[Deleted]` (soft-delete)
3371 /// `Deleted` → `[Active]` (undelete)
3372 /// `Purged` → `[]` (terminal; nothing is reachable)
3373 /// `Purged` is DELIBERATELY excluded even from `Deleted`: reaching `purged` is
3374 /// the `purge` verb's job (see [`Engine::purge`]), NOT a legal `transition`
3375 /// target, so reporting it here would mislead a caller into thinking
3376 /// `transition(deleted → purged)` is legal when it is not. Likewise `Pending`
3377 /// is create-time-only and is never a `transition` target. Derived directly
3378 /// from [`is_legal_transition_move`] so this can never drift from the table.
3379 #[must_use]
3380 pub fn legal_next_states(self) -> Vec<LifecycleState> {
3381 [
3382 LifecycleState::Pending,
3383 LifecycleState::Active,
3384 LifecycleState::Deleted,
3385 LifecycleState::Purged,
3386 ]
3387 .into_iter()
3388 .filter(|&to| is_legal_transition_move(self, to))
3389 .collect()
3390 }
3391}
3392
3393/// OPP-12 Phase-1 (0.8.19 Slice 10) — whether `(from, to)` is one of the four
3394/// legal `transition`-verb moves (design §2 table): `pending→active` (promote),
3395/// `pending→deleted` (reject), `active→deleted` (soft-delete), `deleted→active`
3396/// (undelete). Every other pair — self-loops, any move to `Purged` (purge-only)
3397/// or `Pending` (create-only), or from `Purged` — is illegal via `transition`.
3398#[must_use]
3399fn is_legal_transition_move(from: LifecycleState, to: LifecycleState) -> bool {
3400 matches!(
3401 (from, to),
3402 (LifecycleState::Pending, LifecycleState::Active)
3403 | (LifecycleState::Pending, LifecycleState::Deleted)
3404 | (LifecycleState::Active, LifecycleState::Deleted)
3405 | (LifecycleState::Deleted, LifecycleState::Active)
3406 )
3407}
3408
3409/// OPP-12 Phase-1 (0.8.19 Slice 5) — the CREATE-TIME subset of [`LifecycleState`].
3410///
3411/// A write can only bring a node into existence as `Pending` or `Active` (design
3412/// §2 / gap-6). You CANNOT create a `Deleted`/`Purged` node — those states are
3413/// reachable only via the `transition`/`purge` verbs (Slice 10). Making the
3414/// create-time surface a separate two-variant type is the TYPED rejection: a
3415/// `deleted`/`purged` create is simply unrepresentable in the Rust API (the SDK
3416/// bindings map an out-of-subset string to a typed write-validation error).
3417#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
3418pub enum InitialState {
3419 Pending,
3420 /// The back-compat default: every pre-lifecycle write lands `Active`, matching
3421 /// the migration step-20 `DEFAULT 'active'`.
3422 #[default]
3423 Active,
3424}
3425
3426impl InitialState {
3427 /// On-disk `canonical_nodes.state` spelling for a create-time state.
3428 #[must_use]
3429 pub fn as_str(self) -> &'static str {
3430 match self {
3431 InitialState::Pending => "pending",
3432 InitialState::Active => "active",
3433 }
3434 }
3435
3436 /// The full [`LifecycleState`] this create-time state corresponds to.
3437 #[must_use]
3438 pub fn to_lifecycle_state(self) -> LifecycleState {
3439 match self {
3440 InitialState::Pending => LifecycleState::Pending,
3441 InitialState::Active => LifecycleState::Active,
3442 }
3443 }
3444
3445 /// Parse a caller-supplied create-time `state` string into the create-time
3446 /// subset. `Some(state)` for `"pending"`/`"active"`; `None` for `"deleted"`,
3447 /// `"purged"`, or any unknown value — the SDK bindings turn `None` into a
3448 /// typed write-validation rejection (you cannot CREATE a deleted/purged node).
3449 #[must_use]
3450 pub fn from_create_str(value: &str) -> Option<Self> {
3451 match value {
3452 "pending" => Some(InitialState::Pending),
3453 "active" => Some(InitialState::Active),
3454 _ => None,
3455 }
3456 }
3457}
3458
3459/// F5 (0.8.14 Slice 10) — per-field BM25F weights for the `search_index_v2`
3460/// multi-column FTS index. One weight per indexed field
3461/// (`kind`/`body`/`status`), applied as the field's contribution multiplier in
3462/// the BM25F weighted-term-frequency accumulation.
3463///
3464/// The default is uniform (`1.0` each) — the "unweighted" baseline the R-F5-1
3465/// acceptance test contrasts against. Boosting a field (e.g. `kind`) makes a
3466/// match in that field outrank a same-strength match in a lower-weighted field.
3467/// Engine-internal for 0.8.14: there is NO public Py/TS SDK surface for these
3468/// tunables this release (cross-binding parity is a Slice-40/X1 concern).
3469#[derive(Clone, Copy, Debug, PartialEq)]
3470pub struct Bm25fFieldWeights {
3471 pub kind: f64,
3472 pub body: f64,
3473 pub status: f64,
3474}
3475
3476impl Default for Bm25fFieldWeights {
3477 fn default() -> Self {
3478 Self { kind: 1.0, body: 1.0, status: 1.0 }
3479 }
3480}
3481
3482/// F5 (0.8.14 Slice 10) — the compiled BM25F query plan for the fielded lexical
3483/// arm (`ADR-0.8.1` §3.2 `BM25fQueryPlan`). Carries the tunable per-field
3484/// `weights` and the tunable length-normalization `b` (and the term-saturation
3485/// `k1`).
3486///
3487/// NOTE on `b`: SQLite FTS5's built-in `bm25()` auxiliary function pins its
3488/// internal `k1`/`b` and exposes ONLY per-column weights — it cannot express a
3489/// tunable `b`. So the score is computed in-engine (a textbook BM25F over the
3490/// FTS5-recalled candidates) rather than delegated to the built-in `bm25()`:
3491/// that is what makes `b` (and `k1`) genuinely tunable here, not a dead
3492/// parameter. The `search_index_v2` FTS5 index is still load-bearing — it does
3493/// the candidate recall (`MATCH`) that the scorer then ranks.
3494///
3495/// Defaults match Robertson/SQLite BM25 (`b = 0.75`, `k1 = 1.2`) with uniform
3496/// field weights. Engine-internal for 0.8.14 (no SDK surface).
3497#[derive(Clone, Copy, Debug, PartialEq)]
3498pub struct Bm25fQueryPlan {
3499 pub weights: Bm25fFieldWeights,
3500 pub b: f64,
3501 pub k1: f64,
3502}
3503
3504impl Default for Bm25fQueryPlan {
3505 fn default() -> Self {
3506 Self { weights: Bm25fFieldWeights::default(), b: 0.75, k1: 1.2 }
3507 }
3508}
3509
3510/// Snapshot of engine-internal counters returned by [`Engine::counters`].
3511///
3512/// Public key set is owned by `dev/design/lifecycle.md` § Public key set
3513/// and locked by AC-004a. Reading a snapshot is non-perturbing per
3514/// AC-004c. The 0.6.0 surface exposes exactly these seven fields.
3515#[derive(Clone, Debug, Default, Eq, PartialEq)]
3516pub struct CounterSnapshot {
3517 pub queries: u64,
3518 pub writes: u64,
3519 pub write_rows: u64,
3520 pub errors_by_code: BTreeMap<String, u64>,
3521 pub admin_ops: u64,
3522 pub cache_hit: u64,
3523 pub cache_miss: u64,
3524}
3525
3526pub use lifecycle::Subscription;
3527
3528/// Stable corruption-on-open detail carried by
3529/// [`EngineOpenError::Corruption`].
3530///
3531/// Layout owned by `dev/design/errors.md` § Corruption detail owner.
3532#[derive(Clone, Debug, Eq, PartialEq)]
3533pub struct CorruptionDetail {
3534 pub kind: CorruptionKind,
3535 pub stage: OpenStage,
3536 pub locator: CorruptionLocator,
3537 pub recovery_hint: RecoveryHint,
3538}
3539
3540/// Open-path corruption category.
3541///
3542/// 0.6.0 emits exactly the four members below; per
3543/// `dev/design/errors.md` § Engine.open corruption table, doctor-only
3544/// finding codes are not represented here.
3545#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3546pub enum CorruptionKind {
3547 WalReplayFailure,
3548 HeaderMalformed,
3549 SchemaInconsistent,
3550 EmbedderIdentityDrift,
3551}
3552
3553/// `Engine.open` stage at which corruption was detected.
3554///
3555/// Per ADR-0.6.0-corruption-open-behavior, `LockAcquisition` is intentionally
3556/// not a member here; lock contention is surfaced via
3557/// [`EngineOpenError::DatabaseLocked`].
3558#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3559pub enum OpenStage {
3560 WalReplay,
3561 HeaderProbe,
3562 SchemaProbe,
3563 EmbedderIdentity,
3564}
3565
3566/// Locator pointing at the corrupted region of the database file.
3567///
3568/// Variant set owned by `dev/design/errors.md` § CorruptionLocator
3569/// ownership. `OpaqueSqliteError` is the required fallback when SQLite
3570/// surfaces corruption without a usable structured locator.
3571#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3572pub enum CorruptionLocator {
3573 FileOffset { offset: u64 },
3574 PageId { page: u32 },
3575 TableRow { table: &'static str, rowid: i64 },
3576 Vec0ShadowRow { partition: &'static str, rowid: i64 },
3577 MigrationStep { from: u32, to: u32 },
3578 OpaqueSqliteError { sqlite_extended_code: i32 },
3579}
3580
3581/// Recovery dispatch surface attached to a corruption detail.
3582///
3583/// `code` is the stable dispatch key used by bindings and doctor output;
3584/// `doc_anchor` points at the documentation section that explains the
3585/// remediation path.
3586#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3587pub struct RecoveryHint {
3588 pub code: &'static str,
3589 pub doc_anchor: &'static str,
3590}
3591
3592#[derive(Clone, Debug, Eq, PartialEq)]
3593pub enum EngineOpenError {
3594 DatabaseLocked {
3595 holder_pid: Option<u32>,
3596 },
3597 Corruption(CorruptionDetail),
3598 IncompatibleSchemaVersion {
3599 seen: u32,
3600 supported: u32,
3601 },
3602 MigrationError {
3603 schema_version_before: u32,
3604 schema_version_current: u32,
3605 step_id: u32,
3606 },
3607 EmbedderIdentityMismatch {
3608 stored: EmbedderIdentity,
3609 supplied: EmbedderIdentity,
3610 },
3611 EmbedderDimensionMismatch {
3612 stored: u32,
3613 supplied: u32,
3614 },
3615 /// Embedder runtime returned a typed error during `Engine::open`.
3616 Embedder(RuntimeEmbedderError),
3617 Io {
3618 message: String,
3619 },
3620}
3621
3622/// Caller-facing selector for the embedder used by an opened engine
3623/// (`dev/design/embedder.md` §0).
3624#[derive(Clone)]
3625pub enum EmbedderChoice {
3626 /// Use the engine's default embedder. With the `default-embedder`
3627 /// Cargo feature enabled, this materializes a `CandleBgeEmbedder`
3628 /// via the EU-3 loader at `Engine::open`; on first use the loader
3629 /// downloads pinned bge-small-en-v1.5 weights from HuggingFace per
3630 /// `ADR-0.7.1-default-embedder-weight-fetch`. Without the feature,
3631 /// this returns `EmbedderError::Failed` directing the caller to
3632 /// rebuild with `--features default-embedder` or supply
3633 /// `EmbedderChoice::Caller`.
3634 Default,
3635 /// Caller supplies the embedder instance. The supplied embedder's
3636 /// `identity()` becomes the workspace's default-profile identity.
3637 Caller(Arc<dyn Embedder>),
3638 /// No embedder configured. Engine opens; subsequent vector writes
3639 /// fail with `EngineError::EmbedderNotConfigured`. Useful for
3640 /// read-only or canonical-only flows.
3641 None,
3642}
3643
3644impl Display for EngineOpenError {
3645 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
3646 match self {
3647 Self::DatabaseLocked { holder_pid } => match holder_pid {
3648 Some(pid) => write!(f, "database is locked by process {pid}"),
3649 None => write!(f, "database is locked by another engine instance"),
3650 },
3651 Self::Corruption(detail) => {
3652 write!(
3653 f,
3654 "engine corruption at {:?} stage: {}",
3655 detail.stage, detail.recovery_hint.code
3656 )
3657 }
3658 Self::IncompatibleSchemaVersion { seen, supported } => write!(
3659 f,
3660 "database schema version {seen} is incompatible with supported version {supported}"
3661 ),
3662 Self::MigrationError {
3663 schema_version_before,
3664 schema_version_current,
3665 step_id,
3666 } => write!(
3667 f,
3668 "schema migration failed at step {step_id}; schema version remained between {schema_version_before} and {schema_version_current}"
3669 ),
3670 Self::EmbedderIdentityMismatch { stored, supplied } => write!(
3671 f,
3672 "embedder identity mismatch: stored {}@{}, supplied {}@{}",
3673 stored.name, stored.revision, supplied.name, supplied.revision,
3674 ),
3675 Self::EmbedderDimensionMismatch { stored, supplied } => write!(
3676 f,
3677 "embedder vector dimension mismatch: stored {stored}, supplied {supplied}",
3678 ),
3679 Self::Embedder(err) => match err {
3680 RuntimeEmbedderError::Timeout => write!(f, "embedder timeout during open"),
3681 RuntimeEmbedderError::Failed { message } => {
3682 write!(f, "embedder failure during open: {message}")
3683 }
3684 },
3685 Self::Io { message } => write!(f, "database I/O error: {message}"),
3686 }
3687 }
3688}
3689
3690impl Error for EngineOpenError {}
3691
3692#[derive(Clone, Debug, Eq, PartialEq)]
3693pub enum EngineError {
3694 Storage,
3695 Projection,
3696 Vector,
3697 Embedder,
3698 EmbedderNotConfigured,
3699 KindNotVectorIndexed,
3700 EmbedderDimensionMismatch {
3701 expected: u32,
3702 actual: u32,
3703 },
3704 Scheduler,
3705 OpStore,
3706 WriteValidation,
3707 SchemaValidation,
3708 Overloaded,
3709 Closing,
3710 /// G11 (Slice 15) — BYO-LLM extractor subprocess error (protocol mismatch,
3711 /// spawn failure, or harness-returned error code).
3712 Extractor,
3713 /// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — BYO-LLM consolidation provider
3714 /// error (protocol mismatch, spawn/handshake failure, task not advertised in
3715 /// `supported_tasks`, or a malformed/out-of-cluster verdict). Rides the SAME
3716 /// `provider_session` transport as `Extractor`; this is the task-specific leaf.
3717 Consolidator,
3718 /// G4 (Slice 35) — filter predicate construction error: non-allowlisted
3719 /// path or invalid filter argument. NOT a panic — returned as a typed error
3720 /// from [`Predicate::json_path_eq`] / [`Predicate::json_path_compare`].
3721 InvalidFilter {
3722 reason: String,
3723 },
3724 /// Slice 20 (G5/G6) — an argument is out of the accepted range (e.g.
3725 /// `depth > 3` for graph traversal). The `msg` field carries a
3726 /// human-readable explanation; it is intentionally non-exhaustive so the
3727 /// binding layer can forward it as a `ValueError` / `TypeError`.
3728 InvalidArgument {
3729 msg: String,
3730 },
3731 /// 0.8.18 Slice 5 (#5 vector-equivalence probe KEYSTONE) — the open-time
3732 /// self-check re-embedded the 45 committed probes with the live backend and
3733 /// found a divergence beyond the frozen D4 floor (a Phase-1 mean-centered
3734 /// `embedding_bin` sign flip, OR a Phase-2 un-centered L2 distance over
3735 /// `VECTOR_EQUIVALENCE_L2_EPSILON`). `Engine::open` succeeded into a degraded
3736 /// state (`dense_disabled = true`); this query-time error is raised at the
3737 /// single choke point [`Engine::search_inner_with_stats`] BEFORE any embedding
3738 /// / vector SQL / graph seeding / CE rerank, refusing EVERY vector-dependent
3739 /// arm (`search`, `search_expand`, explain/rerank, graph-arm). The explicit
3740 /// text-only/FTS-only path ([`Engine::search_text_only`]) stays serviceable.
3741 /// Sibling of the open-time `EngineOpenError::EmbedderIdentityMismatch`; per
3742 /// ADR-0.8.18 codex R2 U1-1 the refusal surfaces as an `EngineError` (queries
3743 /// never surface `EngineOpenError`). `reason` carries a human-readable summary.
3744 VectorEquivalenceMismatch {
3745 reason: String,
3746 },
3747 /// OPP-12 Phase-1 (0.8.19 Slice 10) — a lifecycle `transition`/`purge` move
3748 /// that the engine-enforced legal-transition table (design §2) forbids.
3749 /// Raised for an illegal `transition` target (`purged`/`pending` are never
3750 /// `transition` targets; self-loops; a from→to pair not in the table) AND for
3751 /// a `purge` precondition failure (purge is legal only from `deleted`).
3752 /// `from_state`/`to_state` use the FULL, parity-safe field names (S7 — `from`
3753 /// is a Python reserved word); `legal` enumerates the target states reachable
3754 /// from `from_state` in the full state machine.
3755 IllegalTransition {
3756 from_state: LifecycleState,
3757 to_state: LifecycleState,
3758 legal: Vec<LifecycleState>,
3759 },
3760 /// OPP-12 Phase-1 (0.8.19 Slice 10) — a lifecycle verb (`transition`/`purge`)
3761 /// was addressed with a non-`Logical` id space (a `Content`/`h:` doc-seeded or
3762 /// `Passage`/`p:` synthetic id). Only the `Logical` (`l:`) space is
3763 /// lifecycle-addressable (design §3); this is a typed refusal, never a panic
3764 /// or a silent no-op. `id_space` carries the offending [`IdSpaceKind`].
3765 NotLifecycleAddressable {
3766 id_space: IdSpaceKind,
3767 },
3768 /// 0.8.20 Slice 5b (R-20-E5, design `0.8.20-slice0-erasure-design.md` §4
3769 /// item 4) — an erasure verb (`purge` / `excise_source` /
3770 /// `excise_collection_record`) deleted its rows but could NOT complete the
3771 /// erasure **at rest**, so it refuses to report success.
3772 ///
3773 /// The motivating case is the write-ahead log. `PRAGMA secure_delete=ON`
3774 /// zeroes pages freed inside the database file, but the erased content also
3775 /// sits in the WAL as committed frames from the ORIGINAL insert: an erasure
3776 /// DELETE appends new frames, it never rewrites old ones. Only a
3777 /// `wal_checkpoint(TRUNCATE)` removes them, and a concurrent reader pinning a
3778 /// WAL snapshot makes that checkpoint return `busy`. After a bounded retry
3779 /// the verb raises THIS error rather than returning `Ok` over erased bytes
3780 /// that are still `grep`-able on disk.
3781 ///
3782 /// **Contract: an erasure verb must never report success on an incomplete
3783 /// erasure.** The row deletions are committed and durable when this is
3784 /// raised; what failed is the at-rest scrub. The remedy is to retry the verb
3785 /// (or `recover --truncate-wal`) once the blocking reader has finished.
3786 /// `stage` names the uncompleted step (e.g. `"wal_checkpoint"`,
3787 /// `"telemetry_redaction"`); `detail` is a human-readable summary.
3788 ErasureIncomplete {
3789 stage: String,
3790 detail: String,
3791 },
3792 /// 0.8.20 Slice 15d (R-20-PR) — `configure_projections` refused an
3793 /// incompatible/DESTRUCTIVE change to an existing projection `name` that was
3794 /// NOT accompanied by an explicit `drop`. Omission from the spec never drops
3795 /// (C3, `api-surface.md:27`); a role REMOVAL or a tokenizer/embedder change
3796 /// on a live projection would silently discard an expensive-to-rebuild
3797 /// resource, so it is refused with the destructive `delta` surfaced. The
3798 /// caller re-issues with `drop: [name]` to consciously rebuild.
3799 ProjectionDestructive {
3800 name: String,
3801 delta: String,
3802 },
3803}
3804
3805impl Display for EngineError {
3806 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
3807 match self {
3808 Self::Storage => write!(f, "storage error"),
3809 Self::Projection => write!(f, "projection error"),
3810 Self::Vector => write!(f, "vector error"),
3811 Self::Embedder => write!(f, "embedder error"),
3812 Self::EmbedderNotConfigured => write!(f, "embedder is not configured"),
3813 Self::KindNotVectorIndexed => write!(f, "kind is not configured for vector indexing"),
3814 Self::EmbedderDimensionMismatch { expected, actual } => {
3815 write!(f, "embedder dimension mismatch: expected {expected}, actual {actual}")
3816 }
3817 Self::Scheduler => write!(f, "scheduler error"),
3818 Self::OpStore => write!(f, "op-store error"),
3819 Self::WriteValidation => write!(f, "write validation error"),
3820 Self::SchemaValidation => write!(f, "schema validation error"),
3821 Self::Overloaded => write!(f, "engine overloaded"),
3822 Self::Closing => write!(f, "engine is closing"),
3823 Self::Extractor => write!(f, "extractor error"),
3824 Self::Consolidator => write!(f, "consolidator error"),
3825 Self::InvalidFilter { reason } => write!(f, "invalid filter: {reason}"),
3826 Self::InvalidArgument { msg } => write!(f, "invalid argument: {msg}"),
3827 Self::VectorEquivalenceMismatch { reason } => {
3828 write!(f, "vector-equivalence self-check failed; dense retrieval refused: {reason}")
3829 }
3830 Self::IllegalTransition { from_state, to_state, legal } => {
3831 let legal_list = legal.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ");
3832 write!(
3833 f,
3834 "illegal lifecycle transition {} -> {}; legal targets from {}: [{}]",
3835 from_state.as_str(),
3836 to_state.as_str(),
3837 from_state.as_str(),
3838 legal_list,
3839 )
3840 }
3841 Self::NotLifecycleAddressable { id_space } => write!(
3842 f,
3843 "id space {:?} ({}) is not lifecycle-addressable; only the logical (l:) space is",
3844 id_space,
3845 id_space.prefix(),
3846 ),
3847 Self::ErasureIncomplete { stage, detail } => write!(
3848 f,
3849 "erasure incomplete at stage '{stage}': the rows were deleted but the erasure \
3850 could not be completed at rest ({detail})",
3851 ),
3852 Self::ProjectionDestructive { name, delta } => write!(
3853 f,
3854 "configure_projections refused a destructive change to projection '{name}' \
3855 without an explicit drop ({delta}); re-issue with drop: [\"{name}\"] to rebuild",
3856 ),
3857 }
3858 }
3859}
3860
3861impl EngineError {
3862 /// Stable machine-readable code for `errors_by_code` keys.
3863 ///
3864 /// Names match the binding-facing class stems in
3865 /// `dev/design/errors.md` § Binding-facing class matrix.
3866 fn stable_code(&self) -> &'static str {
3867 match self {
3868 Self::Storage => "StorageError",
3869 Self::Projection => "ProjectionError",
3870 Self::Vector => "VectorError",
3871 Self::Embedder => "EmbedderError",
3872 Self::EmbedderNotConfigured => "EmbedderNotConfiguredError",
3873 Self::KindNotVectorIndexed => "KindNotVectorIndexedError",
3874 Self::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
3875 Self::Scheduler => "SchedulerError",
3876 Self::OpStore => "OpStoreError",
3877 Self::WriteValidation => "WriteValidationError",
3878 Self::SchemaValidation => "SchemaValidationError",
3879 Self::Overloaded => "OverloadedError",
3880 Self::Closing => "ClosingError",
3881 Self::Extractor => "ExtractorError",
3882 Self::Consolidator => "ConsolidatorError",
3883 Self::InvalidFilter { .. } => "InvalidFilterError",
3884 Self::InvalidArgument { .. } => "InvalidArgumentError",
3885 Self::VectorEquivalenceMismatch { .. } => "VectorEquivalenceMismatchError",
3886 Self::IllegalTransition { .. } => "IllegalTransitionError",
3887 Self::NotLifecycleAddressable { .. } => "NotLifecycleAddressableError",
3888 Self::ErasureIncomplete { .. } => "ErasureIncompleteError",
3889 Self::ProjectionDestructive { .. } => "ProjectionDestructiveError",
3890 }
3891 }
3892}
3893
3894impl Error for EngineError {}
3895
3896/// Doctor `check-integrity` invocation flags. `quick` and `round_trip`
3897/// are accepted in 0.6.0 but treated as default; only `full` activates
3898/// `PRAGMA integrity_check`. Per `dev/design/recovery.md` § Doctor-only
3899/// flags.
3900#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
3901pub struct CheckIntegrityOpts {
3902 pub quick: bool,
3903 pub full: bool,
3904 pub round_trip: bool,
3905}
3906
3907/// One section of an [`IntegrityReport`]. Either every check in the
3908/// section was clean, or one or more typed [`Finding`]s describe the
3909/// detected issue. Per AC-043b.
3910#[derive(Clone, Debug, Eq, PartialEq)]
3911pub enum Section {
3912 Clean,
3913 Findings(Vec<Finding>),
3914}
3915
3916/// Single doctor finding record. Stable report-shape per AC-043c. The
3917/// `code` and `doc_anchor` strings are stable dispatch keys owned by
3918/// `dev/design/recovery.md` § Code-to-operator-action cross-reference.
3919#[derive(Clone, Debug, Eq, PartialEq)]
3920pub struct Finding {
3921 pub code: &'static str,
3922 pub stage: &'static str,
3923 pub locator: CorruptionLocator,
3924 pub doc_anchor: &'static str,
3925 pub detail: String,
3926}
3927
3928/// Three-section integrity report. AC-043a pins exactly these three
3929/// keys.
3930#[derive(Clone, Debug, Eq, PartialEq)]
3931pub struct IntegrityReport {
3932 pub physical: Section,
3933 pub logical: Section,
3934 pub semantic: Section,
3935}
3936
3937/// Result of a successful [`Engine::safe_export`] call. The returned
3938/// `manifest_sha256` equals the SHA-256 of the export file bytes (per
3939/// AC-039a) and matches the `sha256` field written into the manifest
3940/// JSON.
3941#[derive(Clone, Debug, Eq, PartialEq)]
3942pub struct SafeExportArtifact {
3943 pub export_path: PathBuf,
3944 pub manifest_path: PathBuf,
3945 pub manifest_sha256: String,
3946}
3947
3948/// Phase 9 Pack B trace report (AC-042). One event per canonical row
3949/// attributable to the requested `source_id`, ordered by `write_cursor`
3950/// ascending.
3951#[derive(Clone, Debug, Eq, PartialEq)]
3952pub struct TraceReport {
3953 pub source_ref: String,
3954 pub events: Vec<TraceEvent>,
3955}
3956
3957/// Single canonical-row tracing record. `table` is one of
3958/// `"canonical_nodes"` or `"canonical_edges"`.
3959#[derive(Clone, Debug, Eq, PartialEq)]
3960pub struct TraceEvent {
3961 pub write_cursor: u64,
3962 pub kind: String,
3963 pub table: &'static str,
3964}
3965
3966/// Which shadow-state surface a [`RebuildReport`] describes.
3967/// `Projections` covers the full FTS5 + vec0 + projection-terminal
3968/// rebuild emitted by [`Engine::rebuild_projections`]. `Vec0` covers
3969/// the vec0-only path emitted by [`Engine::rebuild_vec0`].
3970#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3971pub enum RebuildKind {
3972 Projections,
3973 Vec0,
3974}
3975
3976/// Structured result of a rebuild operation. `rows_invalidated` is the
3977/// total shadow-state rows truncated before re-derivation; `rows_rebuilt`
3978/// is the count of rows the synchronous rebuild loop re-materialised
3979/// (asynchronous re-enqueue work performed by the projection scheduler is
3980/// not counted here). `projection_cursor_after` is the post-rebuild value
3981/// of the projection cursor.
3982#[derive(Clone, Debug, Eq, PartialEq)]
3983pub struct RebuildReport {
3984 pub kind: RebuildKind,
3985 pub rows_invalidated: u64,
3986 pub rows_rebuilt: u64,
3987 pub projection_cursor_after: u64,
3988}
3989
3990/// Phase 9 Pack B excise report (AC-028a/b/c). Counts are post-excise
3991/// totals; `projections_invalidated` reports the shadow-row invalidation
3992/// total (FTS5 + vec0 + projection terminal) for the excised source.
3993#[derive(Clone, Debug, Eq, PartialEq)]
3994pub struct ExciseReport {
3995 pub source_ref: String,
3996 pub nodes_excised: u64,
3997 pub edges_excised: u64,
3998 pub projections_invalidated: u64,
3999}
4000
4001/// 0.8.20 Slice 15d (R-20-PR, C-1) — one member of a [`ProjectionSpec`]'s role
4002/// set. **Exactly three members** (HITL-ratified S8, `api-surface.md:87`):
4003/// `searchable→FTS` and `searchable→vector` are NOT roles — they are tier labels
4004/// carried by the `fts`/`vector` sub-objects of the spec, so an attribute is
4005/// `Searchable` once and the sub-objects select FTS-only / vector-only / both.
4006#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
4007pub enum ProjectionRole {
4008 /// Projects into the EAV store + its `(attr_name, attr_value)` composite
4009 /// index — cheap equality/range, built same-transaction.
4010 Filterable,
4011 /// The F9 importance/recency signal. **Graceful-absent (Q6a):** declaring
4012 /// it is legal and never errors, but the engine DEFERS the build until F9
4013 /// exists and grafts it on the next idempotent `configure_projections`.
4014 Rankable,
4015 /// Full-text / dense recall of the meaning text. The `fts`/`vector`
4016 /// sub-objects select the sub-target.
4017 Searchable,
4018}
4019
4020impl ProjectionRole {
4021 #[must_use]
4022 pub fn as_str(self) -> &'static str {
4023 match self {
4024 ProjectionRole::Filterable => "filterable",
4025 ProjectionRole::Rankable => "rankable",
4026 ProjectionRole::Searchable => "searchable",
4027 }
4028 }
4029
4030 #[must_use]
4031 pub fn from_str_opt(value: &str) -> Option<Self> {
4032 match value {
4033 "filterable" => Some(ProjectionRole::Filterable),
4034 "rankable" => Some(ProjectionRole::Rankable),
4035 "searchable" => Some(ProjectionRole::Searchable),
4036 _ => None,
4037 }
4038 }
4039}
4040
4041/// 0.8.20 Slice 15d (R-20-PR) — the `searchable→FTS` sub-target selector.
4042#[derive(Clone, Debug, Default, Eq, PartialEq)]
4043pub struct ProjectionFts {
4044 /// Optional tokenizer override; `None` ⇒ the engine default FTS5 tokenizer
4045 /// (`body`-FTS's `porter unicode61 remove_diacritics 2`). A custom
4046 /// per-attr tokenizer is the ≥0.9.x multi-field FTS work — recorded but
4047 /// not honoured here (graceful-graft later, same as `rankable`).
4048 pub tokenizer: Option<String>,
4049}
4050
4051/// 0.8.20 Slice 20 (R-20-DR) — the ENGINE-SET readiness of the
4052/// `searchable→vector` projection, per
4053/// `dev/design/record-lifecycle-protocol/projection-registry-and-async-embed.md`
4054/// §3.
4055///
4056/// **Exactly two members.** `filterable` and `searchable→FTS` are
4057/// same-transaction (non-stale on commit) so they need no readiness axis at all;
4058/// `searchable→vector` is **async, rebuild-durable**, so it carries one.
4059///
4060/// **Naming discipline (load-bearing).** The token **`pending` is RESERVED for
4061/// the admission axis** (quarantine/trust — an app judgment). Index-readiness is
4062/// a DIFFERENT, orthogonal dimension (a record can be
4063/// `active ∧ is_latest ∧ admissible` yet `dense_readiness = embedding`), so this
4064/// enum deliberately does **not** reuse that word: the non-ready member is
4065/// `Embedding`, never `Pending`.
4066#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
4067pub enum DenseReadiness {
4068 /// Every row in the vector projection's row set has reached a projection
4069 /// terminal — the dense arm is caught up. Because the vector INSERT and the
4070 /// terminal record are written in ONE transaction
4071 /// ([`commit_projection_outcomes`]), `Ready` can never be observed with the
4072 /// vector row absent (design §4.1 invariant 1).
4073 Ready,
4074 /// At least one row in the projection's row set has not yet reached a
4075 /// projection terminal — embedding is outstanding. This is the ONLY
4076 /// tolerable torn state: readiness `embedding` with the vector absent (the
4077 /// dense arm reads as partial and RRF under-ranks; it does not hide).
4078 Embedding,
4079}
4080
4081impl DenseReadiness {
4082 #[must_use]
4083 pub fn as_str(self) -> &'static str {
4084 match self {
4085 DenseReadiness::Ready => "ready",
4086 DenseReadiness::Embedding => "embedding",
4087 }
4088 }
4089
4090 /// The two accepted spellings. `"pending"` is DELIBERATELY not one of them
4091 /// (reserved for the admission axis) and so parses to `None`.
4092 #[must_use]
4093 pub fn from_str_opt(value: &str) -> Option<Self> {
4094 match value {
4095 "ready" => Some(DenseReadiness::Ready),
4096 "embedding" => Some(DenseReadiness::Embedding),
4097 _ => None,
4098 }
4099 }
4100}
4101
4102/// 0.8.20 Slice 15d (R-20-PR) — the `searchable→vector` sub-target selector.
4103///
4104/// **Slice 20 (R-20-DR) attached `dense_readiness` HERE, additively:** this
4105/// sub-object is STORED by 15d (so the shape exists and a caller can declare a
4106/// vector projection); Slice 20 hangs the READ-METADATA readiness flag off it.
4107/// Nothing in 15d's persisted shape changed (the registry columns
4108/// `vector_embedder` + `vector_declared` still round-trip the declaration) —
4109/// **readiness is DERIVED, never stored**, so there is no schema step and no
4110/// separate flag that could tear (see [`derive_dense_readiness`]).
4111#[derive(Clone, Debug, Default, Eq, PartialEq)]
4112pub struct ProjectionVector {
4113 /// Optional embedder override; `None` ⇒ the engine's shipped default.
4114 pub embedder: Option<String>,
4115 /// 0.8.20 Slice 20 (R-20-DR) — **READ METADATA, engine-set.** Populated by
4116 /// [`Engine::read_projections`]; `None` on every caller-authored spec.
4117 ///
4118 /// It is **not part of the declaration**: `configure_projections` neither
4119 /// stores nor honours it (see [`StoredProjection::from_spec`], which reads
4120 /// only `embedder`), so a value supplied here is INERT — the engine always
4121 /// reports the derived truth. This is deliberately accept-inert rather than
4122 /// hard-reject so `read.projections` output stays feedable straight back
4123 /// into `configure_projections` (the fix-4 read→configure round-trip, which
4124 /// both bindings pin with a test).
4125 ///
4126 /// **0.8.20 Slice 23 (`R-20-SV`) correction (TC-39 class).** This doc used to
4127 /// justify accept-inert by analogy with "the already-audited accept-inert
4128 /// ruling on an `fts`/`vector` sub-object declared without the `searchable`
4129 /// role". **That ruling is OVERRULED** — the HITL ruled the shape an INVALID
4130 /// SPEC on 2026-07-24 and [`apply_projection_config`] now rejects it with
4131 /// [`EngineError::WriteValidation`]. `dense_readiness` accept-inert is
4132 /// UNCHANGED and stands on its own footing: it is engine-set READ METADATA,
4133 /// never part of the declaration, so there is nothing about it to reject.
4134 ///
4135 /// The bindings still HARD-REJECT the shapes that could
4136 /// not round-trip: a readiness supplied with `vector = false`, and any
4137 /// spelling outside `{ready, embedding}`.
4138 pub dense_readiness: Option<DenseReadiness>,
4139}
4140
4141/// 0.8.20 Slice 15d (R-20-PR / C-1) — a single declarative projection
4142/// declaration. HITL-ratified shape (`api-surface.md:85-89`):
4143/// `{ name, roles: Set<ProjectionRole>, fts?, vector? }`. `roles` carries SET
4144/// semantics (dedup + membership; an attribute can be `Filterable` AND
4145/// `Searchable`) — encoded here as a sorted, de-duplicated `BTreeSet`. Named
4146/// `roles`, not `kind` (`kind` is the node/edge type discriminator).
4147#[derive(Clone, Debug, Eq, PartialEq)]
4148pub struct ProjectionSpec {
4149 pub name: String,
4150 pub roles: BTreeSet<ProjectionRole>,
4151 pub fts: Option<ProjectionFts>,
4152 pub vector: Option<ProjectionVector>,
4153}
4154
4155/// 0.8.20 Slice 15d (R-20-PR) — the diff [`Engine::configure_projections`]
4156/// applied. Idempotent re-registration yields `unchanged == true` with all
4157/// vecs empty (the "re-registration is a no-op" acceptance signal). A
4158/// destructive change without an explicit `drop` is an `Err`, not a delta.
4159#[derive(Clone, Debug, Default, Eq, PartialEq)]
4160pub struct ProjectionDelta {
4161 /// Attribute names whose same-transaction projections (EAV / property-FTS)
4162 /// were (re)built by this apply.
4163 pub built: Vec<String>,
4164 /// Attribute names dropped (explicit `drop` list) — their EAV + property-FTS
4165 /// rows and registry row removed.
4166 pub dropped: Vec<String>,
4167 /// Attribute names whose declared roles were persisted but NOT built:
4168 /// `rankable` (F9 not yet live) and the `searchable→vector` sub-target
4169 /// (Slice 20). These graft on a future idempotent apply. No error.
4170 pub deferred: Vec<String>,
4171 /// True iff nothing was built, dropped, or newly deferred — the whole apply
4172 /// diffed to a no-op.
4173 pub unchanged: bool,
4174 /// 0.8.20 Slice 22 (R-20-VC / **TC-67**) — **node KINDS, not attribute
4175 /// names.** The vector-eligible node kinds present in the corpus that the
4176 /// vector writer can NEVER commit, so no `searchable→vector` declaration
4177 /// will ever produce an embedding for them.
4178 ///
4179 /// # Why this field exists — the silence it replaces
4180 ///
4181 /// [`kind_is_vector_committable`] (Slice 20c fix-2) restricted enrolment to
4182 /// the kinds [`resolve_source_type`] maps, because enrolling any other kind
4183 /// is a permanent liveness wedge. That fix was correct and is unchanged —
4184 /// but it made the exclusion **silent**: the declaration persists, its name
4185 /// is pushed onto [`ProjectionDelta::deferred`], and the caller cannot tell
4186 /// "waiting on the embedder" (transient) from "this kind will never be
4187 /// embedded" (permanent). Per the HITL ruling on TC-67 the remedy is
4188 /// option **(c) REPORT** — the vocabulary is NOT grown and the Pack-1 D3
4189 /// partition-key lock is NOT touched (`dev/design/0.7.0-vector-quant-pack1.md`).
4190 ///
4191 /// # Axis, and why the name is what it is
4192 ///
4193 /// `built` / `dropped` / `deferred` are all lists of **projection attribute
4194 /// names**. This one is a list of **node kinds** — a different axis entirely,
4195 /// so the name says `kinds` explicitly and is prefixed `vector_` to bind it
4196 /// to the dense arm (an unsupported kind is still fully FTS/lexically
4197 /// searchable). Sorted and de-duplicated (`SELECT DISTINCT … ORDER BY kind`).
4198 ///
4199 /// # It is a STATE report, not a diff
4200 ///
4201 /// Unlike the other three vectors it does not describe what this call
4202 /// changed; it describes the corpus as it stands. So it is populated on an
4203 /// idempotent re-apply too (where `unchanged == true` and the other three
4204 /// are empty), and it deliberately does NOT feed [`ProjectionDelta::unchanged`].
4205 /// That is what makes the declare-time residual cheap to live with: to
4206 /// refresh the report after writing new kinds, re-apply the same spec — a
4207 /// no-op that still returns a current report.
4208 ///
4209 /// # Independent of the embedder
4210 ///
4211 /// Computed whenever a `searchable→vector` projection is declared, whether
4212 /// or not this session has a live embedder. The vocabulary is static, so
4213 /// "this kind can never be embedded" is true in a no-embedder session too —
4214 /// and must not be conflated with the Q6a graceful-absent deferral, which is
4215 /// transient and is reported through `deferred`.
4216 ///
4217 /// Empty (never absent) when there is nothing to report.
4218 pub vector_unsupported_kinds: Vec<String>,
4219}
4220
4221/// 0.8.20 Slice 5b (R-20-E7) — outcome of
4222/// [`Engine::excise_collection_record`]. `records_excised` counts the erased
4223/// `operational_mutations` versions (an append-only-log collection keeps every
4224/// version of a key); `state_rows_excised` counts the erased
4225/// `operational_state` row (0 or 1).
4226///
4227/// `record_digest` is `SHA-256(collection + 0x1F + record_key)` — the audit
4228/// handle. The raw `record_key` is deliberately NOT carried: it is arbitrary
4229/// caller-supplied text and may itself be the identifier being erased, so
4230/// echoing it into a durable audit row would defeat the erasure.
4231#[derive(Clone, Debug, Eq, PartialEq)]
4232pub struct ExciseRecordReport {
4233 pub collection: String,
4234 pub record_digest: String,
4235 pub records_excised: u64,
4236 pub state_rows_excised: u64,
4237}
4238
4239/// Typed outcome of [`Engine::verify_embedder`]. Mismatches do not raise
4240/// `EngineError`; the operator workflow needs to see the stored vs.
4241/// supplied pair to decide on next action.
4242#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4243pub enum VerifyEmbedderStatus {
4244 Match,
4245 IdentityMismatch,
4246 DimensionMismatch,
4247 BothMismatch,
4248}
4249
4250/// Result of [`Engine::verify_embedder`]. `stored_identity` is the
4251/// `name:revision` pair persisted in `_fathomdb_embedder_profiles`;
4252/// `supplied_identity` echoes the operator's input verbatim.
4253#[derive(Clone, Debug, Eq, PartialEq)]
4254pub struct VerifyEmbedderReport {
4255 pub stored_identity: String,
4256 pub stored_dimension: u32,
4257 pub supplied_identity: String,
4258 pub supplied_dimension: u32,
4259 pub status: VerifyEmbedderStatus,
4260}
4261
4262/// Single table or index entry emitted by [`Engine::dump_schema`].
4263#[derive(Clone, Debug, Eq, PartialEq)]
4264pub struct SchemaObject {
4265 pub name: String,
4266 pub sql: String,
4267}
4268
4269/// Result of [`Engine::dump_schema`]. `user_version` is the
4270/// `PRAGMA user_version` sentinel. Canonical tables appear first per
4271/// [`fathomdb_schema::CANONICAL_TABLES`], then remaining non-`sqlite_*`
4272/// tables alphabetically. Indexes follow the same alphabetical rule.
4273#[derive(Clone, Debug, Eq, PartialEq)]
4274pub struct DumpSchemaReport {
4275 pub user_version: u32,
4276 pub tables: Vec<SchemaObject>,
4277 pub indexes: Vec<SchemaObject>,
4278}
4279
4280/// Single canonical-table row count emitted by [`Engine::dump_row_counts`].
4281#[derive(Clone, Debug, Eq, PartialEq)]
4282pub struct TableRowCount {
4283 pub name: String,
4284 pub rows: u64,
4285}
4286
4287/// Result of [`Engine::dump_row_counts`]. Canonical tables only;
4288/// projection / FTS / vec0 shadow tables are excluded. Order matches
4289/// [`fathomdb_schema::CANONICAL_TABLES`].
4290#[derive(Clone, Debug, Eq, PartialEq)]
4291pub struct DumpRowCountsReport {
4292 pub counts: Vec<TableRowCount>,
4293}
4294
4295/// 0.8.20 Slice 5d (R-20-E8) — one `source_id` bucket in an
4296/// [`OrphanProvenanceReport`]. `source_id` is `None` for the NULL-provenance
4297/// bucket, which after migration step 21 should contain ONLY governed NODES.
4298#[derive(Clone, Debug, Eq, PartialEq)]
4299pub struct OrphanProvenanceSource {
4300 /// `None` = the NULL-`source_id` bucket.
4301 pub source_id: Option<String>,
4302 /// Canonical rows (nodes + edges) carrying this provenance.
4303 pub rows: u64,
4304 /// How many of `rows` carry a `logical_id`.
4305 ///
4306 /// NOT the same thing as "purge-addressable": only a NODE's `logical_id`
4307 /// confers purge-addressability. An EDGE's `logical_id` is a supersession
4308 /// identity and reaches no erasure verb (see
4309 /// [`Engine::orphan_provenance`]), so governed edges are counted here but
4310 /// are NOT subtracted from
4311 /// [`OrphanProvenanceReport::unerasable_rows`].
4312 pub governed_rows: u64,
4313 /// True for the engine's reserved `_`-prefixed namespace (`_engine:*`,
4314 /// `_legacy:pre-0.8.20`). Reserved buckets are reachable only through the
4315 /// operator seam `excise_source`, never through the governed
4316 /// [`Engine::erase_source`].
4317 pub reserved: bool,
4318}
4319
4320/// Result of [`Engine::orphan_provenance`] — the per-`source_id` census behind
4321/// `fathomdb doctor orphan-provenance` (design §4 item 11).
4322///
4323/// `unerasable_rows` is the load-bearing field: canonical rows carrying
4324/// NEITHER a `source_id` NOR a `logical_id`. Such a row is reachable by no
4325/// erasure verb at all — `purge` keys on `logical_id`, `erase_source` keys on
4326/// `source_id` — so it can never be deleted on request. Slice 5c made that
4327/// state unwritable and migration step 21 back-filled the historical cases, so
4328/// a non-zero count means the invariant has been violated and the verb exits
4329/// `DOCTOR_FOUND_ISSUES`.
4330#[derive(Clone, Debug, Eq, PartialEq)]
4331pub struct OrphanProvenanceReport {
4332 /// Per-`source_id` buckets, ordered by descending `rows` then `source_id`
4333 /// so the output is deterministic (a diagnostic that reorders between runs
4334 /// cannot be diffed).
4335 pub sources: Vec<OrphanProvenanceSource>,
4336 /// Total canonical rows surveyed.
4337 pub total_rows: u64,
4338 /// Rows with NO `source_id` AND NO `logical_id` — un-erasable by any verb.
4339 pub unerasable_rows: u64,
4340}
4341
4342/// Result of [`Engine::dump_profile`]. Mirrors the open-time embedder
4343/// posture + the per-kind vector configuration registered in
4344/// `_fathomdb_vector_kinds`.
4345#[derive(Clone, Debug, Eq, PartialEq)]
4346pub struct DumpProfileReport {
4347 pub embedder_identity: String,
4348 pub embedder_dimension: u32,
4349 pub vectorized_kinds: Vec<String>,
4350}
4351
4352/// 0.7.2 PR-2b — result of [`Engine::recompute_mean`] (the manual
4353/// `doctor recompute-mean` path) and of the shared in-transaction
4354/// recompute core. `drift_cos_before` is the cosine between the freshly
4355/// derived corpus mean and the previously-pinned mean (1.0 when nothing
4356/// was pinned yet, i.e. a first pin). `mean_was_pinned` distinguishes a
4357/// refresh of an existing mean from an initial pin. See
4358/// `dev/design/embedder.md` §0.3.
4359#[derive(Clone, Debug, PartialEq)]
4360pub struct MeanRecomputeReport {
4361 pub dim: u32,
4362 pub old_doc_count: u64,
4363 pub doc_count_requantized: u64,
4364 pub drift_cos_before: f32,
4365 pub mean_was_pinned: bool,
4366 pub elapsed_ms: u64,
4367}
4368
4369/// Typed outcome of [`Engine::truncate_wal`]. `Done` matches SQLite's
4370/// `busy = 0` return from `PRAGMA wal_checkpoint(TRUNCATE)`; any other
4371/// value surfaces as `Busy`.
4372#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4373pub enum TruncateWalStatus {
4374 Done,
4375 Busy,
4376}
4377
4378/// Result of [`Engine::truncate_wal`]. Carries the three counters
4379/// returned by `PRAGMA wal_checkpoint(TRUNCATE)`: `busy`, `log_frames`,
4380/// `checkpointed_frames`.
4381#[derive(Clone, Debug, Eq, PartialEq)]
4382pub struct TruncateWalReport {
4383 pub status: TruncateWalStatus,
4384 pub busy: u32,
4385 pub log_frames: u32,
4386 pub checkpointed_frames: u32,
4387}
4388
4389impl Drop for Engine {
4390 fn drop(&mut self) {
4391 let _ = self.close();
4392 }
4393}
4394
4395impl Engine {
4396 pub fn open(path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
4397 Self::open_with_embedder_and_subscriber(
4398 path,
4399 default_embedder_identity(),
4400 None,
4401 None,
4402 None,
4403 &mut |_| {},
4404 )
4405 }
4406
4407 /// Open an engine with an explicit [`EmbedderChoice`].
4408 ///
4409 /// Per `dev/design/embedder.md` §0 + the 0.7.1 EU-5 campaign, this is
4410 /// the canonical entry point for selecting how the workspace's
4411 /// default embedder is supplied. See [`EmbedderChoice`] for the
4412 /// semantics of each variant; in particular `Default` materializes
4413 /// the pinned BGE embedder via the loader when the `default-embedder`
4414 /// feature is enabled.
4415 pub fn open_with_choice(
4416 path: impl Into<PathBuf>,
4417 choice: EmbedderChoice,
4418 ) -> Result<OpenedEngine, EngineOpenError> {
4419 match choice {
4420 EmbedderChoice::Default => Self::open_default_embedder(path),
4421 EmbedderChoice::Caller(embedder) => {
4422 let identity = embedder.identity();
4423 Self::open_with_embedder_and_subscriber(
4424 path,
4425 identity,
4426 Some(embedder),
4427 None,
4428 None,
4429 &mut |_| {},
4430 )
4431 }
4432 EmbedderChoice::None => Self::open_with_embedder_and_subscriber(
4433 path,
4434 default_embedder_identity(),
4435 None,
4436 None,
4437 None,
4438 &mut |_| {},
4439 ),
4440 }
4441 }
4442
4443 /// EU-5b: materialize the engine's pinned default embedder
4444 /// (`CandleBgeEmbedder` backed by the EU-3 loader) and open the
4445 /// workspace with it. Without the `default-embedder` feature, fails
4446 /// with a typed `Embedder` error rather than touching the network.
4447 #[cfg(feature = "default-embedder")]
4448 fn open_default_embedder(path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
4449 use std::time::Instant as DownloadInstant;
4450 let download_start = DownloadInstant::now();
4451 let weights = fathomdb_embedder::loader::load_pinned_default_embedder().map_err(|err| {
4452 EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
4453 message: format!("default embedder loader: {err}"),
4454 })
4455 })?;
4456 let events = weights.events.clone();
4457 let download_ms = if weights.bytes_downloaded > 0 {
4458 Some(u64::try_from(download_start.elapsed().as_millis()).unwrap_or(u64::MAX))
4459 } else {
4460 None
4461 };
4462 let embedder =
4463 fathomdb_embedder::CandleBgeEmbedder::new_from_weights(weights).map_err(|err| {
4464 EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
4465 message: format!("default embedder construct: {err}"),
4466 })
4467 })?;
4468 let embedder: Arc<dyn Embedder> = Arc::new(embedder);
4469 let identity = embedder.identity();
4470 let loader_info = LoaderInfo { download_ms, events };
4471 Self::open_with_embedder_and_subscriber(
4472 path,
4473 identity,
4474 Some(embedder),
4475 Some(loader_info),
4476 None,
4477 &mut |_| {},
4478 )
4479 }
4480
4481 #[cfg(not(feature = "default-embedder"))]
4482 fn open_default_embedder(_path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
4483 Err(EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
4484 message: "EmbedderChoice::Default requires the `default-embedder` Cargo feature"
4485 .to_string(),
4486 }))
4487 }
4488
4489 pub fn open_with_migration_event_sink(
4490 path: impl Into<PathBuf>,
4491 mut emit_migration_event: impl FnMut(&MigrationStepReport),
4492 ) -> Result<OpenedEngine, EngineOpenError> {
4493 Self::open_with_embedder_and_subscriber(
4494 path,
4495 default_embedder_identity(),
4496 None,
4497 None,
4498 None,
4499 &mut emit_migration_event,
4500 )
4501 }
4502
4503 #[cfg(debug_assertions)]
4504 #[doc(hidden)]
4505 pub fn open_with_migrations_for_test(
4506 path: impl Into<PathBuf>,
4507 migrations: &'static [fathomdb_schema::Migration],
4508 mut emit_migration_event: impl FnMut(&MigrationStepReport),
4509 ) -> Result<OpenedEngine, EngineOpenError> {
4510 Self::open_with_migrations(
4511 path,
4512 migrations,
4513 default_embedder_identity(),
4514 None,
4515 None,
4516 &mut emit_migration_event,
4517 None,
4518 )
4519 }
4520
4521 #[doc(hidden)]
4522 pub fn open_with_subscriber_for_test(
4523 path: impl Into<PathBuf>,
4524 subscriber: Arc<dyn lifecycle::Subscriber>,
4525 ) -> Result<OpenedEngine, EngineOpenError> {
4526 Self::open_with_embedder_and_subscriber(
4527 path,
4528 default_embedder_identity(),
4529 None,
4530 None,
4531 Some(subscriber),
4532 &mut |_| {},
4533 )
4534 }
4535
4536 #[doc(hidden)]
4537 pub fn open_without_embedder_for_test(
4538 path: impl Into<PathBuf>,
4539 ) -> Result<OpenedEngine, EngineOpenError> {
4540 Self::open_with_embedder_and_subscriber(
4541 path,
4542 default_embedder_identity(),
4543 None,
4544 None,
4545 None,
4546 &mut |_| {},
4547 )
4548 }
4549
4550 #[doc(hidden)]
4551 pub fn open_with_embedder_for_test(
4552 path: impl Into<PathBuf>,
4553 embedder: Arc<dyn Embedder>,
4554 ) -> Result<OpenedEngine, EngineOpenError> {
4555 let identity = embedder.identity();
4556 Self::open_with_embedder_and_subscriber(
4557 path,
4558 identity,
4559 Some(embedder),
4560 None,
4561 None,
4562 &mut |_| {},
4563 )
4564 }
4565
4566 fn open_with_embedder_and_subscriber(
4567 path: impl Into<PathBuf>,
4568 embedder_identity: EmbedderIdentity,
4569 runtime_embedder: Option<Arc<dyn Embedder>>,
4570 loader_info: Option<LoaderInfo>,
4571 initial_subscriber: Option<Arc<dyn lifecycle::Subscriber>>,
4572 emit_migration_event: &mut impl FnMut(&MigrationStepReport),
4573 ) -> Result<OpenedEngine, EngineOpenError> {
4574 Self::open_with_migrations(
4575 path,
4576 MIGRATIONS,
4577 embedder_identity,
4578 runtime_embedder,
4579 loader_info,
4580 emit_migration_event,
4581 initial_subscriber,
4582 )
4583 }
4584
4585 fn open_with_migrations(
4586 path: impl Into<PathBuf>,
4587 migrations: &'static [fathomdb_schema::Migration],
4588 embedder_identity: EmbedderIdentity,
4589 runtime_embedder: Option<Arc<dyn Embedder>>,
4590 loader_info: Option<LoaderInfo>,
4591 emit_migration_event: &mut impl FnMut(&MigrationStepReport),
4592 initial_subscriber: Option<Arc<dyn lifecycle::Subscriber>>,
4593 ) -> Result<OpenedEngine, EngineOpenError> {
4594 let canonical_path = canonical_database_path(&path.into())?;
4595 let lock = acquire_lock(&canonical_path)?;
4596 let open_result = Self::open_locked(
4597 canonical_path.clone(),
4598 migrations,
4599 &embedder_identity,
4600 emit_migration_event,
4601 );
4602
4603 match open_result {
4604 Ok((connection, readers, mut report, reader_lookaside_rcs)) => {
4605 // EU-5b — splice the loader's measurements + structured
4606 // events into the report. The loader path is the only
4607 // surface that produces these today; caller-supplied
4608 // embedders and EmbedderChoice::None leave them as the
4609 // open_locked defaults (None / empty).
4610 if let Some(info) = loader_info {
4611 if info.download_ms.is_some() {
4612 report.embedder_download_ms = info.download_ms;
4613 }
4614 if !info.events.is_empty() {
4615 report.embedder_events = info.events;
4616 }
4617 }
4618
4619 // 0.8.18 Slice 5 (#5 vector-equivalence probe KEYSTONE) — run the
4620 // open-time self-check on the FINAL post-recovery connection (the
4621 // mean is already pinned/recovered inside open_locked, U1-b). First
4622 // registration persists the 45 UN-centered f32 references; a
4623 // subsequent open re-embeds + asserts P1 (mean-centered flip count,
4624 // floor 0) and P2 (un-centered L2 ε). Divergence ⇒ degraded-open
4625 // (`dense_disabled=true`), surfaced on the OpenReport (R-VEQ-6); the
4626 // query-time refusal fires later at `search_inner_with_stats`.
4627 let veq = run_vector_equivalence_probe(
4628 &connection,
4629 runtime_embedder.as_deref(),
4630 &embedder_identity,
4631 report.embedder_mean_vec_pinned,
4632 );
4633 report.dense_disabled = veq.dense_disabled;
4634 report.dense_disabled_reason = veq.reason.clone();
4635
4636 let next_cursor = load_next_cursor(&connection);
4637 let subscribers = Arc::new(lifecycle::SubscriberRegistry::new());
4638 let profiling_enabled = Arc::new(AtomicBool::new(false));
4639 let slow_threshold_ms = Arc::new(AtomicU64::new(DEFAULT_SLOW_THRESHOLD_MS));
4640 let mut profile_contexts: Vec<Box<ProfileContext>> = Vec::new();
4641 let projection_runtime = ProjectionRuntime::new(
4642 canonical_path.clone(),
4643 runtime_embedder.clone(),
4644 embedder_identity.clone(),
4645 report.embedder_mean_vec_pinned,
4646 Arc::clone(&subscribers),
4647 );
4648
4649 install_profile_callback(
4650 &connection,
4651 &subscribers,
4652 &profiling_enabled,
4653 &slow_threshold_ms,
4654 &mut profile_contexts,
4655 );
4656 for reader in &readers {
4657 install_profile_callback(
4658 reader,
4659 &subscribers,
4660 &profiling_enabled,
4661 &slow_threshold_ms,
4662 &mut profile_contexts,
4663 );
4664 }
4665
4666 let opened = OpenedEngine {
4667 engine: Self {
4668 path: canonical_path.clone(),
4669 next_cursor: AtomicU64::new(next_cursor),
4670 closed: AtomicBool::new(false),
4671 lock: Mutex::new(Some(lock)),
4672 connection: Mutex::new(Some(connection)),
4673 reader_pool: ReaderWorkerPool::new(readers),
4674 counters: lifecycle::Counters::new(),
4675 subscribers,
4676 profiling_enabled,
4677 slow_threshold_ms,
4678 runtime_embedder,
4679 runtime_embedder_identity: embedder_identity,
4680 projection_runtime,
4681 provenance_row_cap: AtomicU64::new(DEFAULT_PROVENANCE_ROW_CAP),
4682 profile_contexts: Mutex::new(profile_contexts),
4683 reader_lookaside_rcs,
4684 telemetry: Mutex::new(None),
4685 telemetry_enabled: AtomicBool::new(false),
4686 dense_disabled: AtomicBool::new(veq.dense_disabled),
4687 dense_disabled_reason: Mutex::new(veq.reason),
4688 vector_equivalence_refusals: AtomicU64::new(0),
4689 #[cfg(debug_assertions)]
4690 force_next_commit_failure: AtomicBool::new(false),
4691 },
4692 report,
4693 };
4694 if let Some(subscriber) = initial_subscriber {
4695 opened.engine.subscribers.attach_persistent(subscriber);
4696 }
4697 if database_has_pending_projection_work(&canonical_path).unwrap_or(false) {
4698 opened.engine.projection_runtime.notify_new_work();
4699 }
4700 Ok(opened)
4701 }
4702 Err(err) => {
4703 if let Some(subscriber) = initial_subscriber {
4704 emit_open_error_event(&subscriber, &err);
4705 }
4706 drop(lock);
4707 Err(err)
4708 }
4709 }
4710 }
4711
4712 fn open_locked(
4713 path: PathBuf,
4714 migrations: &'static [fathomdb_schema::Migration],
4715 embedder_identity: &EmbedderIdentity,
4716 emit_migration_event: &mut impl FnMut(&MigrationStepReport),
4717 ) -> Result<(Connection, Vec<Connection>, OpenReport, Vec<i32>), EngineOpenError> {
4718 init_perf_experiments_runtime();
4719 register_sqlite_vec_extension();
4720 let mut connection = Connection::open(&path)
4721 .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))?;
4722 // Order pinned by `dev/design/errors.md` § OpenStage matrix: each
4723 // step routes its own SQLite-level error to a distinct
4724 // `CorruptionKind` (Header → WalReplay → Schema → EmbedderIdentity).
4725 // The schema and WAL probes both happen BEFORE `pragma WAL`
4726 // because that pragma also reads page 1 — letting it run first
4727 // would reclassify schema-side corruption as a WAL replay
4728 // failure, breaking the AC-035b stable-code contract.
4729 probe_database_header(&connection)?;
4730 probe_open_integrity(&connection)?;
4731 probe_wal_sidecar(&path)?;
4732 // 0.7.0 perf-experiments: apply writer-side experiment PRAGMAs
4733 // (page_size, etc.) BEFORE journal_mode + migrations. page_size
4734 // is silently ignored once any table exists; this is the only
4735 // legal window to set it on a fresh DB. Gated on
4736 // FATHOMDB_PERF_EXPERIMENTS=1; no-op in production.
4737 apply_perf_experiment_writer_pragmas(&connection);
4738 // OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — standing
4739 // `secure_delete=ON` on the writer, applied at EVERY open (fresh + migrated).
4740 // It zeroes every page freed by a future DELETE, so the Slice-10 `purge`
4741 // hard-erase is complete WITHOUT a per-purge `VACUUM`. It is a connection
4742 // PRAGMA (not schema DDL), so it belongs here, not in the 19→20 migration.
4743 // RESIDUAL (documented, not forced): pages freed on a pre-20 DB BEFORE this
4744 // was enabled are not retroactively scrubbed; there is no migration-time
4745 // full `VACUUM` (O(db-size)). NOTE: this is a standing pragma set at EVERY
4746 // connection open (writer here, plus the reader-pool and
4747 // `open_runtime_connection`), NOT the writer alone — non-writer connections
4748 // also free pages (projection / vector-rewrite DELETEs), so a writer-only
4749 // `secure_delete` would leak freed content on disk. See the matching
4750 // reader/runtime open comment (~lines 3335-3336).
4751 connection
4752 .pragma_update(None, "secure_delete", "ON")
4753 .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
4754 connection
4755 .pragma_update(None, "journal_mode", "WAL")
4756 .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
4757
4758 reject_legacy_shape(&connection)?;
4759 let migration = migrate_with_event_sink(&connection, migrations, emit_migration_event)
4760 .map_err(map_migration_error)?;
4761 // 0.8.0 Slice 5 (G1) — global FTS5 tokenizer-default upgrade. Step 11
4762 // drops + recreates `search_index` with the new tokenizer, leaving it
4763 // EMPTY on a migrated DB. The projection scheduler will NOT
4764 // repopulate it (`database_has_pending_projection_work` keys "pending"
4765 // off `_fathomdb_projection_terminal`, which the migration does not
4766 // clear). Re-tokenize from the canonical source rows here, on the
4767 // writer connection, single-threaded, before readers spawn —
4768 // projection-only, no source-record migration.
4769 //
4770 // Crash-retryable (fix-1): step 11 commits `user_version = 11` with an
4771 // empty index in its OWN transaction; this reproject commits in a
4772 // LATER transaction. A crash in that window leaves a durable v11 + empty
4773 // index, on which a boundary-crossing guard (`before < 11`) is FALSE,
4774 // skipping repair forever. So gate on the completion marker's ABSENCE
4775 // (written atomically with the reindex) instead: idempotent, and a
4776 // crash before the reindex commit simply re-runs on the next open.
4777 if migration.schema_version_after >= SEARCH_INDEX_TOKENIZER_SCHEMA_VERSION
4778 && !search_index_tokenizer_reproject_complete(&connection).map_err(|_| {
4779 EngineOpenError::Io {
4780 message: "could not read search_index tokenizer reproject marker".to_string(),
4781 }
4782 })?
4783 {
4784 reproject_search_index_after_tokenizer_upgrade(&connection).map_err(|_| {
4785 EngineOpenError::Io {
4786 message: "could not re-tokenize search_index after tokenizer upgrade"
4787 .to_string(),
4788 }
4789 })?;
4790 }
4791 let mut embedder_mean_vec_pinned = check_embedder_profile(&connection, embedder_identity)?;
4792 ensure_vector_partition(&mut connection, embedder_identity.dimension).map_err(|_| {
4793 EngineOpenError::Io { message: "could not initialize vector partition".to_string() }
4794 })?;
4795
4796 // 0.8.20 Slice 15c (TC-33) fix-6 [codex §9 P1] — the step-23
4797 // `canonical_edges` recreate drops every edge row (NO DATA MIGRATION) and
4798 // removes their `_fathomdb_vector_rows` sidecar rows, but the vec0
4799 // `vector_default` shadow those mirror is engine-created + dim-aware, so
4800 // the migration cannot delete its rows. Left behind, an orphaned edge vec0
4801 // row (whose `canonical_edges` row is gone) still occupies a top-K KNN
4802 // candidate slot — `build_vector_phase1_sql` reads candidates DIRECTLY
4803 // from `vector_default` before hydrating them through the canonical tables
4804 // — and is then discarded at hydration, so an upgraded DB silently returns
4805 // too few / no vector results. Prune the orphans now that
4806 // `ensure_vector_partition` guarantees `vector_default` exists, BEFORE the
4807 // mean-vec row-count recovery below (so the count excludes them). One-time
4808 // and crash-retryable via the durable completion marker; a no-op on any
4809 // healthy corpus (every vec0 row has a sidecar entry), so recall / eu7
4810 // fidelity are unchanged on a DB that never dropped edges.
4811 if migration.schema_version_after >= EDGE_TEMPORAL_EPOCH_SCHEMA_VERSION
4812 && !edge_vector_prune_complete(&connection).map_err(|_| EngineOpenError::Io {
4813 message: "could not read edge-vector prune marker".to_string(),
4814 })?
4815 {
4816 prune_orphaned_edge_vectors(&connection).map_err(|_| EngineOpenError::Io {
4817 message: "could not prune orphaned edge vector rows".to_string(),
4818 })?;
4819 }
4820
4821 // 0.8.20 Slice 15d (R-20-PR, Q5) — boot re-derive the projection registry
4822 // (the engine `ProjectionSpec` is a derived cache). For every persisted
4823 // declaration, clear + backfill its EAV / property-FTS rows from the
4824 // canonical nodes so a crash window (registry row survives, projection
4825 // rows partial) self-heals idempotently. A no-op single empty-table read
4826 // on every DB that has not declared a projection. On the writer
4827 // connection, single-threaded, before readers spawn — like the tokenizer
4828 // reproject above. Runs after the fix-6 edge-vector prune above; the two
4829 // are independent boot reconciliations.
4830 rederive_projections_on_boot(&connection).map_err(|_| EngineOpenError::Io {
4831 message: "could not re-derive projection registry on boot".to_string(),
4832 })?;
4833
4834 // 0.8.20 Slice 21 fix-1 (codex §9 round 1 [P2], ledger `TC-71`) — bring an
4835 // ALREADY-ENROLLED inert vector kind into agreement with the role-aware
4836 // decision. Slice 21c closed the three forward doors, but a database that
4837 // already ran the old code under `{roles:[filterable], vector:{}}` keeps
4838 // its `_fathomdb_vector_kinds` rows — `vector_kind_needs_enrolment`
4839 // short-circuits on `kind_is_vector_indexed` and never reaches the new
4840 // predicate, and `project_canonical_node_row` reads only the registry
4841 // membership — so upgrading did not actually stop the unwanted embeddings.
4842 // Narrowly authorised (registry EXISTS, declares a `vector` sub-object,
4843 // and declares no `searchable→vector` projection) so a LEGACY workspace
4844 // with a working dense arm is never touched; see
4845 // [`registry_governs_an_inert_dense_arm`]. Deletes no embedding. Runs
4846 // BEFORE `run_vector_equivalence_probe` (which fires after `open_locked`
4847 // returns), so a database whose only enrolment was the inert one pays no
4848 // probe embeds on the healing open. Another boot reconciliation on the
4849 // writer connection, single-threaded, before readers spawn.
4850 reconcile_inert_vector_enrolments_on_boot(&connection).map_err(|_| {
4851 EngineOpenError::Io {
4852 message: "could not reconcile inert vector kind enrolments on boot".to_string(),
4853 }
4854 })?;
4855
4856 // 0.8.20 Slice 15e — reconcile the live `vector_default` attribute columns
4857 // with the registry's `filterable` set. On a DB whose vec0 shape already
4858 // matches the registry (the common case, incl. every reopen of a DB that
4859 // declared filterable projections in a prior session) this is a pure
4860 // no-op: the diff is empty, so boot never re-inserts and NEVER silently
4861 // wipes the corpus. It converges only a shape that drifted from the
4862 // registry (e.g. a restored registry row). A no-op when the table is
4863 // absent (no embedder). Runs on the writer connection, single-threaded,
4864 // before readers spawn — like the boot re-derive above.
4865 {
4866 let tx = connection.transaction().map_err(|_| EngineOpenError::Io {
4867 message: "could not begin vector-attr reconcile on boot".to_string(),
4868 })?;
4869 reconcile_vector_attr_columns(&tx, embedder_identity.dimension).map_err(|_| {
4870 EngineOpenError::Io {
4871 message: "could not reconcile vector attribute columns on boot".to_string(),
4872 }
4873 })?;
4874 tx.commit().map_err(|_| EngineOpenError::Io {
4875 message: "could not commit vector-attr reconcile on boot".to_string(),
4876 })?;
4877 }
4878
4879 // EU-5f — recovery pin (`dev/design/embedder.md` §0.3, Hazard 4). If
4880 // the identity is MC-required, no mean is pinned, yet the workspace
4881 // already holds >= MEAN_VEC_PIN_THRESHOLD vector rows (e.g. a crash
4882 // between the threshold-crossing write and its pin commit), derive
4883 // the mean from the existing un-centered rows and pin+re-quantize
4884 // now, single-threaded, before the projection workers spawn. The
4885 // NULL guard makes this idempotent on subsequent opens.
4886 if identity_requires_mean_centering(embedder_identity) && !embedder_mean_vec_pinned {
4887 let row_count: u64 = connection
4888 .query_row("SELECT COUNT(*) FROM vector_default", [], |row| row.get(0))
4889 .unwrap_or(0);
4890 if row_count >= MEAN_VEC_PIN_THRESHOLD {
4891 recover_mean_vec_pin(&mut connection, embedder_identity).map_err(|_| {
4892 EngineOpenError::Io {
4893 message: "could not recover mean-centering pin".to_string(),
4894 }
4895 })?;
4896 embedder_mean_vec_pinned = true;
4897 }
4898 }
4899
4900 let warmup_started = Instant::now();
4901 // Static identity capability — see `dev/design/embedder.md`
4902 // §0.6. Today only the bge-small identity reports `true`; the
4903 // noop scaffolding identity is `false`. EU-5b's identity flip
4904 // makes the Default path return `true` here automatically.
4905 let embedder_mean_centering_required = embedder_identity.name == BGE_SMALL_EMBEDDER_NAME;
4906 // EU-5a2 — populated from `_fathomdb_embedder_profiles.mean_vec`
4907 // by `check_embedder_profile` above (was hard-coded `false` in
4908 // EU-5a1). Dimension invariant (§0.2) enforced by that check.
4909 let report = OpenReport {
4910 schema_version_before: migration.schema_version_before,
4911 schema_version_after: migration.schema_version_after,
4912 migration_steps: migration.migration_steps,
4913 embedder_warmup_ms: u64::try_from(warmup_started.elapsed().as_millis())
4914 .unwrap_or(u64::MAX),
4915 query_backend: "fathomdb-query + sqlite-vec",
4916 default_embedder: embedder_identity.clone(),
4917 // TODO(EU-5b): surface `LoadedWeights.download_ms` from the
4918 // loader once the Default path materializes through it.
4919 embedder_download_ms: None,
4920 // TODO(EU-5b): surface `LoadedWeights.events` from the loader.
4921 embedder_events: Vec::new(),
4922 embedder_mean_centering_required,
4923 embedder_mean_vec_pinned,
4924 // 0.8.18 Slice 5 — set by the #5 self-check in `open_with_migrations`
4925 // (which has the runtime embedder in scope). `open_locked` returns the
4926 // non-degraded default; the probe runs after this returns.
4927 dense_disabled: false,
4928 dense_disabled_reason: None,
4929 };
4930
4931 let mut readers = Vec::with_capacity(READER_POOL_SIZE);
4932 let mut lookaside_rcs: Vec<i32> = Vec::with_capacity(READER_POOL_SIZE);
4933 for _ in 0..READER_POOL_SIZE {
4934 let reader = Connection::open(&path)
4935 .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))?;
4936 // Pack 6.G G.1: configure per-connection lookaside BEFORE
4937 // any PRAGMA / prepare runs on this reader. Reordering this
4938 // after the journal-mode / query_only PRAGMAs would let
4939 // SQLite silently ignore the lookaside setting.
4940 let rc: i32 = configure_reader_lookaside(&reader);
4941 debug_assert_eq!(
4942 rc,
4943 rusqlite::ffi::SQLITE_OK,
4944 "sqlite3_db_config(LOOKASIDE) must return SQLITE_OK on a freshly opened reader",
4945 );
4946 lookaside_rcs.push(rc);
4947 reader
4948 .pragma_update(None, "journal_mode", "WAL")
4949 .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
4950 // OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — `secure_delete=ON`
4951 // at EVERY connection open, not just the writer. `secure_delete` is a
4952 // per-connection pager flag, so a reader-pool connection that frees a
4953 // page (vector-rewrite / projection DELETEs run off non-writer
4954 // connections) would otherwise leave that freed content on disk,
4955 // defeating GDPR erasure. Set BEFORE `query_only=ON` so the ordering is
4956 // unambiguous (the flag is a pager setting, not a DB write).
4957 reader
4958 .pragma_update(None, "secure_delete", "ON")
4959 .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
4960 reader
4961 .pragma_update(None, "query_only", "ON")
4962 .map_err(|err| map_open_sqlite_error(err, OpenStage::SchemaProbe))?;
4963 apply_perf_experiment_reader_pragmas(&reader);
4964 readers.push(reader);
4965 }
4966
4967 Ok((connection, readers, report, lookaside_rcs))
4968 }
4969
4970 #[must_use]
4971 pub fn path(&self) -> &Path {
4972 &self.path
4973 }
4974
4975 pub fn write(&self, batch: &[PreparedWrite]) -> Result<WriteReceipt, EngineError> {
4976 let category = if batch_is_admin(batch) {
4977 lifecycle::EventCategory::Admin
4978 } else {
4979 lifecycle::EventCategory::Writer
4980 };
4981 self.emit_event(lifecycle::Phase::Started, category, None);
4982 let started = Instant::now();
4983 let outcome = self.write_inner(batch);
4984 self.detect_slow(started, category);
4985 match outcome {
4986 Ok(receipt) => {
4987 let rows = u64::try_from(batch.len()).unwrap_or(u64::MAX);
4988 if batch_is_admin(batch) {
4989 self.counters.record_admin();
4990 } else {
4991 self.counters.record_write(rows);
4992 }
4993 self.emit_event(lifecycle::Phase::Finished, category, None);
4994 Ok(receipt)
4995 }
4996 Err(err) => {
4997 let code = err.stable_code();
4998 self.counters.record_error(code);
4999 // AC-003d: capture-ordinal < raise-ordinal — Failed and Error
5000 // events both fire before the EngineError returns to the caller.
5001 self.emit_event(lifecycle::Phase::Failed, category, Some(code));
5002 self.emit_event(
5003 lifecycle::Phase::Failed,
5004 lifecycle::EventCategory::Error,
5005 Some(code),
5006 );
5007 Err(err)
5008 }
5009 }
5010 }
5011
5012 /// 0.8.20 Slice 20c (R-20-DR remainder) — **late enrolment**, the write-path
5013 /// half of the C4 rider.
5014 ///
5015 /// [`enqueue_declared_vector_backfill`] enrols the kinds the corpus held AT
5016 /// DECLARATION TIME. A kind first written AFTERWARDS would otherwise fall
5017 /// through [`project_canonical_node_row`]'s `kind_is_vector_indexed` gate
5018 /// straight onto a permanent `'up_to_date'` terminal and be silently,
5019 /// irrecoverably un-embedded — the identical false-ready barrier, reached by
5020 /// writing second instead of declaring second.
5021 ///
5022 /// **Gated on a LIVE embedder** (`runtime_embedder`), which is exactly why
5023 /// this lives on `Engine` and not inside the projector: with
5024 /// `EmbedderChoice::None` there is no dense arm at all, so enrolling a kind
5025 /// would only queue embeds that retry to a `failed` terminal and pollute
5026 /// `projection_failures`. The declaration still PERSISTS without an embedder
5027 /// — it simply defers, and grafts on the next idempotent apply in a session
5028 /// that has one (the shipped Q6a graceful-absent/graceful-graft contract,
5029 /// same as `rankable`). It mirrors the `run_vector_equivalence_probe` gate:
5030 /// "no live embedder ⇒ no dense arm to guard".
5031 ///
5032 /// Enrolment is an idempotent `INSERT OR IGNORE`, so running it on the writer
5033 /// connection just OUTSIDE the batch transaction is safe: if the batch then
5034 /// fails, the workspace is left having enrolled a kind for which no row
5035 /// exists — inert.
5036 ///
5037 /// fix-1 (codex §9 [P2]) — the `vector_projection_declared` probe below is
5038 /// what stops this path re-enrolling immediately after
5039 /// [`unenrol_registry_vector_node_kinds`] has run: the inverse removes the
5040 /// registry row, and with no active declaration this returns without
5041 /// re-adding it. (The kind registry DOES now have delete paths: that one, plus
5042 /// the Slice-21 fix-1 reconciliation
5043 /// [`reconcile_inert_vector_enrolments_on_boot`]. Both are gated on the SAME
5044 /// predicate this probe reads, so neither can be undone by a later write.)
5045 ///
5046 /// Cost: the registry probe is skipped entirely once the kind is enrolled, so
5047 /// a workspace with a live dense arm pays nothing; a workspace that never
5048 /// declared a vector projection pays two `prepare_cached` `EXISTS` probes per
5049 /// batch-row of an unenrolled kind. (Slice 21c / `TC-71` made that probe
5050 /// require the `searchable` ROLE, and deliberately kept the second `EXISTS`
5051 /// as a fast negative so this cost is unchanged — see
5052 /// [`vector_projection_declared`].)
5053 ///
5054 /// fix-2 (codex §9 [P2]) — a late enrolment now runs the SAME stranded-row
5055 /// treatment the declare-time door runs ([`reenqueue_stranded_vector_rows`]),
5056 /// and returns `true` iff that re-enqueued anything. Enrolling a kind while
5057 /// enqueueing ONLY the batch's own row left every earlier row of that kind
5058 /// holding its permanent `'up_to_date'` terminal with no vector, so once the
5059 /// new row drained readiness reported `ready` with pre-existing vector-eligible
5060 /// rows unembedded — a FALSE READY. Reached, for instance, by a database that
5061 /// persisted the declaration while opened WITHOUT an embedder and then reopened
5062 /// WITH one and wrote before re-applying the projection.
5063 ///
5064 /// fix-5 (codex §9 round 4 [P2]) — the registry INSERT and the un-stranding
5065 /// commit as ONE `BEGIN IMMEDIATE`…`COMMIT` (the shape
5066 /// [`rederive_projections_on_boot`] and
5067 /// [`reproject_search_index_after_tokenizer_upgrade`] already use). fix-2 ran
5068 /// them as two, and that window is not benign: a crash or a failed repair in
5069 /// between leaves the kind REGISTERED with the older rows still holding their
5070 /// `'up_to_date'` terminals and no vectors — and that state is SELF-SEALING,
5071 /// because `kind_is_vector_indexed` is then true, so every later write skips
5072 /// this path and therefore skips the repair, while readiness reads `ready` for
5073 /// rows nothing will ever embed. Only a manual re-apply of the projection
5074 /// recovers it. No marker table and no new recovery path: the two statements
5075 /// simply share a transaction.
5076 ///
5077 /// That transaction is opened on the writer connection just OUTSIDE the batch
5078 /// transaction: if the batch then fails, the workspace is left having enrolled
5079 /// a kind whose rows are correctly queued for the dense arm the registry does
5080 /// declare — inert, and self-healing on the next write or apply.
5081 fn enrol_batch_vector_kinds(
5082 &self,
5083 connection: &Connection,
5084 batch: &[PreparedWrite],
5085 ) -> Result<bool, EngineError> {
5086 if self.runtime_embedder.is_none() {
5087 return Ok(false);
5088 }
5089 // READ-ONLY pre-pass. Nothing is written here, so the overwhelmingly
5090 // common case — every kind in the batch already enrolled, or no vector
5091 // projection declared at all — still pays only the probes it paid before
5092 // and never takes a write lock.
5093 let mut to_enrol: Vec<&str> = Vec::new();
5094 for write in batch {
5095 // Only `Node` writes: edge bodies enrol `'edge_fact'` themselves in
5096 // `project_canonical_edge_row` (G11), unconditionally and already.
5097 let PreparedWrite::Node { kind, .. } = write else { continue };
5098 if to_enrol.contains(&kind.as_str()) {
5099 continue;
5100 }
5101 if self.vector_kind_needs_enrolment(connection, kind, RowKind::Leaf)? {
5102 to_enrol.push(kind);
5103 }
5104 }
5105 if to_enrol.is_empty() {
5106 return Ok(false);
5107 }
5108 self.enrol_and_unstrand(connection, &to_enrol)
5109 }
5110
5111 /// 0.8.20 Slice 20c — would enrolling `kind` be correct here? The READ-ONLY
5112 /// half of a late enrolment; [`Engine::enrol_and_unstrand`] is the write half.
5113 /// The live-embedder precondition is the CALLER's (see
5114 /// [`Engine::enrol_batch_vector_kinds`]).
5115 fn vector_kind_needs_enrolment(
5116 &self,
5117 connection: &Connection,
5118 kind: &str,
5119 row_kind: RowKind,
5120 ) -> Result<bool, EngineError> {
5121 // `graph` rows are lexically searchable but NEVER embedded
5122 // (`index_targets_for_row_kind`), so they must not drag their kind into
5123 // the vector registry — that would start embedding every other row of
5124 // that kind.
5125 if !index_targets_for_row_kind(row_kind).vector {
5126 return Ok(false);
5127 }
5128 // fix-2 (codex §9 [P1]) — the SAME restriction the declare-time door
5129 // applies, from the SAME predicate, so the two cannot drift: a kind the
5130 // vector writer cannot commit must never be enrolled, or the projection
5131 // worker wedges on it forever. See [`kind_is_vector_committable`].
5132 if !kind_is_vector_committable(kind) {
5133 return Ok(false);
5134 }
5135 if kind_is_vector_indexed(connection, kind)? {
5136 return Ok(false);
5137 }
5138 if !vector_projection_declared(connection).map_err(|_| EngineError::Storage)? {
5139 return Ok(false);
5140 }
5141 Ok(true)
5142 }
5143
5144 /// 0.8.20 Slice 20c fix-5 (codex §9 round 4 [P2]) — the WRITE half of a LATE
5145 /// enrolment: register the kinds AND repair the rows they strand, in ONE
5146 /// transaction. Returns `true` iff the repair re-enqueued anything (the caller
5147 /// must then `notify_new_work()`, since those rows are outside its batch).
5148 ///
5149 /// Split out so both write-path doors ([`Engine::enrol_batch_vector_kinds`]
5150 /// and the `#[doc(hidden)]` `write_canonical_row_with_kind_for_test`) share it
5151 /// verbatim, and so neither can register a kind without owing the repair.
5152 ///
5153 /// `register_vector_kind` is `INSERT OR IGNORE` and
5154 /// [`reenqueue_stranded_vector_rows`] is idempotent, so the read-only pre-pass
5155 /// that chose `kinds` does not need re-validating under the write lock: the
5156 /// worst a stale decision costs is one no-op `MIN` probe.
5157 fn enrol_and_unstrand(
5158 &self,
5159 connection: &Connection,
5160 kinds: &[&str],
5161 ) -> Result<bool, EngineError> {
5162 connection.execute_batch("BEGIN IMMEDIATE").map_err(|_| EngineError::Storage)?;
5163 let result = (|| -> rusqlite::Result<bool> {
5164 for kind in kinds {
5165 register_vector_kind(connection, kind)?;
5166 }
5167 reenqueue_stranded_vector_rows(connection)
5168 })();
5169 match result {
5170 Ok(enqueued) => {
5171 connection.execute_batch("COMMIT").map_err(|_| EngineError::Storage)?;
5172 Ok(enqueued)
5173 }
5174 Err(_) => {
5175 let _ = connection.execute_batch("ROLLBACK");
5176 Err(EngineError::Storage)
5177 }
5178 }
5179 }
5180
5181 fn write_inner(&self, batch: &[PreparedWrite]) -> Result<WriteReceipt, EngineError> {
5182 self.ensure_open()?;
5183
5184 if batch.is_empty() {
5185 return Err(EngineError::WriteValidation);
5186 }
5187
5188 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5189 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
5190 let plans = validate_batch(connection, batch)?;
5191 // 0.8.20 Slice 20c (R-20-DR remainder) — LATE ENROLMENT, before
5192 // `collect_projection_jobs` reads the vector-kind registry to decide
5193 // whether the dispatcher needs waking. See
5194 // `Engine::enrol_batch_vector_kinds`.
5195 //
5196 // fix-2 (codex §9 [P2]) — the flag says the enrolment ALSO un-stranded
5197 // rows outside this batch. Those rows are not in `projection_jobs` (that
5198 // only walks the batch), so it is OR-ed into `pending_projection` below:
5199 // `drain` is a passive barrier and never a trigger (C4 rider), so work
5200 // enqueued without a wake would sit until the next unrelated write.
5201 let unstranded = self.enrol_batch_vector_kinds(connection, batch)?;
5202 let projection_jobs = collect_projection_jobs(connection, batch)?;
5203 #[cfg(debug_assertions)]
5204 if self.force_next_commit_failure.swap(false, Ordering::SeqCst) {
5205 return Err(EngineError::Storage);
5206 }
5207 // One cursor per row. `base_cursor` is the last committed cursor;
5208 // row i in the batch gets cursor `base_cursor + i + 1`, and the
5209 // batch's final cursor (returned in WriteReceipt and stored as
5210 // the new `next_cursor`) is `base_cursor + batch.len()`. Sharing
5211 // one cursor across the batch previously collapsed every vec0
5212 // INSERT onto the same rowid via `INSERT OR IGNORE` — see
5213 // `dev/notes/0.7.0-engine-batch-vec0-collapse.md`.
5214 let base_cursor = self.next_cursor.load(Ordering::SeqCst);
5215 let increment = u64::try_from(batch.len()).unwrap_or(u64::MAX);
5216 let last_cursor = base_cursor.saturating_add(increment);
5217 // G11 (Slice 15) — edge bodies also need projection-runtime notification.
5218 // `collect_projection_jobs` only tracks Node items (pre-fetched for
5219 // cursor assignment); edge bodies update `_fathomdb_projection_state` in
5220 // `commit_batch` but need the scanner to wake up via `notify_new_work`.
5221 let has_edge_body_work =
5222 batch.iter().any(|w| matches!(w, PreparedWrite::Edge { body: Some(_), .. }));
5223 let pending_projection = !projection_jobs.is_empty() || has_edge_body_work || unstranded;
5224
5225 let dangling_edge_endpoints = match commit_batch(
5226 connection,
5227 batch,
5228 &plans,
5229 base_cursor,
5230 self.provenance_row_cap.load(Ordering::Relaxed),
5231 ) {
5232 Ok(count) => count,
5233 Err(err) => {
5234 self.emit_sqlite_internal_error(&err);
5235 return Err(EngineError::Storage);
5236 }
5237 };
5238 self.next_cursor.store(last_cursor, Ordering::SeqCst);
5239 if pending_projection {
5240 self.projection_runtime.notify_new_work();
5241 }
5242
5243 // G0 — surface the per-row cursors (1:1 with input order). Row i got
5244 // `base_cursor + i + 1`, matching the allocation in `commit_batch`.
5245 let row_cursors = (0..batch.len())
5246 .map(|i| base_cursor.saturating_add((i as u64).saturating_add(1)))
5247 .collect();
5248 Ok(WriteReceipt { cursor: last_cursor, row_cursors, dangling_edge_endpoints })
5249 }
5250
5251 /// G11 (Slice 15) — BYO-LLM ingest: spawn an external extraction harness
5252 /// speaking the `fathomdb.extract.v1` NDJSON-over-stdio protocol, send
5253 /// documents for extraction, and write the resulting entities
5254 /// (→ `canonical_nodes`) and fact-edges (→ `canonical_edges` with G11
5255 /// enrichment columns) to the store.
5256 ///
5257 /// `cmd` is argv (first element = program, rest = args). Documents are
5258 /// batched per the harness's `max_docs_per_request`. Entity `logical_id`
5259 /// is derived as `sha256("<type>:<name>")` (lowercase, hex-encoded) for
5260 /// stable cross-re-ingestion identity. Edge `logical_id` is derived as
5261 /// `sha256("<from_lid>:<to_lid>:<relation>")`. Both are consistent with
5262 /// G0 supersession: re-ingesting the same document yields the same ids,
5263 /// triggering tombstone-then-insert rather than accumulation.
5264 ///
5265 /// Returns [`EngineError::Extractor`] on protocol errors (bad handshake,
5266 /// subprocess spawn failure, JSON decode error). `no_facts` warnings from
5267 /// the harness are not errors and do not affect the receipt counts.
5268 pub fn ingest_with_extractor(
5269 &self,
5270 cmd: &[&str],
5271 documents: &[ExtractDocument],
5272 ) -> Result<IngestWithExtractorReceipt, EngineError> {
5273 // 0.8.6 Slice 5 (ADR-0.8.6): the spawn + hello/ready handshake +
5274 // request_id framing + error mapping now live in the reusable
5275 // `provider_session` transport seam, parameterized by `ProviderTask`.
5276 // `ingest_with_extractor` is the thin extract caller: it opens a session
5277 // for `ProviderTask::Extract` then runs the extract-specific payload
5278 // build + DB writes. The session owns child reaping via Drop.
5279 let mut session = self.provider_session(ProviderTask::Extract, cmd)?;
5280 self.run_extract_session(&mut session, documents)
5281 }
5282
5283 /// 0.8.6 Slice 5 (ADR-0.8.6) — open a provider session: spawn the caller
5284 /// subprocess, run the `hello`/`ready` handshake for `task`, and negotiate
5285 /// `supported_tasks`. The transport (NDJSON over stdio, the detached stdout
5286 /// drainer, the bounded-recv timeout, the `request_id` framing, and the
5287 /// catch-all `EngineError::Extractor` mapping) is identical across tasks;
5288 /// only the protocol string (`fathomdb.<task>.v1`) and the negotiated task
5289 /// name differ. For `ProviderTask::Extract` the wire is byte-identical to the
5290 /// pre-0.8.6 `fathomdb.extract.v1` path.
5291 fn provider_session(
5292 &self,
5293 task: ProviderTask,
5294 cmd: &[&str],
5295 ) -> Result<ProviderSession, EngineError> {
5296 let (program, args) = cmd.split_first().ok_or(EngineError::Extractor)?;
5297 let mut child = Command::new(program)
5298 .args(args)
5299 .stdin(Stdio::piped())
5300 .stdout(Stdio::piped())
5301 .stderr(Stdio::inherit())
5302 .spawn()
5303 .map_err(|_| EngineError::Extractor)?;
5304
5305 let child_stdin = match child.stdin.take() {
5306 Some(s) => s,
5307 None => {
5308 let _ = child.kill();
5309 let _ = child.wait();
5310 return Err(EngineError::Extractor);
5311 }
5312 };
5313 let child_stdout = match child.stdout.take() {
5314 Some(s) => s,
5315 None => {
5316 let _ = child.kill();
5317 let _ = child.wait();
5318 return Err(EngineError::Extractor);
5319 }
5320 };
5321
5322 // fix-35 [P1/P2]: drain stdout on a dedicated thread so (a) every read can
5323 // be bounded with a timeout — a hung harness can no longer block ingest
5324 // forever — and (b) the child's stdout pipe is drained continuously,
5325 // preventing a large-request deadlock (parent blocked writing stdin while
5326 // the child blocks writing a full stdout pipe). The handle is detached:
5327 // joining could hang if a misbehaving child holds stdout open past its
5328 // stdin EOF, so the session's `Drop` (child.kill()) is what guarantees
5329 // thread exit.
5330 let io_timeout = extractor_io_timeout();
5331 let (line_tx, line_rx) = mpsc::channel::<std::io::Result<String>>();
5332 thread::spawn(move || {
5333 let mut reader = BufReader::new(child_stdout);
5334 loop {
5335 let mut buf = String::new();
5336 match reader.read_line(&mut buf) {
5337 Ok(0) => break,
5338 Ok(_) => {
5339 if line_tx.send(Ok(buf)).is_err() {
5340 break;
5341 }
5342 }
5343 Err(e) => {
5344 let _ = line_tx.send(Err(e));
5345 break;
5346 }
5347 }
5348 }
5349 });
5350
5351 let mut session = ProviderSession {
5352 task,
5353 child,
5354 writer: std::io::BufWriter::new(child_stdin),
5355 line_rx,
5356 io_timeout,
5357 model: None,
5358 max_docs_per_request: 8,
5359 };
5360 // On any handshake/negotiation error the session is dropped here, which
5361 // reaps the child (Drop) — matching the prior outer kill/wait semantics.
5362 session.handshake()?;
5363 Ok(session)
5364 }
5365
5366 /// 0.8.6 Slice 5 — extract-specific driver over a `ProviderSession`. The
5367 /// payload build (documents → entities/edges) and DB writes are byte-identical
5368 /// to the pre-0.8.6 inner loop; only the spawn/handshake/framing moved into
5369 /// the shared session.
5370 fn run_extract_session(
5371 &self,
5372 session: &mut ProviderSession,
5373 documents: &[ExtractDocument],
5374 ) -> Result<IngestWithExtractorReceipt, EngineError> {
5375 let extractor_model_id = session.model.clone();
5376 let max_docs = session.max_docs_per_request;
5377
5378 // --- per-batch extract → write loop ---
5379 let mut nodes_written: u64 = 0;
5380 let mut edges_written: u64 = 0;
5381 let docs_processed = documents.len() as u64;
5382
5383 for (batch_idx, batch) in documents.chunks(max_docs).enumerate() {
5384 let request_id = format!("req-{batch_idx}");
5385 let docs_json: Vec<Value> = batch
5386 .iter()
5387 .map(|d| {
5388 serde_json::json!({
5389 "source_doc_id": d.source_doc_id,
5390 "body": d.body,
5391 })
5392 })
5393 .collect();
5394
5395 // Send the framed extract request and receive its matching `result`.
5396 // The session adds protocol/type/request_id and validates the
5397 // type=="result" + matching request_id envelope (fix-24 [P2]).
5398 let result = session
5399 .request(&request_id, vec![("documents".to_string(), Value::Array(docs_json))])?;
5400
5401 // R-20-E2 (0.8.20 Slice 5c, design §4 item 10) — every row this batch
5402 // produces takes its provenance from the CALLER's
5403 // `ExtractDocument.source_doc_id`, NEVER from the model's echo of that
5404 // field. The echo is attacker-/error-controlled: a harness that omits
5405 // it used to yield rows with NULL `source_id`, which no
5406 // `excise_source` call can reach — the model could make a row
5407 // permanently un-erasable simply by dropping a key.
5408 //
5409 // `resolve_provenance` therefore admits the echo only as a SELECTOR
5410 // among ids the caller already supplied in THIS batch, and never as a
5411 // value:
5412 //
5413 // * single-document batch — attribution is unambiguous, so the
5414 // caller's id is used and the echo is ignored outright;
5415 // * multi-document batch — the echo must name one of the batch's
5416 // caller-supplied ids (the caller's own copy of the string is
5417 // then stored). An absent or unrecognised echo is a protocol
5418 // violation and fails the ingest LOUDLY with
5419 // `EngineError::Extractor`, because the alternative — guessing an
5420 // attribution — would silently mis-file the row under a document
5421 // whose erasure would then not remove it.
5422 let batch_provenance = batch
5423 .iter()
5424 .map(|d| SourceId::new(d.source_doc_id.clone()))
5425 .collect::<Result<Vec<_>, _>>()?;
5426 let resolve_provenance = |echo: Option<&str>| -> Result<SourceId, EngineError> {
5427 if let [only] = batch_provenance.as_slice() {
5428 return Ok(only.clone());
5429 }
5430 let echo = echo.ok_or(EngineError::Extractor)?;
5431 batch_provenance
5432 .iter()
5433 .find(|caller_id| caller_id.as_str() == echo)
5434 .cloned()
5435 .ok_or(EngineError::Extractor)
5436 };
5437
5438 // --- map entities → PreparedWrite::Node with stable logical_id ---
5439 let entities =
5440 result.get("entities").and_then(|v| v.as_array()).cloned().unwrap_or_default();
5441 let raw_edges =
5442 result.get("edges").and_then(|v| v.as_array()).cloned().unwrap_or_default();
5443
5444 // R3 (SCHEMA-GATE-1): collect substituted_t_valid values from
5445 // temporal_fallback warnings. An edge whose t_valid matches one of
5446 // these values had its event time defaulted to created_at (not
5447 // text-grounded) and must be flagged so BFS can exclude it.
5448 //
5449 // TC-33: kept as RAW `Value`s here and normalised below, together
5450 // with the edge side, through the SAME function. See the
5451 // normalisation block for why that is load-bearing.
5452 let raw_fallback_dates: Vec<&Value> = result
5453 .get("warnings")
5454 .and_then(|v| v.as_array())
5455 .map(|ws| {
5456 ws.iter()
5457 .filter(|w| {
5458 w.get("kind").and_then(|k| k.as_str()) == Some("temporal_fallback")
5459 })
5460 .filter_map(|w| w.get("substituted_t_valid"))
5461 .collect()
5462 })
5463 .unwrap_or_default();
5464
5465 if !entities.is_empty() {
5466 let node_batch: Vec<PreparedWrite> = entities
5467 .iter()
5468 .map(|entity| -> Result<PreparedWrite, EngineError> {
5469 let name = entity.get("name").and_then(|v| v.as_str()).unwrap_or("");
5470 let kind = entity.get("type").and_then(|v| v.as_str()).unwrap_or("entity");
5471 // R-20-E2: caller-grounded, echo used only as a selector.
5472 let source_doc_id = resolve_provenance(
5473 entity.get("source_doc_id").and_then(|v| v.as_str()),
5474 )?;
5475 // fix-34 [P1]: derive_logical_id now rejects an empty name
5476 // or a ':' in kind — inputs that would collide distinct
5477 // entities onto one identity and silently drop one.
5478 let logical_id = derive_logical_id(kind, name)?;
5479 Ok(PreparedWrite::Node {
5480 kind: kind.to_string(),
5481 body: name.to_string(),
5482 source_id: source_doc_id,
5483 logical_id: Some(logical_id),
5484 state: InitialState::Active,
5485 reason: None,
5486 valid_from: None,
5487 valid_until: None,
5488 })
5489 })
5490 .collect::<Result<Vec<_>, _>>()?;
5491
5492 // fix-29/fix-34 [P2]: deduplicate within the batch by logical_id so
5493 // a harness that returns the same entity twice does not write a row
5494 // that immediately supersedes its sibling (shared with the edge arm).
5495 let node_batch = dedup_prepared_by_logical_id(node_batch);
5496
5497 // fix-23 [P2]: skip entities whose logical_id is already active
5498 // to avoid needless supersede churn on re-ingest.
5499 let ids: Vec<String> = node_batch
5500 .iter()
5501 .filter_map(|w| {
5502 if let PreparedWrite::Node { logical_id: Some(id), .. } = w {
5503 Some(id.clone())
5504 } else {
5505 None
5506 }
5507 })
5508 .collect();
5509 let existing: std::collections::HashSet<String> = self
5510 // Internal existence probe: STRICT view — this must see
5511 // exactly the rows the pre-slice code saw.
5512 .read_get_many(&ids, &ReadView::default())?
5513 .into_iter()
5514 .zip(ids)
5515 .filter_map(|(opt, id)| opt.map(|_| id))
5516 .collect();
5517 let new_nodes: Vec<PreparedWrite> = node_batch
5518 .into_iter()
5519 .filter(|w| {
5520 if let PreparedWrite::Node { logical_id: Some(id), .. } = w {
5521 !existing.contains(id)
5522 } else {
5523 true
5524 }
5525 })
5526 .collect();
5527 if !new_nodes.is_empty() {
5528 let n = new_nodes.len() as u64;
5529 self.write(&new_nodes)?;
5530 nodes_written = nodes_written.saturating_add(n);
5531 }
5532 }
5533
5534 // --- map edges → PreparedWrite::Edge with G11 columns ---
5535 if !raw_edges.is_empty() {
5536 // fix-33 [P1]: the protocol gives edges NO endpoint types —
5537 // `from_entity`/`to_entity` reference entities BY NAME (or alias).
5538 // Build a name+alias → (canonical name, type) index from the same
5539 // result's `entities[]` so each endpoint's logical_id matches the
5540 // node's. (Nodes derive id from the entity's real type; defaulting
5541 // the edge endpoint kind to "entity" orphaned every contract-faithful
5542 // edge from its nodes and tripped the G8 dangling probe.)
5543 //
5544 // Two passes so a canonical NAME always wins over a (different
5545 // entity's) ALIAS regardless of `entities[]` order: pass 1 inserts
5546 // all canonical names, pass 2 fills aliases only where no name
5547 // already claims that key. (Name↔name clashes remain first-wins —
5548 // contradictory input; no principled resolution exists.)
5549 let mut entity_index: std::collections::HashMap<String, (String, String)> =
5550 std::collections::HashMap::new();
5551 for entity in &entities {
5552 let name = entity.get("name").and_then(|v| v.as_str()).unwrap_or("");
5553 if name.is_empty() {
5554 continue;
5555 }
5556 let kind =
5557 entity.get("type").and_then(|v| v.as_str()).unwrap_or("entity").to_string();
5558 entity_index
5559 .entry(name.to_lowercase())
5560 .or_insert_with(|| (name.to_string(), kind));
5561 }
5562 for entity in &entities {
5563 let name = entity.get("name").and_then(|v| v.as_str()).unwrap_or("");
5564 if name.is_empty() {
5565 continue;
5566 }
5567 let kind =
5568 entity.get("type").and_then(|v| v.as_str()).unwrap_or("entity").to_string();
5569 if let Some(aliases) = entity.get("aliases").and_then(|v| v.as_array()) {
5570 for alias in aliases.iter().filter_map(|a| a.as_str()) {
5571 if !alias.is_empty() {
5572 entity_index
5573 .entry(alias.to_lowercase())
5574 .or_insert_with(|| (name.to_string(), kind.clone()));
5575 }
5576 }
5577 }
5578 }
5579
5580 // TC-33 — normalise EVERY extractor timestamp here, in ONE pass,
5581 // under ONE connection lock, BEFORE any edge is built. Both the
5582 // edge side (`t_valid`/`t_invalid`) and the temporal_fallback
5583 // warning side (`substituted_t_valid`) go through the SAME
5584 // function, and any value that cannot be normalised HARD-REJECTS
5585 // the whole ingest.
5586 //
5587 // **Normalising both sides is load-bearing, and nothing would
5588 // have caught it.** `temporal_fallback` is decided by comparing
5589 // the edge's t_valid against the warnings' substituted_t_valid.
5590 // That was a RAW BYTE-FOR-BYTE STRING MATCH with
5591 // `.unwrap_or(false)` on the miss path, and `substituted_t_valid`
5592 // is a FREE-FORM JSON key on the ELPS warnings envelope, not a
5593 // Rust struct field. So normalising only the edge side would
5594 // leave the set never matching, `.unwrap_or(false)` firing, and
5595 // EVERY fallback edge silently becoming a TRUSTED edge — with no
5596 // compile error anywhere. That flag is the only thing excluding
5597 // untrustworthy-time edges from graph BFS and graph seeding.
5598 //
5599 // Normalising both sides also FIXES a pre-existing brittleness:
5600 // `2025-03-20T09:30:00Z` and `2025-03-20T09:30:00+00:00` are the
5601 // same instant but MISS each other under a byte comparison. They
5602 // now compare equal as epochs.
5603 //
5604 // A malformed `substituted_t_valid` rejects rather than being
5605 // skipped: skipping it would leave the edge unflagged, i.e.
5606 // treated as TRUSTED — the same fail-open in a different place.
5607 //
5608 // The lock is taken and released HERE; `self.write(...)` below
5609 // re-acquires it, so no lock is held across the write.
5610 // (t_valid, t_invalid) epoch pair per edge, in `raw_edges` order.
5611 type EdgeTimes = Vec<(Option<i64>, Option<i64>)>;
5612 let (edge_times, fallback_epochs): (EdgeTimes, std::collections::HashSet<i64>) = {
5613 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5614 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
5615
5616 let mut times = Vec::with_capacity(raw_edges.len());
5617 for edge in &raw_edges {
5618 times.push((
5619 normalize_extractor_timestamp(
5620 connection,
5621 "t_valid",
5622 edge.get("t_valid"),
5623 )?,
5624 normalize_extractor_timestamp(
5625 connection,
5626 "t_invalid",
5627 edge.get("t_invalid"),
5628 )?,
5629 ));
5630 }
5631
5632 let mut epochs = std::collections::HashSet::new();
5633 for raw in &raw_fallback_dates {
5634 if let Some(epoch) = normalize_extractor_timestamp(
5635 connection,
5636 "substituted_t_valid",
5637 Some(raw),
5638 )? {
5639 epochs.insert(epoch);
5640 }
5641 }
5642 (times, epochs)
5643 };
5644
5645 let edge_batch: Vec<PreparedWrite> = raw_edges
5646 .iter()
5647 .zip(&edge_times)
5648 .map(|(edge, &(t_valid, t_invalid))| -> Result<PreparedWrite, EngineError> {
5649 let from_entity =
5650 edge.get("from_entity").and_then(|v| v.as_str()).unwrap_or("");
5651 let to_entity =
5652 edge.get("to_entity").and_then(|v| v.as_str()).unwrap_or("");
5653 let relation =
5654 edge.get("relation").and_then(|v| v.as_str()).unwrap_or("related_to");
5655 let body = edge.get("body").and_then(|v| v.as_str()).map(str::to_string);
5656 // TC-33: `t_valid`/`t_invalid` were normalised (and any
5657 // malformed or non-string value hard-rejected) in the
5658 // pass above; they arrive here as epoch seconds.
5659 // fix-26 [P2]: validate confidence is in [0.0, 1.0] at the
5660 // protocol boundary; reject out-of-range values.
5661 let confidence = match edge.get("confidence").and_then(|v| v.as_f64()) {
5662 Some(c) if !(0.0..=1.0).contains(&c) => {
5663 return Err(EngineError::Extractor);
5664 }
5665 c => c,
5666 };
5667 // R-20-E2: caller-grounded, echo used only as a selector.
5668 let source_doc_id =
5669 resolve_provenance(edge.get("source_doc_id").and_then(|v| v.as_str()))?;
5670
5671 // fix-33 [P1]: resolve each endpoint via the entities[]
5672 // index (by name or alias) → the entity's canonical
5673 // (name, type); fall back to kind "entity" only for a truly
5674 // unlisted name (synthesized dangling endpoints ARE listed,
5675 // so this is the defensive path). derive_logical_id (fix-34)
5676 // still rejects an empty name / ':' in kind.
5677 let (from_name, from_kind) = entity_index
5678 .get(&from_entity.to_lowercase())
5679 .cloned()
5680 .unwrap_or_else(|| (from_entity.to_string(), "entity".to_string()));
5681 let (to_name, to_kind) = entity_index
5682 .get(&to_entity.to_lowercase())
5683 .cloned()
5684 .unwrap_or_else(|| (to_entity.to_string(), "entity".to_string()));
5685 let from_lid = derive_logical_id(&from_kind, &from_name)?;
5686 let to_lid = derive_logical_id(&to_kind, &to_name)?;
5687 let edge_key = format!("{from_lid}:{to_lid}:{relation}");
5688 let edge_lid = derive_logical_id("edge", &edge_key)?;
5689
5690 // TC-33: BOTH sides are now epochs from the SAME
5691 // normalisation, so this compares instants rather than
5692 // byte strings.
5693 let is_temporal_fallback =
5694 t_valid.is_some_and(|tv| fallback_epochs.contains(&tv));
5695 Ok(PreparedWrite::Edge {
5696 kind: relation.to_string(),
5697 from: from_lid,
5698 to: to_lid,
5699 source_id: source_doc_id,
5700 logical_id: Some(edge_lid),
5701 body,
5702 t_valid,
5703 t_invalid,
5704 confidence,
5705 extractor_model_id: extractor_model_id.clone(),
5706 temporal_fallback: if is_temporal_fallback { Some(true) } else { None },
5707 })
5708 })
5709 .collect::<Result<Vec<_>, _>>()?;
5710 // fix-34 [P2]: dedup edges by logical_id, mirroring the node arm
5711 // (fix-29) — a duplicate edge in one harness response would
5712 // otherwise write a row that immediately supersedes its sibling.
5713 let edge_batch = dedup_prepared_by_logical_id(edge_batch);
5714 let n = edge_batch.len() as u64;
5715 self.write(&edge_batch)?;
5716 edges_written = edges_written.saturating_add(n);
5717 }
5718 }
5719
5720 // The `ProviderSession` (and its writer/child) is dropped by the caller
5721 // when `ingest_with_extractor` returns: Drop sends stdin EOF and reaps
5722 // the child, matching the prior explicit drop(writer)+kill/wait.
5723 Ok(IngestWithExtractorReceipt { nodes_written, edges_written, docs_processed })
5724 }
5725
5726 /// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — BYO-LLM CONSOLIDATION / RECENCY.
5727 ///
5728 /// The SECOND consumer of the one `provider_session` transport (ADR-0.8.6):
5729 /// consolidation reuses the exact NDJSON-over-stdio transport, hello/ready
5730 /// handshake, `supported_tasks` negotiation, `request_id` framing, and
5731 /// bounded-recv timeout — only the protocol string
5732 /// (`fathomdb.consolidate.v1`) and the task-specific payload differ. There is
5733 /// NO second transport and NO second handshake.
5734 ///
5735 /// For each `(subject, relation)` axis, FathomDB assembles a candidate
5736 /// cluster of competing active fact-edges DETERMINISTICALLY (CPU-only, no
5737 /// LLM), sends it to the caller-supplied harness, and applies the returned
5738 /// verdicts. **CALLER-SIDE BYO-LLM**: the harness is the caller's subprocess;
5739 /// the library never embeds or calls an LLM and makes NO network egress.
5740 ///
5741 /// **Load-bearing semantic (ADR-0.8.12 §2.1):** consolidation records
5742 /// supersession / recency METADATA only — `invalidate` sets `t_invalid`,
5743 /// `supersede`/`merge` marks the row superseded via the existing G0 tombstone
5744 /// column. Edge BODIES are NEVER rewritten and NO row is ever deleted (the
5745 /// 0.8.3 lesson: blind content-merge HURT accuracy). The original rows
5746 /// survive; the engine stays deterministic.
5747 ///
5748 /// Returns [`EngineError::Consolidator`] on any transport/handshake/protocol
5749 /// fault or a malformed / out-of-cluster verdict.
5750 pub fn consolidate_with_provider(
5751 &self,
5752 cmd: &[&str],
5753 axes: &[ConsolidateAxis],
5754 ) -> Result<ConsolidateReceipt, EngineError> {
5755 // Reuse the shared transport verbatim; remap its (Extractor-flavoured)
5756 // transport error to the task-specific Consolidator leaf.
5757 let mut session = self
5758 .provider_session(ProviderTask::Consolidate, cmd)
5759 .map_err(|_| EngineError::Consolidator)?;
5760 self.run_consolidate_session(&mut session, axes)
5761 }
5762
5763 /// 0.8.12 Slice 15 — consolidate-specific driver over a `ProviderSession`.
5764 /// Mirrors [`run_extract_session`][Engine::run_extract_session]: assemble the
5765 /// task payload, run the framed request over the shared session, apply the
5766 /// task-specific DB effect. The cluster assembly + verdict application are
5767 /// CPU-only/deterministic.
5768 fn run_consolidate_session(
5769 &self,
5770 session: &mut ProviderSession,
5771 axes: &[ConsolidateAxis],
5772 ) -> Result<ConsolidateReceipt, EngineError> {
5773 let mut receipt = ConsolidateReceipt::default();
5774
5775 for (i, axis) in axes.iter().enumerate() {
5776 // 1. Deterministically assemble the candidate cluster (CPU-only, no LLM).
5777 let cluster = self.assemble_consolidate_cluster(axis)?;
5778 if cluster.is_empty() {
5779 continue;
5780 }
5781 receipt.clusters_processed = receipt.clusters_processed.saturating_add(1);
5782 receipt.edges_examined = receipt.edges_examined.saturating_add(cluster.len() as u64);
5783
5784 // 2. Send the cluster; receive the verdict envelope. The session adds
5785 // protocol/type/request_id and validates type=="result" + matching
5786 // request_id. Any transport/protocol fault → Consolidator.
5787 let request_id = format!("req-{i}");
5788 // TC-33: storage and `ConsolidateCandidateEdge` are INTEGER epoch
5789 // seconds, but the harness WIRE is ISO-8601 — the same split as the
5790 // extractor boundary. Render on the way out; the verdict's
5791 // `t_invalid` is normalised back on the way in. Without this the
5792 // harness would receive epoch integers and (since the reference stub
5793 // echoes the winner's `t_valid` straight back as `t_invalid`) its
5794 // reply would be rejected by our own inbound normaliser.
5795 let edges_json: Vec<Value> = {
5796 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5797 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
5798 // TC-33 fix-1 backstop [DEFENSIVE — unreachable]. A stored
5799 // `Some(ts)` that fails to render must NOT become a silent
5800 // `null`: that is exactly the "still valid" resurrection vector.
5801 // With `reject_unrenderable_edge_epoch` guarding the write
5802 // boundary, no unrenderable epoch can reach storage — so this is
5803 // a hard-assert upholding that invariant STRUCTURALLY, not the
5804 // primary defence. `None` (unknown) still renders to JSON null;
5805 // only a NON-NULL stored epoch that fails to render is an error.
5806 let render = |field: &str, value: Option<i64>| -> Result<Value, EngineError> {
5807 match value {
5808 None => Ok(Value::Null),
5809 Some(ts) => match epoch_seconds_to_iso8601(connection, ts) {
5810 Some(iso) => Ok(Value::from(iso)),
5811 None => Err(EngineError::InvalidArgument {
5812 msg: format!(
5813 "INVARIANT VIOLATION (TC-33 fix-1): stored edge `{field}` = \
5814 {ts} is unrenderable to ISO-8601 and would have gone to the \
5815 consolidation wire as a silent null (\"still valid\"). The \
5816 write boundary should have made this unstorable."
5817 ),
5818 }),
5819 },
5820 }
5821 };
5822 cluster
5823 .iter()
5824 .map(|e| {
5825 Ok::<Value, EngineError>(serde_json::json!({
5826 "edge_ref": e.edge_ref,
5827 "body": e.body,
5828 "t_valid": render("t_valid", e.t_valid)?,
5829 "t_invalid": render("t_invalid", e.t_invalid)?,
5830 "confidence": e.confidence,
5831 "source_doc_id": e.source_doc_id,
5832 "extractor_model_id": e.extractor_model_id,
5833 }))
5834 })
5835 .collect::<Result<Vec<Value>, EngineError>>()?
5836 };
5837 let cluster_json = serde_json::json!({
5838 "subject": axis.subject_logical_id,
5839 "relation": axis.relation,
5840 "edges": edges_json,
5841 });
5842 let result = session
5843 .request(&request_id, vec![("cluster".to_string(), cluster_json)])
5844 .map_err(|_| EngineError::Consolidator)?;
5845
5846 // 3. Apply the verdicts (metadata-only; original rows + bodies survive).
5847 let verdicts = result
5848 .get("verdicts")
5849 .and_then(|v| v.as_array())
5850 .ok_or(EngineError::Consolidator)?
5851 .clone();
5852 self.apply_consolidate_verdicts(&cluster, &verdicts, &mut receipt)?;
5853 }
5854
5855 Ok(receipt)
5856 }
5857
5858 /// 0.8.12 Slice 15 — assemble the competing fact-edge cluster for one
5859 /// `(subject, relation)` axis, deterministically, from active `canonical_edges`
5860 /// (`from_id = subject AND kind = relation AND superseded_at IS NULL`), ordered
5861 /// by `write_cursor` (stable insertion order). CPU-only; no network, no LLM.
5862 fn assemble_consolidate_cluster(
5863 &self,
5864 axis: &ConsolidateAxis,
5865 ) -> Result<Vec<ConsolidateCandidateEdge>, EngineError> {
5866 self.ensure_open()?;
5867 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5868 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
5869 let mut stmt = connection
5870 .prepare(
5871 "SELECT logical_id, body, t_valid, t_invalid, confidence, source_id, \
5872 extractor_model_id \
5873 FROM canonical_edges \
5874 WHERE from_id = ?1 AND kind = ?2 AND superseded_at IS NULL \
5875 ORDER BY write_cursor",
5876 )
5877 .map_err(|_| EngineError::Storage)?;
5878 let rows = stmt
5879 .query_map(params![axis.subject_logical_id, axis.relation], |r| {
5880 Ok(ConsolidateCandidateEdge {
5881 edge_ref: r.get::<_, Option<String>>(0)?.unwrap_or_default(),
5882 body: r.get(1)?,
5883 t_valid: r.get(2)?,
5884 t_invalid: r.get(3)?,
5885 confidence: r.get(4)?,
5886 source_doc_id: r.get(5)?,
5887 extractor_model_id: r.get(6)?,
5888 })
5889 })
5890 .map_err(|_| EngineError::Storage)?;
5891 let out: rusqlite::Result<Vec<ConsolidateCandidateEdge>> = rows.collect();
5892 // Skip any edge with a NULL/empty logical_id (no stable ref to round-trip).
5893 Ok(out
5894 .map_err(|_| EngineError::Storage)?
5895 .into_iter()
5896 .filter(|e| !e.edge_ref.is_empty())
5897 .collect())
5898 }
5899
5900 /// 0.8.12 Slice 15 — apply the harness verdicts as METADATA-ONLY transitions
5901 /// (ADR-0.8.12 §2.1). NEVER rewrites a body, NEVER deletes a row. A verdict
5902 /// referencing an edge not in the presented cluster, or an unknown verdict
5903 /// kind, is a protocol fault → [`EngineError::Consolidator`].
5904 fn apply_consolidate_verdicts(
5905 &self,
5906 cluster: &[ConsolidateCandidateEdge],
5907 verdicts: &[Value],
5908 receipt: &mut ConsolidateReceipt,
5909 ) -> Result<(), EngineError> {
5910 let known: std::collections::HashSet<&str> =
5911 cluster.iter().map(|e| e.edge_ref.as_str()).collect();
5912 // fix-1 [P2] bijection: the verdict set must cover the presented cluster
5913 // EXACTLY — every presented edge ruled on, none ruled on twice.
5914 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
5915
5916 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5917 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
5918 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
5919
5920 for v in verdicts {
5921 let edge_ref =
5922 v.get("edge_ref").and_then(|x| x.as_str()).ok_or(EngineError::Consolidator)?;
5923 // The harness may only rule on edges FathomDB presented in the cluster.
5924 if !known.contains(edge_ref) {
5925 return Err(EngineError::Consolidator);
5926 }
5927 // fix-1 [P2]: a repeated edge_ref is a protocol fault (not a bijection).
5928 if !seen.insert(edge_ref) {
5929 return Err(EngineError::Consolidator);
5930 }
5931 let verdict =
5932 v.get("verdict").and_then(|x| x.as_str()).ok_or(EngineError::Consolidator)?;
5933 // Look up the active edge's projection cursor BEFORE any UPDATE so a
5934 // supersede (which clears `superseded_at IS NULL`) can still find it.
5935 let active_cursor = Self::active_edge_write_cursor(&tx, edge_ref)?;
5936 match verdict {
5937 "keep" => {
5938 receipt.edges_kept = receipt.edges_kept.saturating_add(1);
5939 }
5940 "invalidate" => {
5941 // Recency metadata: set t_invalid; the row and its body are
5942 // left intact (this is NOT a destructive content rewrite).
5943 //
5944 // TC-33: the CONSOLIDATION harness is the same class of
5945 // BYO-LLM boundary as the extractor, so it carries ISO-8601
5946 // on the wire and is normalised here with the SAME hard
5947 // rejection. Previously the raw string went straight into the
5948 // UPDATE with no validation whatsoever.
5949 // fix-3 [P2]: consolidation is a BYO-LLM PROVIDER boundary, so
5950 // a malformed / non-string `t_invalid` is a PROVIDER protocol
5951 // fault → `Consolidator`, NOT the extractor/user `InvalidArgument`
5952 // that `normalize_extractor_timestamp` emits. Remap it to match
5953 // the two sibling failure modes on this same value (missing key
5954 // and null/unparseable-to-None, both `Consolidator`). Consistent,
5955 // not a diagnostic loss: `Consolidator` is a unit variant and the
5956 // adjacent `.ok_or(EngineError::Consolidator)` cases already
5957 // discard any message.
5958 let ts = normalize_extractor_timestamp(
5959 &tx,
5960 "t_invalid",
5961 Some(v.get("t_invalid").ok_or(EngineError::Consolidator)?),
5962 )
5963 .map_err(|_| EngineError::Consolidator)?
5964 .ok_or(EngineError::Consolidator)?;
5965 tx.execute(
5966 "UPDATE canonical_edges SET t_invalid = ?1 \
5967 WHERE logical_id = ?2 AND superseded_at IS NULL",
5968 params![ts, edge_ref],
5969 )
5970 .map_err(|_| EngineError::Storage)?;
5971 // fix-1 [P1]: prune the STATIC projection shadow rows so the
5972 // consolidated-away edge stops surfacing in FTS/vector — but
5973 // ONLY when the edge is ended as of the engine's "now",
5974 // mirroring the graph-traversal filter `edge_validity_sql`.
5975 // A future-dated t_invalid keeps the edge valid ⇒ keep the
5976 // projection. NON-DESTRUCTIVE: the canonical_edges row + body
5977 // survive (ADR-0.8.12 §2.1).
5978 //
5979 // TC-33: this used to be `SELECT datetime(?1) <= datetime('now')`
5980 // — an inline clock, AND a misleading error class: junk made
5981 // the SELECT yield SQL NULL, so `r.get::<bool>` failed as
5982 // `EngineError::Storage`. Both timestamps are integers now, so
5983 // the comparison is plain Rust against the bound `:now` seam.
5984 if let Some(cursor) = active_cursor {
5985 let ended = ts <= current_epoch_seconds();
5986 if ended {
5987 // fix-2 [P2]: KEEP the projection terminal row. The
5988 // canonical_edges row stays NON-superseded (invalidate
5989 // is metadata-only), and `database_has_pending_projection_work`
5990 // flags any non-superseded edge that has a body but no
5991 // terminal as pending; since `next_pending_projection_jobs`
5992 // only scans cursors ABOVE the stored projection cursor, an
5993 // already-projected invalidated edge would never be requeued
5994 // and `drain()`/`wait_for_idle` would hang forever. Dropping
5995 // the FTS/vec shadows (below) hides it from active retrieval;
5996 // retaining the terminal keeps the scheduler idle.
5997 Self::prune_edge_projection_shadows(&tx, cursor, true)?;
5998 }
5999 }
6000 receipt.edges_invalidated = receipt.edges_invalidated.saturating_add(1);
6001 }
6002 // `merge` maps cleanly to supersede + metadata (ADR-0.8.12 §3):
6003 // the loser is marked superseded; the winner ("by"/"into") is the
6004 // surviving active row. No body is merged.
6005 "supersede" | "merge" => {
6006 // Mark superseded via the existing G0 tombstone column; the row
6007 // survives (invalidate-not-delete). Use a fresh monotonic cursor.
6008 let cursor = self.next_cursor.fetch_add(1, Ordering::SeqCst).saturating_add(1);
6009 tx.execute(
6010 "UPDATE canonical_edges SET superseded_at = ?1 \
6011 WHERE logical_id = ?2 AND superseded_at IS NULL",
6012 params![cursor, edge_ref],
6013 )
6014 .map_err(|_| EngineError::Storage)?;
6015 // fix-1 [P1]: a superseded edge is unconditionally out of the
6016 // active set (graph traversal filters `superseded_at IS NULL`),
6017 // so prune its FTS/vector projection shadow rows to match.
6018 if let Some(active_cursor) = active_cursor {
6019 // A superseded row is excluded from the pending-work check
6020 // (`superseded_at IS NOT NULL`), so dropping its terminal too
6021 // is safe (matches the excise pattern) and cannot phantom-pend.
6022 Self::prune_edge_projection_shadows(&tx, active_cursor, false)?;
6023 }
6024 receipt.edges_superseded = receipt.edges_superseded.saturating_add(1);
6025 }
6026 _ => return Err(EngineError::Consolidator),
6027 }
6028 }
6029
6030 // fix-1 [P2]: bijection completeness — every presented cluster edge must
6031 // have received exactly one verdict.
6032 if seen.len() != known.len() {
6033 return Err(EngineError::Consolidator);
6034 }
6035
6036 tx.commit().map_err(|_| EngineError::Storage)?;
6037 Ok(())
6038 }
6039
6040 /// fix-1 [P1] — the active (non-superseded) row's projection `write_cursor`
6041 /// for a fact-edge `logical_id`, or `None` if there is no active row. The
6042 /// cursor keys the STATIC projection shadow rows (FTS `search_index_edges`,
6043 /// vec0 `vector_default` by rowid, `_fathomdb_vector_rows`,
6044 /// `_fathomdb_projection_terminal`).
6045 fn active_edge_write_cursor(
6046 tx: &rusqlite::Transaction<'_>,
6047 edge_ref: &str,
6048 ) -> Result<Option<i64>, EngineError> {
6049 tx.query_row(
6050 "SELECT write_cursor FROM canonical_edges \
6051 WHERE logical_id = ?1 AND superseded_at IS NULL",
6052 params![edge_ref],
6053 |r| r.get::<_, i64>(0),
6054 )
6055 .optional()
6056 .map_err(|_| EngineError::Storage)
6057 }
6058
6059 /// fix-1 [P1] — prune the STATIC projection shadow rows for a canonical
6060 /// row's `write_cursor` so a consolidated-away edge stops surfacing in
6061 /// FTS/vector retrieval. Mirrors the excision pattern at
6062 /// `excise_source_inner` (invalidate-not-delete: the canonical row + body
6063 /// are NEVER touched here).
6064 ///
6065 /// `keep_terminal` retains the `_fathomdb_projection_terminal` marker — set it
6066 /// when the canonical row stays NON-superseded (an `invalidate` verdict), so the
6067 /// projection scheduler still treats the cursor as done (fix-2 [P2]); clear it
6068 /// when the row is superseded (excluded from the pending-work scan anyway).
6069 ///
6070 /// FIXED (0.8.12 Slice A, R-CON-2 named default-ON blocker; Slice-20 codex
6071 /// §9 [P2]): a full `rebuild_projections` re-projects every non-superseded
6072 /// edge with a body from `canonical_edges` — this used to re-materialise an
6073 /// invalidated edge's FTS/vec shadows even though graph traversal excludes
6074 /// it via the `t_invalid > now` filter. The FTS rebuild SELECT
6075 /// (`rebuild_shadow_state`), the vec projection queue
6076 /// (`next_pending_projection_jobs`), and the pending-work probe
6077 /// (`database_has_pending_projection_work`) now all carry the same
6078 /// `edge_validity_sql` filter as graph traversal (TC-33: INTEGER compare
6079 /// against the bound `:now`, formerly `datetime(t_invalid) > datetime('now')`),
6080 /// so a rebuild is durable across the recency exclusion.
6081 fn prune_edge_projection_shadows(
6082 tx: &rusqlite::Transaction<'_>,
6083 cursor: i64,
6084 keep_terminal: bool,
6085 ) -> Result<(), EngineError> {
6086 tx.execute("DELETE FROM search_index_edges WHERE write_cursor = ?1", [cursor])
6087 .map_err(|_| EngineError::Storage)?;
6088 // vec0 rowid is the canonical row's write_cursor. TC-76: via the one
6089 // vec0-delete primitive ([`delete_vector_partition_row`]).
6090 delete_vector_partition_row(tx, cursor).map_err(|_| EngineError::Storage)?;
6091 tx.execute("DELETE FROM _fathomdb_vector_rows WHERE write_cursor = ?1", [cursor])
6092 .map_err(|_| EngineError::Storage)?;
6093 if !keep_terminal {
6094 tx.execute(
6095 "DELETE FROM _fathomdb_projection_terminal WHERE write_cursor = ?1",
6096 [cursor],
6097 )
6098 .map_err(|_| EngineError::Storage)?;
6099 }
6100 Ok(())
6101 }
6102
6103 pub fn search(&self, query: &str) -> Result<SearchResult, EngineError> {
6104 self.search_filtered(query, None)
6105 }
6106
6107 /// 0.8.20 Slice 15b fix-2 (R-20-NV / R-20-RV) — `search` under an explicit
6108 /// [`ReadView`], the escape hatch matching the one the five read verbs got in
6109 /// Slice 10b. `search(query)` is exactly `search_view(query, &ReadView::default())`.
6110 ///
6111 /// **Scope: the VALIDITY axis only.** `include_out_of_window` and
6112 /// `valid_as_of` are honoured; the EXISTENCE flags (`include_superseded`,
6113 /// `include_inactive`) are **refused** with
6114 /// [`EngineError::InvalidArgument`] rather than silently ignored. Relaxing
6115 /// `superseded_at IS NULL` on a retrieval path would resurrect the stale-body
6116 /// leak the Slice-15 fix-1 review closed, and search hydrates from projection
6117 /// indexes (`search_index`, `vector_default`) that are not version-complete —
6118 /// so "include superseded" has no truthful answer here. Refusing says that;
6119 /// ignoring would be the dead surface this fix exists to remove.
6120 ///
6121 /// Governed surface: PROPOSED / NOT SIGNED (0.8.20 Slice 15b fix-2).
6122 pub fn search_view(&self, query: &str, view: &ReadView) -> Result<SearchResult, EngineError> {
6123 self.search_reranked_with_explain(query, None, 0, false, 0.3, 0, false, *view)
6124 }
6125
6126 /// 0.8.20 Slice 15b fix-2 (R-20-NV / R-20-RV) — the FULL-arity view entry
6127 /// point: [`search_reranked`][Engine::search_reranked] /
6128 /// [`search_explained`][Engine::search_explained] under an explicit
6129 /// [`ReadView`]. This is what the Python and TypeScript `search(..., view=)`
6130 /// bindings call, so a caller can combine a content filter, the CE knobs and
6131 /// a validity view in one query — passing `view` must not silently disable
6132 /// the filter, and passing a filter must not silently disable `view`.
6133 ///
6134 /// `search_reranked(q, f, d, g, a, p)` is exactly
6135 /// `search_reranked_view(q, f, d, g, a, p, false, &ReadView::default())`.
6136 ///
6137 /// Validity axis only; existence flags are refused. See
6138 /// [`search_view`][Engine::search_view].
6139 ///
6140 /// Governed surface: PROPOSED / NOT SIGNED (0.8.20 Slice 15b fix-2).
6141 #[allow(clippy::too_many_arguments)] // mirrors search_explained + the view
6142 pub fn search_reranked_view(
6143 &self,
6144 query: &str,
6145 filter: Option<SearchFilter>,
6146 rerank_depth: usize,
6147 use_graph_arm: bool,
6148 alpha: f64,
6149 pool_n: usize,
6150 explain: bool,
6151 view: &ReadView,
6152 ) -> Result<SearchResult, EngineError> {
6153 self.search_reranked_with_explain(
6154 query,
6155 filter,
6156 rerank_depth,
6157 use_graph_arm,
6158 alpha,
6159 pool_n,
6160 explain,
6161 *view,
6162 )
6163 }
6164
6165 /// G10 — hybrid `search` with an optional closed [`SearchFilter`]. `None`
6166 /// (or an all-`None` filter) is the unfiltered path whose phase-1 SQL is
6167 /// byte-identical to 0.7.2. The filter prunes the vector branch in the
6168 /// single phase-1 candidates statement and constrains the text branch by the
6169 /// same metadata. Ranking is the unconditional G9 RRF fusion.
6170 pub fn search_filtered(
6171 &self,
6172 query: &str,
6173 filter: Option<SearchFilter>,
6174 ) -> Result<SearchResult, EngineError> {
6175 // 0.8.11 Slice 40 (R-FIL-2): re-express the shipped G10 `SearchFilter`
6176 // sugar through the unified `Filter` type, then lower back to the vec0
6177 // backend's `SearchFilter` (D4). The round-trip is lossless +
6178 // canonical-order-preserving, so the produced phase-1 SQL stays
6179 // byte-identical to 0.7.2 on the `None`/all-`None` path. `SearchFilter`
6180 // never carries a `Json` term, so `to_search_filter` never rejects here.
6181 //
6182 // 0.8.20 Slice 15e — the unified `Filter`/`FilterTerm` grammar does not yet
6183 // carry `filterable`-attribute terms (a later slice adds that surface), so
6184 // the round-trip would drop `SearchFilter.attributes`. Carry them across
6185 // explicitly: they already route pre-KNN through `vector_filter_clause`.
6186 let lowered = filter
6187 .map(|sf| {
6188 let attributes = sf.attributes.clone();
6189 Filter::from(&sf).to_search_filter().map(|mut lo| {
6190 lo.attributes = attributes;
6191 lo
6192 })
6193 })
6194 .transpose()?;
6195 // FIX-6: delegate to search_reranked(depth=0, use_graph_arm=false) to eliminate the
6196 // ~26-line duplicate body that would otherwise drift with search_reranked.
6197 // 0.8.5: depth=0 is inert, so the α/pool_n defaults (0.3, 0) never reach the blend.
6198 self.search_reranked(query, lowered, 0, false, 0.3, 0)
6199 }
6200
6201 /// 0.8.11 Slice 40 (#17) — unified-`Filter` entry point for the vec0 search
6202 /// backend. Lowers the metadata subset to the indexed pre-KNN `WHERE` and
6203 /// **typed-rejects** a [`FilterTerm::Json`] term with
6204 /// [`EngineError::InvalidFilter`] (D3 no-demotion guarantee). This is the
6205 /// unified surface the 0.8.15 router `constraints` block reasons over; the
6206 /// shipped [`Engine::search_filtered`]`(query, Option<SearchFilter>)` stays
6207 /// as sugar over the same path.
6208 pub fn search_filter(&self, query: &str, filter: &Filter) -> Result<SearchResult, EngineError> {
6209 let sf = filter.to_search_filter()?;
6210 self.search_reranked(query, Some(sf), 0, false, 0.3, 0)
6211 }
6212
6213 /// 0.8.1 Slice 10 (R1) / Slice 30 (R3) — `search_reranked`: hybrid search
6214 /// with optional CE reranking and optional graph-BFS third arm. `rerank_depth
6215 /// = 0` is the identity (soft-fallback) path, byte-identical to
6216 /// [`search_filtered`][Engine::search_filtered]. `rerank_depth = N > 0`
6217 /// applies the cross-encoder over the top-N fused hits (when the
6218 /// `default-reranker` feature is enabled and the model is loaded); without the
6219 /// model, the call falls back to the fused order.
6220 ///
6221 /// `use_graph_arm = false` (the default) produces byte-identical results to
6222 /// the pre-Slice-30 two-arm pipeline. `use_graph_arm = true` seeds a BFS over
6223 /// temporal fact-edges from the top-10 fused hits and fuses the reachable
6224 /// nodes as a third RRF arm.
6225 ///
6226 /// Governed surface: re-exported from `fathomdb` facade.
6227 pub fn search_reranked(
6228 &self,
6229 query: &str,
6230 filter: Option<SearchFilter>,
6231 rerank_depth: usize,
6232 use_graph_arm: bool,
6233 alpha: f64,
6234 pool_n: usize,
6235 ) -> Result<SearchResult, EngineError> {
6236 // explain=false → `SearchResult.explanation == None`, byte-identical results.
6237 self.search_reranked_with_explain(
6238 query,
6239 filter,
6240 rerank_depth,
6241 use_graph_arm,
6242 alpha,
6243 pool_n,
6244 false,
6245 ReadView::default(),
6246 )
6247 }
6248
6249 /// 0.8.8 EXP-OBS (Slice 5) — `search_explained`: the opt-in `explain=true`
6250 /// surface. Identical retrieval to [`search_reranked`][Engine::search_reranked]
6251 /// (same fused/CE ranking, same `results`), additionally returning a
6252 /// [`Explanation`] sidecar on `SearchResult.explanation` with per-hit arm
6253 /// provenance + score breakdown + a query-level [`QueryTrace`]. The default
6254 /// `search`/`search_filtered`/`search_reranked` paths are unaffected and stay
6255 /// byte-identical (R-OBS-2).
6256 ///
6257 /// Governed surface: re-exported from `fathomdb` facade.
6258 pub fn search_explained(
6259 &self,
6260 query: &str,
6261 filter: Option<SearchFilter>,
6262 rerank_depth: usize,
6263 use_graph_arm: bool,
6264 alpha: f64,
6265 pool_n: usize,
6266 ) -> Result<SearchResult, EngineError> {
6267 self.search_reranked_with_explain(
6268 query,
6269 filter,
6270 rerank_depth,
6271 use_graph_arm,
6272 alpha,
6273 pool_n,
6274 true,
6275 ReadView::default(),
6276 )
6277 }
6278
6279 /// 0.8.18 Slice 5 (#5 vector-equivalence probe, R-VEQ-4) — the explicit
6280 /// **text-only / FTS-only** search path. It does NOT embed the query and does
6281 /// NOT route through the vector-dependent choke point
6282 /// [`search_inner_with_stats`][Engine::search_inner_with_stats], so it NEVER
6283 /// raises [`EngineError::VectorEquivalenceMismatch`] and stays serviceable when
6284 /// the engine opened in the degraded `dense_disabled` state (the D2 "keep FTS
6285 /// servable" contract; codex R2 U1-2). Results come from the node-body FTS
6286 /// branch only — no vector recall, no CE rerank, no graph arm. Available
6287 /// regardless of degraded state; when dense is healthy it is simply a
6288 /// text-only view of the same corpus.
6289 ///
6290 /// Governed surface: re-exported from the `fathomdb` facade + Py/TS bindings.
6291 pub fn search_text_only(&self, query: &str) -> Result<SearchResult, EngineError> {
6292 self.search_text_only_view(query, &ReadView::default())
6293 }
6294
6295 /// 0.8.20 Slice 15b fix-2 (R-20-NV / R-20-RV) — [`search_text_only`][Engine::search_text_only]
6296 /// under an explicit [`ReadView`]. Same validity-axis-only scope, and the same
6297 /// typed refusal of the existence flags, as [`search_view`][Engine::search_view].
6298 ///
6299 /// Governed surface: PROPOSED / NOT SIGNED (0.8.20 Slice 15b fix-2).
6300 pub fn search_text_only_view(
6301 &self,
6302 query: &str,
6303 view: &ReadView,
6304 ) -> Result<SearchResult, EngineError> {
6305 self.ensure_open()?;
6306 view.reject_existence_relaxation_on_search()?;
6307 if query.trim().is_empty() {
6308 return Err(EngineError::WriteValidation);
6309 }
6310 let compiled = compile_text_query(query);
6311 let search_limit = self
6312 .projection_runtime
6313 .shared
6314 .search_limit_override
6315 .load(Ordering::SeqCst)
6316 .max(SEARCH_RERANK_LIMIT);
6317 let (response_tx, response_rx) = mpsc::sync_channel::<ReaderResponse>(1);
6318 // `query_vector = None` ⇒ `read_search_in_tx` skips the vector branch
6319 // entirely (no embed, no phase-1 bit-KNN, no phase-2 L2) and returns the
6320 // text/FTS branch — exactly the un-embedded fallback the hybrid path already
6321 // takes on an embed miss.
6322 let request = ReaderRequest::Search {
6323 compiled,
6324 query_vector: None,
6325 query_vector_bin: None,
6326 search_limit,
6327 filter: None,
6328 recency_enabled: false,
6329 importance_enabled: false,
6330 vector_stage_only: false,
6331 raw_query: Box::from(query),
6332 rerank_depth: 0,
6333 use_graph_arm: false,
6334 alpha: 0.3,
6335 pool_n: 0,
6336 explain: false,
6337 view: *view,
6338 respond: response_tx,
6339 };
6340 if self.reader_pool.dispatch(request).is_err() {
6341 return Err(EngineError::Closing);
6342 }
6343 let search_result = response_rx.recv().map_err(|_| EngineError::Storage)?;
6344 let (cursor, soft_fallback, results, _graph_stats, explanation) = match search_result {
6345 Ok(result) => result,
6346 // fix-3 (codex §9 [P2]) — carry the reader-snapshot validation verdict
6347 // through: an undeclared `filterable` attribute is the EXISTING typed
6348 // `InvalidFilter`, never collapsed to `Storage`. (This path takes
6349 // `filter = None`, so it never fires here, but the match stays total.)
6350 Err(SearchReaderError::InvalidFilter(reason)) => {
6351 return Err(EngineError::InvalidFilter { reason });
6352 }
6353 Err(SearchReaderError::Sqlite(err)) => {
6354 self.emit_sqlite_internal_error(&err);
6355 return Err(EngineError::Storage);
6356 }
6357 };
6358 Ok(SearchResult { projection_cursor: cursor, soft_fallback, results, explanation })
6359 }
6360
6361 /// 0.8.18 Slice 5 (R-VEQ-6) — degraded-open observability accessor. `true` iff
6362 /// the open-time #5 self-check found a vector-equivalence divergence and every
6363 /// vector-dependent arm is refusing. Mirrors `OpenReport.dense_disabled`; read
6364 /// lock-free.
6365 #[must_use]
6366 pub fn dense_disabled(&self) -> bool {
6367 self.dense_disabled.load(Ordering::Acquire)
6368 }
6369
6370 /// 0.8.18 Slice 5 (R-VEQ-6) — the human-readable reason for the degraded state
6371 /// (which representation tripped), or `None` when dense is healthy.
6372 #[must_use]
6373 pub fn dense_disabled_reason(&self) -> Option<String> {
6374 self.dense_disabled_reason.lock().ok().and_then(|g| g.clone())
6375 }
6376
6377 /// 0.8.18 Slice 5 (R-VEQ-6) — telemetry counter: number of query-time
6378 /// vector-dependent-arm refusals raised because the engine opened degraded.
6379 /// Observable pre/post-query.
6380 #[must_use]
6381 pub fn vector_equivalence_refusal_count(&self) -> u64 {
6382 self.vector_equivalence_refusals.load(Ordering::Relaxed)
6383 }
6384
6385 /// Shared event-wrapped body for [`search_reranked`][Engine::search_reranked]
6386 /// (`explain=false`) and [`search_explained`][Engine::search_explained]
6387 /// (`explain=true`). Keeps the Started/Finished/Failed lifecycle emissions +
6388 /// slow detection in one place.
6389 #[allow(clippy::too_many_arguments)] // mirrors search_reranked + the explain flag
6390 fn search_reranked_with_explain(
6391 &self,
6392 query: &str,
6393 filter: Option<SearchFilter>,
6394 rerank_depth: usize,
6395 use_graph_arm: bool,
6396 alpha: f64,
6397 pool_n: usize,
6398 explain: bool,
6399 view: ReadView,
6400 ) -> Result<SearchResult, EngineError> {
6401 // fix-2: refuse an existence-relaxing view BEFORE any work (and before the
6402 // Started event), so the refusal is a pure argument error rather than a
6403 // half-emitted query lifecycle.
6404 view.reject_existence_relaxation_on_search()?;
6405 self.emit_event(lifecycle::Phase::Started, lifecycle::EventCategory::Search, None);
6406 let started = Instant::now();
6407 let outcome = self.search_inner(
6408 query,
6409 filter,
6410 rerank_depth,
6411 use_graph_arm,
6412 alpha,
6413 pool_n,
6414 explain,
6415 view,
6416 );
6417 self.detect_slow(started, lifecycle::EventCategory::Search);
6418 match outcome {
6419 Ok(result) => {
6420 self.counters.record_query();
6421 // 0.8.8 Slice 15 (OPP-9) — opt-in telemetry capture. No-op + no
6422 // allocation when telemetry is OFF (the default).
6423 self.capture_telemetry(query, &result);
6424 self.emit_event(lifecycle::Phase::Finished, lifecycle::EventCategory::Search, None);
6425 Ok(result)
6426 }
6427 Err(err) => {
6428 let code = err.stable_code();
6429 self.counters.record_error(code);
6430 self.emit_event(
6431 lifecycle::Phase::Failed,
6432 lifecycle::EventCategory::Search,
6433 Some(code),
6434 );
6435 self.emit_event(
6436 lifecycle::Phase::Failed,
6437 lifecycle::EventCategory::Error,
6438 Some(code),
6439 );
6440 Err(err)
6441 }
6442 }
6443 }
6444
6445 /// 0.8.8 Slice 15 (OPP-9) — enable opt-in telemetry capture to a local JSONL
6446 /// `sink_path` (append-only). Off by default; once enabled, each `search`
6447 /// records a query→result event and `record_feedback` appends agent labels.
6448 /// Local file only — no network/egress. `query_id` + `ts_monotonic_ms` are
6449 /// reset deterministically on enable. Idempotent re-enable resets the seq.
6450 pub fn enable_telemetry(&self, sink_path: &str) -> Result<(), EngineError> {
6451 // Touch the sink (create + validate writable) before arming capture, so a
6452 // bad path fails loudly here rather than silently dropping events.
6453 std::fs::OpenOptions::new()
6454 .create(true)
6455 .append(true)
6456 .open(sink_path)
6457 .map_err(|_| EngineError::Storage)?;
6458 let mut guard = self.telemetry.lock().map_err(|_| EngineError::Storage)?;
6459 *guard = Some(TelemetrySink {
6460 path: PathBuf::from(sink_path),
6461 base: Instant::now(),
6462 nonce: 0,
6463 seq: 0,
6464 last_query_id: None,
6465 });
6466 // Arm the fast OFF-path guard LAST (after the sink is installed) so a
6467 // concurrent search either sees telemetry fully off or fully on.
6468 self.telemetry_enabled.store(true, Ordering::Release);
6469 Ok(())
6470 }
6471
6472 /// 0.8.8 Slice 15 — the most-recent captured `query_id` (for `record_feedback`).
6473 /// `None` when telemetry is off or no query has been captured yet.
6474 pub fn last_telemetry_query_id(&self) -> Option<String> {
6475 self.telemetry.lock().ok()?.as_ref().and_then(|s| s.last_query_id.clone())
6476 }
6477
6478 /// 0.8.8 Slice 15 — capture a query→result telemetry event. No-op (no alloc,
6479 /// no I/O) when telemetry is off (the default). Best-effort: a sink write error
6480 /// never fails the search. Captures ONLY ids, arms, and the query LENGTH —
6481 /// never the query text or `source_id` (privacy, ADR §C).
6482 ///
6483 /// ID-SPACES (Cause-A, 0.8.11.2 — honest record). `result_ids` is the interim
6484 /// `SearchHit.id` == `write_cursor`: within-session consistent but NOT
6485 /// cross-session-stable (reassigned on re-projection/re-ingest). `arm_of` is
6486 /// keyed by that same `write_cursor`. Cause-A adds a NEW PARALLEL field
6487 /// `result_stable_ids` carrying the cross-session-stable id
6488 /// ([`SearchHit::stable_id`], `logical_id` / content-hash) in the SAME order as
6489 /// `result_ids`; the existing `write_cursor` keys are RETAINED unchanged so
6490 /// pre-Cause-A gold and sink byte-output stay valid (the F-8a `id_space` flip
6491 /// is a separate, conscious step — see
6492 /// `dev/plans/runs/NOTE-0.8.8-to-steward-id-contract.md`).
6493 fn capture_telemetry(&self, query: &str, result: &SearchResult) {
6494 // Fast OFF path (codex §9 P2): a single atomic load when telemetry has
6495 // never been enabled — NO mutex acquisition, NO contention with the search
6496 // hot path.
6497 if !self.telemetry_enabled.load(Ordering::Acquire) {
6498 return;
6499 }
6500 let Ok(mut guard) = self.telemetry.lock() else { return };
6501 let Some(sink) = guard.as_mut() else { return };
6502 let query_id = format!("q{}-{}", sink.nonce, sink.seq);
6503 let ts_monotonic_ms = sink.base.elapsed().as_millis() as u64;
6504 let mut arm_of = serde_json::Map::new();
6505 for h in &result.results {
6506 // Keyed on the engine-internal positional cursor (the pre-C-2
6507 // `SearchHit.id` == `write_cursor`), byte-unchanged so `record_feedback`
6508 // + the gold pipeline keep keying on the same `result_ids` space.
6509 arm_of
6510 .insert(h.write_cursor.to_string(), serde_json::Value::from(branch_str(h.branch)));
6511 }
6512 let event = serde_json::json!({
6513 "type": "event",
6514 "schema_version": 1,
6515 "ts_monotonic_ms": ts_monotonic_ms,
6516 "query_id": query_id,
6517 "query_chars": query.chars().count() as u64,
6518 "result_ids": result.results.iter().map(|h| h.write_cursor).collect::<Vec<u64>>(),
6519 // Cause-A / C-2: parallel cross-session-stable ids, SAME order as
6520 // result_ids. Post-C-2 the stable id lives on `SearchHit.id` (its
6521 // prefixed form == the pre-swap `stable_id` value byte-for-byte), so
6522 // the emitted bytes are unchanged and the `write_cursor` result_ids
6523 // keys are retained unchanged (pre-Cause-A gold stays valid).
6524 "result_stable_ids": result
6525 .results
6526 .iter()
6527 .map(|h| h.id.to_prefixed())
6528 .collect::<Vec<String>>(),
6529 "arm_of": arm_of,
6530 });
6531 let _ = append_jsonl(&sink.path, &event);
6532 sink.seq += 1;
6533 sink.last_query_id = Some(query_id);
6534 }
6535
6536 /// 0.8.8 Slice 15 — append an agent-supplied relevance-label record for a
6537 /// previously-captured `query_id`. `label_source` is the only exogenous string
6538 /// (caller-declared, e.g. `"agent:hermes"`).
6539 ///
6540 /// ID-SPACE (Cause-A, 0.8.11.2 — honest record). `relevant_ids` /
6541 /// `irrelevant_ids` are the interim `SearchHit.id` == `write_cursor` (the same
6542 /// space as the captured event's `result_ids`), NOT `logical_id`. The
6543 /// signature is left byte-stable: the gold pipeline maps these `write_cursor`
6544 /// keys to the cross-session-stable id via the capture event's parallel
6545 /// `result_ids` ↔ `result_stable_ids` arrays (`eval/gold_capture.py`), so no
6546 /// new feedback parameter — and no binding-signature churn — is required.
6547 /// Errors if telemetry is off.
6548 pub fn record_feedback(
6549 &self,
6550 query_id: &str,
6551 relevant_ids: &[u64],
6552 irrelevant_ids: &[u64],
6553 label_source: &str,
6554 ) -> Result<(), EngineError> {
6555 let guard = self.telemetry.lock().map_err(|_| EngineError::Storage)?;
6556 let sink = guard
6557 .as_ref()
6558 .ok_or(EngineError::InvalidArgument { msg: "telemetry is not enabled".to_string() })?;
6559 // codex §9 [P1] (privacy): `query_id` is an exogenous caller string. Only a
6560 // deterministic id that `capture_telemetry` has ALREADY emitted may be
6561 // persisted — otherwise a caller could smuggle query text / a `source_id`
6562 // into the sink under the `query_id` key. Require the canonical
6563 // `q{nonce}-{seq}` form with `nonce == sink.nonce` AND `seq < sink.seq`
6564 // (a seq the capture path has issued). Reject (writing nothing) otherwise.
6565 let is_issued_id = query_id
6566 .strip_prefix('q')
6567 .and_then(|rest| rest.split_once('-'))
6568 .and_then(|(nonce, seq)| Some((nonce.parse::<u64>().ok()?, seq.parse::<u64>().ok()?)))
6569 .is_some_and(|(nonce, seq)| nonce == sink.nonce && seq < sink.seq);
6570 if !is_issued_id {
6571 return Err(EngineError::InvalidArgument { msg: "unknown query_id".to_string() });
6572 }
6573 let record = serde_json::json!({
6574 "type": "feedback",
6575 "schema_version": 1,
6576 "query_id": query_id,
6577 "relevant_ids": relevant_ids,
6578 "irrelevant_ids": irrelevant_ids,
6579 "label_source": label_source,
6580 });
6581 append_jsonl(&sink.path, &record).map_err(|_| EngineError::Storage)
6582 }
6583
6584 fn detect_slow(&self, started: Instant, category: lifecycle::EventCategory) {
6585 let elapsed = started.elapsed();
6586 let threshold = self.slow_threshold_ms.load(Ordering::Relaxed);
6587 let threshold_duration = std::time::Duration::from_millis(threshold);
6588 if elapsed > threshold_duration {
6589 // `dev/design/lifecycle.md` § Slow and heartbeat policy: a slow
6590 // operation produces TWO correlated facts. The
6591 // statement-level slow-statement signal is dispatched by the
6592 // sqlite3_profile callback (`profile_callback_trampoline`).
6593 // This site emits the lifecycle `Phase::Slow` event for the
6594 // outer operation envelope (AC-008).
6595 self.emit_event(lifecycle::Phase::Slow, category, None);
6596 }
6597 }
6598
6599 fn emit_event(
6600 &self,
6601 phase: lifecycle::Phase,
6602 category: lifecycle::EventCategory,
6603 code: Option<&'static str>,
6604 ) {
6605 let event =
6606 lifecycle::Event { phase, source: lifecycle::EventSource::Engine, category, code };
6607 self.subscribers.dispatch(&event);
6608 }
6609
6610 /// Emit a `(SqliteInternal, Error, code: <SQLITE_*>)` lifecycle
6611 /// event for a rusqlite error. Per `dev/design/lifecycle.md`
6612 /// § Diagnostic source and category, SQLite-originated diagnostics
6613 /// route through the same host subscriber as engine-originated
6614 /// events with `source` preserved. AC-021 dispatches on
6615 /// `code == "SQLITE_SCHEMA"`.
6616 fn emit_sqlite_internal_error(&self, err: &rusqlite::Error) {
6617 if let Some(code) = sqlite_extended_code_name(err) {
6618 let event = lifecycle::Event {
6619 phase: lifecycle::Phase::Failed,
6620 source: lifecycle::EventSource::SqliteInternal,
6621 category: lifecycle::EventCategory::Error,
6622 code: Some(code),
6623 };
6624 self.subscribers.dispatch(&event);
6625 }
6626 }
6627
6628 /// Thin wrapper: the production search path that discards the G0 Phase-2
6629 /// frontier meter (it never reaches `SearchResult` / the governed surface).
6630 #[allow(clippy::too_many_arguments)] // mirrors search_reranked + the explain flag
6631 fn search_inner(
6632 &self,
6633 query: &str,
6634 filter: Option<SearchFilter>,
6635 rerank_depth: usize,
6636 use_graph_arm: bool,
6637 alpha: f64,
6638 pool_n: usize,
6639 explain: bool,
6640 view: ReadView,
6641 ) -> Result<SearchResult, EngineError> {
6642 self.search_inner_with_stats(
6643 query,
6644 filter,
6645 rerank_depth,
6646 use_graph_arm,
6647 alpha,
6648 pool_n,
6649 explain,
6650 view,
6651 )
6652 .map(|(result, _stats)| result)
6653 }
6654
6655 /// G0 Phase-2: the search body, additionally returning the graph-arm frontier
6656 /// meter. Only the `_graph_frontier_stats_for_test` seam consumes the stats;
6657 /// `search_inner` (and thus `search_reranked` / `search`) drops them.
6658 #[allow(clippy::too_many_arguments)] // mirrors search_reranked + the explain flag
6659 fn search_inner_with_stats(
6660 &self,
6661 query: &str,
6662 filter: Option<SearchFilter>,
6663 rerank_depth: usize,
6664 use_graph_arm: bool,
6665 alpha: f64,
6666 pool_n: usize,
6667 explain: bool,
6668 view: ReadView,
6669 ) -> Result<(SearchResult, GraphFrontierStats), EngineError> {
6670 self.ensure_open()?;
6671 // 0.8.18 Slice 5 (#5 vector-equivalence probe, R-VEQ-4) — the SINGLE
6672 // vector-dependent choke point. If the open-time self-check found a
6673 // divergence beyond the D4 floor, refuse EVERY vector-dependent arm
6674 // (search / search_expand / explain-rerank / graph-arm all funnel here)
6675 // BEFORE any embedding / vector SQL / graph seeding / CE rerank — no
6676 // silent partial results. The text-only/FTS-only path
6677 // (`search_text_only`) does NOT route through here, so FTS stays
6678 // serviceable in degraded mode.
6679 if self.dense_disabled.load(Ordering::Acquire) {
6680 self.vector_equivalence_refusals.fetch_add(1, Ordering::Relaxed);
6681 let reason =
6682 self.dense_disabled_reason.lock().ok().and_then(|g| g.clone()).unwrap_or_else(
6683 || "open-time #5 vector-equivalence self-check failed".to_string(),
6684 );
6685 return Err(EngineError::VectorEquivalenceMismatch { reason });
6686 }
6687 if query.trim().is_empty() {
6688 return Err(EngineError::WriteValidation);
6689 }
6690
6691 // 0.8.20 Slice 15e fix-2 finding 1 [P2] — every filter attribute name is
6692 // validated against the declared `filterable` registry set BEFORE any arm
6693 // runs, so an UNDECLARED name is a typed `InvalidFilter` rejection instead
6694 // of an opaque `no such column` `Storage` crash (vector arm) or a silent
6695 // no-match (FTS arm). ADR-0.8.11 D3: every filter term has a DEFINED outcome
6696 // ("compiles" or "typed rejection") IDENTICAL across arms.
6697 //
6698 // keystone closeout fix-3 (codex §9 [P2], TOCTOU): that validation is NO
6699 // LONGER performed here on `self.connection` before dispatch. fix-2 checked
6700 // the registry on the WRITER connection and then let the reader prepare the
6701 // vec0 query on a DIFFERENT connection/snapshot — a `configure_projections`
6702 // DROP landing in the window between the check and the reader snapshot could
6703 // still make the `attr_<hex>` column vanish AFTER validation passed, i.e. the
6704 // exact untyped `Storage` failure fix-2 meant to prevent. The check now runs
6705 // INSIDE the reader's deferred transaction (see
6706 // `validate_filter_attributes_on_snapshot`, called from `read_search_in_tx`),
6707 // so the registry it reads and the vec0 columns the query compiles against are
6708 // ONE snapshot — the race is closed and BOTH arms still see the same typed
6709 // `InvalidFilter`. Moving it there also removes a per-filtered-search writer
6710 // lock and the fix-2 concurrent-ADD false-reject (the reader snapshot sees a
6711 // freshly-added declaration and accepts).
6712 let compiled = compile_text_query(query);
6713 // REQ-013 / AC-059b / REQ-055: the cursor returned with a search
6714 // MUST be derived from the same WAL snapshot the data was read
6715 // from. Loading `next_cursor` from the writer-side atomic before
6716 // the reader transaction acquires its snapshot races against
6717 // concurrent writers — see `dev/design/engine.md` § Cursor
6718 // contract. Run cursor probe + body query inside one read tx
6719 // (BEGIN DEFERRED on a `query_only=ON` connection in WAL mode is
6720 // a snapshot-stable read).
6721 // EU-5a2 mean-centering apply path (query side). `query_vector`
6722 // is ALWAYS un-centered (used by the f32 vec_distance_l2 rerank
6723 // in phase 2). `query_vector_bin` is the (possibly centered) f32
6724 // fed to `vec_quantize_binary` in phase 1. The centering decision
6725 // mirrors the write path: identity must be MC-required AND a
6726 // mean_vec must be pinned. NoopEmbedder collapses to
6727 // `query_vector_bin == query_vector` until EU-5b.
6728 let raw_query_vector =
6729 self.runtime_embedder.as_ref().and_then(|embedder| embedder.embed(query).ok());
6730 let query_vector_bin = match raw_query_vector.as_ref() {
6731 Some(vector) if identity_requires_mean_centering(&self.runtime_embedder_identity) => {
6732 let pinned = {
6733 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
6734 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
6735 read_pinned_mean_vec(connection, self.runtime_embedder_identity.dimension)?
6736 };
6737 match pinned {
6738 Some(mean) => serde_json::to_string(&subtract_mean(vector, &mean)).ok(),
6739 None => serde_json::to_string(vector).ok(),
6740 }
6741 }
6742 Some(vector) => serde_json::to_string(vector).ok(),
6743 None => None,
6744 };
6745 let query_vector = raw_query_vector.and_then(|vector| serde_json::to_string(&vector).ok());
6746 // 0.7.2 PR-2bc S1 fix-1 — phase-2 rerank LIMIT. Production default is
6747 // `SEARCH_RERANK_LIMIT` (10); the test seam may RAISE it, clamped to
6748 // the production floor so a test can never shrink search semantics.
6749 let search_limit = self
6750 .projection_runtime
6751 .shared
6752 .search_limit_override
6753 .load(Ordering::SeqCst)
6754 .max(SEARCH_RERANK_LIMIT);
6755 let recency_enabled =
6756 self.projection_runtime.shared.recency_reweight_enabled.load(Ordering::SeqCst);
6757 let importance_enabled =
6758 self.projection_runtime.shared.importance_reweight_enabled.load(Ordering::SeqCst);
6759 let vector_stage_only =
6760 self.projection_runtime.shared.vector_stage_only_for_test.load(Ordering::SeqCst);
6761 let (response_tx, response_rx) = mpsc::sync_channel::<ReaderResponse>(1);
6762 let request = ReaderRequest::Search {
6763 compiled,
6764 query_vector,
6765 query_vector_bin,
6766 search_limit,
6767 filter: filter.map(Box::new),
6768 recency_enabled,
6769 importance_enabled,
6770 vector_stage_only,
6771 raw_query: Box::from(query), // FIX-4: Box<str> (16B) not String (24B)
6772 rerank_depth,
6773 use_graph_arm,
6774 alpha,
6775 pool_n,
6776 explain,
6777 view,
6778 respond: response_tx,
6779 };
6780 if self.reader_pool.dispatch(request).is_err() {
6781 return Err(EngineError::Closing);
6782 }
6783 let search_result = response_rx.recv().map_err(|_| EngineError::Storage)?;
6784 let (cursor, soft_fallback, results, graph_stats, explanation) = match search_result {
6785 Ok(result) => result,
6786 // fix-3 (codex §9 [P2]) — an undeclared `filterable` attribute is caught
6787 // on the reader's OWN snapshot (validate + vec0 query = one transaction),
6788 // so it surfaces as the EXISTING typed `InvalidFilter` and can never be
6789 // the opaque `no such column` `Storage` error the TOCTOU race produced.
6790 Err(SearchReaderError::InvalidFilter(reason)) => {
6791 return Err(EngineError::InvalidFilter { reason });
6792 }
6793 Err(SearchReaderError::Sqlite(err)) => {
6794 self.emit_sqlite_internal_error(&err);
6795 return Err(EngineError::Storage);
6796 }
6797 };
6798
6799 // The worker (`read_search_in_tx`) has no embedder identity; fill the
6800 // trace's `embedder_id` here, where `self.runtime_embedder_identity` is in
6801 // scope. Only on the explain path (`explanation` is `Some`).
6802 let explanation = explanation.map(|mut exp| {
6803 let id = &self.runtime_embedder_identity;
6804 exp.trace.embedder_id = format!("{}@{} (dim={})", id.name, id.revision, id.dimension);
6805 exp
6806 });
6807
6808 Ok((
6809 SearchResult { projection_cursor: cursor, soft_fallback, results, explanation },
6810 graph_stats,
6811 ))
6812 }
6813
6814 /// G0 Phase-2 (BLOCK-1) test seam — runs the graph-arm retrieval path and
6815 /// returns the frontier meter (`GraphFrontierStats`) for `query`. Mirrors the
6816 /// sanctioned `set_vector_stage_only_for_test` / `_configure_vector_kind_for_test`
6817 /// pattern: kept OFF the governed surface (test/eval-only), so the meter never
6818 /// appears on `SearchResult`. Used by the recall harness to prove the
6819 /// doc-seeded frontier is empty (`resolved_seed_rate == 0.0`) and, post-C1, the
6820 /// 0→>0 flip.
6821 pub fn _graph_frontier_stats_for_test(
6822 &self,
6823 query: &str,
6824 ) -> Result<GraphFrontierStats, EngineError> {
6825 self.search_inner_with_stats(query, None, 0, true, 0.3, 0, false, ReadView::default())
6826 .map(|(_result, stats)| stats)
6827 }
6828
6829 /// Slice 30 (G2) — `read.get`: active-only point lookup by `logical_id`.
6830 /// Delegates to [`Engine::read_get_many`]; returns the single slot. A
6831 /// missing/superseded id is `None` (a normal absence, not an error). Reads
6832 /// ride the ReaderWorkerPool DEFERRED-tx path (never the writer lock).
6833 pub fn read_get(
6834 &self,
6835 logical_id: &str,
6836 view: &ReadView,
6837 ) -> Result<Option<NodeRecord>, EngineError> {
6838 let ids = [logical_id.to_string()];
6839 let rows = self.read_get_many(&ids, view)?;
6840 Ok(rows.into_iter().next().flatten())
6841 }
6842
6843 /// Slice 30 (G2) — `read.get_many`: active-only point lookup over many
6844 /// `logical_id`s. Returns one slot per requested id in REQUEST ORDER, `None`
6845 /// where no active row carries that id (partial, never all-or-nothing).
6846 pub fn read_get_many(
6847 &self,
6848 logical_ids: &[String],
6849 view: &ReadView,
6850 ) -> Result<Vec<Option<NodeRecord>>, EngineError> {
6851 self.ensure_open()?;
6852 if logical_ids.is_empty() {
6853 return Ok(Vec::new());
6854 }
6855 let (response_tx, response_rx) = mpsc::sync_channel(1);
6856 let request = ReaderRequest::GetById {
6857 logical_ids: logical_ids.to_vec(),
6858 view: *view,
6859 respond: response_tx,
6860 };
6861 if self.reader_pool.dispatch(request).is_err() {
6862 return Err(EngineError::Closing);
6863 }
6864 match response_rx.recv().map_err(|_| EngineError::Storage)? {
6865 Ok(rows) => Ok(rows),
6866 Err(err) => {
6867 self.emit_sqlite_internal_error(&err);
6868 Err(EngineError::Storage)
6869 }
6870 }
6871 }
6872
6873 /// Slice 20 (G5) — `read.neighbors`: bounded BFS from `root_logical_id`
6874 /// over `canonical_edges`. Returns nodes reachable within `depth` hops
6875 /// (`1..=3`) in the given `direction`, excluding the root itself.
6876 ///
6877 /// Hard cap: 50 results (engine-enforced `LIMIT 50`).
6878 /// Traversal filter: `superseded_at IS NULL AND (t_invalid IS NULL OR t_invalid > now)`.
6879 ///
6880 /// Returns `Err(EngineError::InvalidArgument)` for `depth > 3`.
6881 /// Returns `Ok(vec![])` for an unknown/superseded root.
6882 /// Reads ride the `ReaderWorkerPool` DEFERRED-tx path.
6883 pub fn graph_neighbors(
6884 &self,
6885 root_logical_id: &str,
6886 depth: u32,
6887 direction: TraversalDirection,
6888 view: &ReadView,
6889 ) -> Result<Vec<NodeRecord>, EngineError> {
6890 self.ensure_open()?;
6891 if depth == 0 || depth > 3 {
6892 return Err(EngineError::InvalidArgument {
6893 msg: format!("traversal depth {depth} is out of range; must be 1, 2, or 3"),
6894 });
6895 }
6896 let (response_tx, response_rx) = mpsc::sync_channel(1);
6897 let request = ReaderRequest::GraphNeighbors {
6898 root_logical_id: root_logical_id.to_string(),
6899 depth,
6900 direction,
6901 view: *view,
6902 respond: response_tx,
6903 };
6904 if self.reader_pool.dispatch(request).is_err() {
6905 return Err(EngineError::Closing);
6906 }
6907 match response_rx.recv().map_err(|_| EngineError::Storage)? {
6908 Ok(nodes) => Ok(nodes),
6909 Err(err) => {
6910 self.emit_sqlite_internal_error(&err);
6911 Err(EngineError::Storage)
6912 }
6913 }
6914 }
6915
6916 /// Slice 20 (G6) — `search_expand`: hybrid search (`G1+G9`) followed by
6917 /// bounded BFS expansion (`G5`) of each search hit. Returns the original
6918 /// search hits (with RRF scores) plus nodes reachable from any hit via
6919 /// up to `depth` hops that are NOT already in the search hit set.
6920 ///
6921 /// Returns `Err(EngineError::InvalidArgument)` for `depth > 3`.
6922 /// A `depth = 0` call returns search hits with their logical_ids resolved
6923 /// but no BFS expansion. Reads ride the `ReaderWorkerPool` DEFERRED-tx path.
6924 ///
6925 /// **Snapshot note:** the search phase (`search_inner`) and the expansion
6926 /// phase (`SearchExpand` reader request) run in separate DEFERRED reader
6927 /// transactions; a write that lands between them is visible to expansion
6928 /// but not search (or vice-versa). In practice the window is negligible for
6929 /// single-process embedded use. The expansion phase mitigates drift by
6930 /// filtering `search_hits` to only include hits whose `write_cursor` is
6931 /// still active in the expansion snapshot (superseded hits are dropped from
6932 /// the result rather than surfaced with stale data).
6933 pub fn search_expand(
6934 &self,
6935 query: &str,
6936 filter: Option<SearchFilter>,
6937 depth: u32,
6938 ) -> Result<SearchExpandResult, EngineError> {
6939 self.ensure_open()?;
6940 if depth > 3 {
6941 return Err(EngineError::InvalidArgument {
6942 msg: format!("traversal depth {depth} exceeds the SDK ceiling of 3"),
6943 });
6944 }
6945 // Step 1: run the hybrid search to get initial hits (no CE reranking in expand).
6946 // 0.8.5: depth=0 → no rerank, so α/pool_n (0.3, 0) are inert here.
6947 let search_result =
6948 self.search_inner(query, filter, 0, false, 0.3, 0, false, ReadView::default())?;
6949 if search_result.results.is_empty() {
6950 return Ok(SearchExpandResult {
6951 search_hits: Vec::new(),
6952 expanded: Vec::new(),
6953 all_logical_ids: Vec::new(),
6954 });
6955 }
6956 // Step 2: dispatch to the reader pool to resolve logical_ids and run BFS.
6957 // depth=0 is forwarded to the reader so it can populate all_logical_ids
6958 // (the union of search-hit logical_ids), even with no expansion.
6959 let (response_tx, response_rx) = mpsc::sync_channel(1);
6960 let request = ReaderRequest::SearchExpand {
6961 search_hits: search_result.results,
6962 depth,
6963 respond: response_tx,
6964 };
6965 if self.reader_pool.dispatch(request).is_err() {
6966 return Err(EngineError::Closing);
6967 }
6968 match response_rx.recv().map_err(|_| EngineError::Storage)? {
6969 Ok(result) => Ok(result),
6970 Err(err) => {
6971 self.emit_sqlite_internal_error(&err);
6972 Err(EngineError::Storage)
6973 }
6974 }
6975 }
6976
6977 /// Slice 20 test seam — run `EXPLAIN QUERY PLAN` on the BFS CTE SQL and
6978 /// return the plan detail lines. Used by `explain_plan_uses_indexes`.
6979 #[doc(hidden)]
6980 pub fn explain_graph_neighbors_for_test(
6981 &self,
6982 root_logical_id: &str,
6983 depth: u32,
6984 direction: TraversalDirection,
6985 ) -> Result<Vec<String>, EngineError> {
6986 self.ensure_open()?;
6987 let (response_tx, response_rx) = mpsc::sync_channel(1);
6988 let request = ReaderRequest::ExplainGraphNeighbors {
6989 root_logical_id: root_logical_id.to_string(),
6990 depth,
6991 direction,
6992 respond: response_tx,
6993 };
6994 if self.reader_pool.dispatch(request).is_err() {
6995 return Err(EngineError::Closing);
6996 }
6997 match response_rx.recv().map_err(|_| EngineError::Storage)? {
6998 Ok(plan) => Ok(plan),
6999 Err(err) => {
7000 self.emit_sqlite_internal_error(&err);
7001 Err(EngineError::Storage)
7002 }
7003 }
7004 }
7005
7006 /// Slice 30 (G3) — `read.collection`: paginated op-store read-back over
7007 /// `operational_mutations` for `collection`, `ORDER BY id`. `limit` is
7008 /// MANDATORY (clamped to the ~1M cap); `after_id` is the exclusive cursor.
7009 /// Reads ride the ReaderWorkerPool DEFERRED-tx path.
7010 pub fn read_collection(
7011 &self,
7012 collection: &str,
7013 after_id: Option<i64>,
7014 limit: usize,
7015 ) -> Result<Vec<OpStoreRow>, EngineError> {
7016 self.read_collection_dispatch(collection, after_id, limit)
7017 }
7018
7019 /// Slice 30 (G3) — `read.mutations`: the mutation-log-oriented alias surface
7020 /// over the SAME op-store read-back as [`Engine::read_collection`].
7021 pub fn read_mutations(
7022 &self,
7023 collection: &str,
7024 after_id: Option<i64>,
7025 limit: usize,
7026 ) -> Result<Vec<OpStoreRow>, EngineError> {
7027 self.read_collection_dispatch(collection, after_id, limit)
7028 }
7029
7030 fn read_collection_dispatch(
7031 &self,
7032 collection: &str,
7033 after_id: Option<i64>,
7034 limit: usize,
7035 ) -> Result<Vec<OpStoreRow>, EngineError> {
7036 self.ensure_open()?;
7037 let (response_tx, response_rx) = mpsc::sync_channel(1);
7038 let request = ReaderRequest::ReadCollection {
7039 collection: collection.to_string(),
7040 after_id,
7041 limit,
7042 respond: response_tx,
7043 };
7044 if self.reader_pool.dispatch(request).is_err() {
7045 return Err(EngineError::Closing);
7046 }
7047 match response_rx.recv().map_err(|_| EngineError::Storage)? {
7048 Ok(rows) => Ok(rows),
7049 Err(err) => {
7050 self.emit_sqlite_internal_error(&err);
7051 Err(EngineError::Storage)
7052 }
7053 }
7054 }
7055
7056 /// Slice 35 (G4) — `read.list`: list active `canonical_nodes` of a given
7057 /// `kind`, optionally filtered by a closed [`Predicate`] set, up to `limit`
7058 /// rows. Returns `Vec<NodeRecord>` (active only; `superseded_at IS NULL`).
7059 ///
7060 /// Multiple predicates are combined as AND (D-F5). An empty predicate slice
7061 /// returns all active nodes of the given kind up to `limit` (unfiltered path).
7062 /// Compilation target: `json_extract(body, '$.field') <op> ?` with bound
7063 /// parameters (injection-safe per D-F4). See `dev/adr/ADR-0.8.0-filter-grammar.md`.
7064 ///
7065 /// Path validation happens at [`Predicate`] construction time; `read_list`
7066 /// revalidates as defense-in-depth (enum variants are `pub`, so direct
7067 /// struct-literal construction could bypass the constructors).
7068 pub fn read_list(
7069 &self,
7070 kind: &str,
7071 predicates: &[Predicate],
7072 limit: usize,
7073 view: &ReadView,
7074 ) -> Result<Vec<NodeRecord>, EngineError> {
7075 self.ensure_open()?;
7076 // Defense-in-depth: revalidate paths even if the caller bypassed the
7077 // validated constructors by constructing enum variants directly.
7078 for pred in predicates {
7079 let path = pred.path();
7080 if !PREDICATE_PATH_ALLOWLIST.contains(&path) {
7081 return Err(EngineError::InvalidFilter {
7082 reason: format!("path '{path}' is not in the predicate path allowlist"),
7083 });
7084 }
7085 }
7086 let (response_tx, response_rx) = mpsc::sync_channel(1);
7087 let request = ReaderRequest::ReadList {
7088 kind: kind.to_string(),
7089 predicates: predicates.to_vec(),
7090 limit,
7091 view: *view,
7092 respond: response_tx,
7093 };
7094 if self.reader_pool.dispatch(request).is_err() {
7095 return Err(EngineError::Closing);
7096 }
7097 match response_rx.recv().map_err(|_| EngineError::Storage)? {
7098 Ok(rows) => Ok(rows),
7099 Err(err) => {
7100 self.emit_sqlite_internal_error(&err);
7101 Err(EngineError::Storage)
7102 }
7103 }
7104 }
7105
7106 /// 0.8.11 Slice 40 (#17) — unified-`Filter` entry point for the
7107 /// canonical_nodes `read.list` backend. Accepts the **full** [`FilterTerm`]
7108 /// set (D3): `Json` runs the shipped allowlisted `json_extract` path;
7109 /// `Status`/`CreatedAfter` lower to allowlisted json-paths; `Kind`/`SourceType`
7110 /// **constant-fold** against the partition `kind` (a guaranteed-empty fold
7111 /// returns an empty `Vec` without touching SQL). Dispatches to the same
7112 /// [`Engine::read_list`] machinery the shipped `Predicate` surface uses, so
7113 /// every inherited invariant (`superseded_at IS NULL`, `json_valid(body)`,
7114 /// the `canonical_nodes(kind)` index, parameterized binds) is preserved.
7115 pub fn read_list_filter(
7116 &self,
7117 kind: &str,
7118 filter: &Filter,
7119 limit: usize,
7120 view: &ReadView,
7121 ) -> Result<Vec<NodeRecord>, EngineError> {
7122 self.ensure_open()?;
7123 match filter.lower_for_read_list(kind)? {
7124 None => Ok(Vec::new()),
7125 Some(preds) => self.read_list(kind, &preds, limit, view),
7126 }
7127 }
7128
7129 /// 0.8.20 Slice 10b (R-20-NV) — the **validity-boundary hook**: which nodes
7130 /// crossed a `[valid_from, valid_until)` boundary in the half-open interval
7131 /// `(since, as_of]`?
7132 ///
7133 /// `since` and the resolved upper bound are INTEGER epoch SECONDS. The upper
7134 /// bound is the view's own instant (`view.valid_as_of`, defaulting to now),
7135 /// so one instant governs both the boundary interval and the view — and, as
7136 /// everywhere else on this path, it is BOUND, never a `datetime('now')`
7137 /// literal, so the answer is deterministic for a fixed `(since, as_of)`.
7138 ///
7139 /// A node appears once, carrying whichever of the two boundaries it crossed;
7140 /// a window that both opened AND closed inside the interval reports both.
7141 /// Rows with an unbounded window on a side cannot cross that side, so a
7142 /// NULL/NULL row (every row predating schema step 22) never appears.
7143 ///
7144 /// The view's EXISTENCE flags still apply (so by default only current,
7145 /// active rows are considered), but its validity predicate does NOT: the
7146 /// question is about boundary crossings, not about being valid right now.
7147 ///
7148 /// When the view relaxes validity entirely (`include_out_of_window`), the
7149 /// interval is unbounded above.
7150 ///
7151 /// This is world-time only. There is deliberately no transaction-time
7152 /// (`history_as_of`) counterpart.
7153 pub fn crossed_boundary_since(
7154 &self,
7155 since: i64,
7156 view: &ReadView,
7157 ) -> Result<Vec<BoundaryCrossing>, EngineError> {
7158 self.ensure_open()?;
7159 let (response_tx, response_rx) = mpsc::sync_channel(1);
7160 let request =
7161 ReaderRequest::CrossedBoundarySince { since, view: *view, respond: response_tx };
7162 if self.reader_pool.dispatch(request).is_err() {
7163 return Err(EngineError::Closing);
7164 }
7165 match response_rx.recv().map_err(|_| EngineError::Storage)? {
7166 Ok(rows) => Ok(rows),
7167 Err(err) => {
7168 self.emit_sqlite_internal_error(&err);
7169 Err(EngineError::Storage)
7170 }
7171 }
7172 }
7173
7174 pub fn close(&self) -> Result<(), EngineError> {
7175 self.closed.store(true, Ordering::SeqCst);
7176 self.projection_runtime.stop();
7177 // Uninstall profile callbacks before dropping the connections so
7178 // SQLite cannot fire one last callback against a profile context
7179 // whose Box is about to free. Per `dev/design/engine.md` § Close
7180 // path step 6, readers drain before the writer connection so
7181 // SQLite's last-handle checkpointer runs on the writer. Each
7182 // reader worker uninstalls its own callback inside
7183 // `reader_worker_loop` before dropping its connection, then
7184 // exits — `shutdown` joins those threads here.
7185 self.reader_pool.shutdown();
7186 if let Ok(mut connection) = self.connection.lock() {
7187 if let Some(conn) = connection.as_ref() {
7188 uninstall_profile_callback(conn);
7189 }
7190 connection.take();
7191 }
7192 if let Ok(mut contexts) = self.profile_contexts.lock() {
7193 contexts.clear();
7194 }
7195 if let Ok(mut lock) = self.lock.lock() {
7196 lock.take();
7197 }
7198 Ok(())
7199 }
7200
7201 /// Block until in-flight writes drain or `timeout_ms` elapses.
7202 ///
7203 /// Surface owned by `dev/interfaces/rust.md` § Engine-attached
7204 /// instrumentation; semantics are owned by `dev/design/lifecycle.md`.
7205 pub fn drain(&self, timeout_ms: u64) -> Result<(), EngineError> {
7206 self.ensure_open()?;
7207 if self.projection_runtime.wait_for_idle(timeout_ms) {
7208 Ok(())
7209 } else {
7210 Err(EngineError::Scheduler)
7211 }
7212 }
7213
7214 /// Snapshot of engine-internal counters.
7215 ///
7216 /// Field set owned by `dev/design/lifecycle.md`.
7217 #[must_use]
7218 pub fn counters(&self) -> CounterSnapshot {
7219 self.counters.snapshot()
7220 }
7221
7222 /// Toggle response-cycle profiling.
7223 ///
7224 /// Per `dev/design/lifecycle.md` § Per-statement profiling, profiling
7225 /// is an opt-in surface that is independently toggleable on a running
7226 /// engine without restart. AC-005a locks runtime toggleability.
7227 pub fn set_profiling(&self, enabled: bool) -> Result<(), EngineError> {
7228 self.profiling_enabled.store(enabled, Ordering::Relaxed);
7229 Ok(())
7230 }
7231
7232 /// Set the threshold above which an operation is reported as slow.
7233 ///
7234 /// Per `dev/design/lifecycle.md` § Slow and heartbeat policy, the
7235 /// threshold is runtime-configurable; mutating it changes detection
7236 /// behavior on subsequent statements without restart (AC-007b).
7237 pub fn set_slow_threshold_ms(&self, value: u64) -> Result<(), EngineError> {
7238 self.slow_threshold_ms.store(value, Ordering::Relaxed);
7239 Ok(())
7240 }
7241
7242 /// Attach a host subscriber to engine events.
7243 ///
7244 /// Dropping the returned [`Subscription`] detaches the subscriber.
7245 /// Payload shape owned by `dev/design/lifecycle.md` and
7246 /// `dev/design/migrations.md`.
7247 #[must_use]
7248 pub fn subscribe(&self, subscriber: Arc<dyn lifecycle::Subscriber>) -> Subscription {
7249 self.subscribers.attach(subscriber)
7250 }
7251
7252 #[cfg(debug_assertions)]
7253 #[doc(hidden)]
7254 pub fn reader_worker_count_for_test(&self) -> usize {
7255 self.reader_pool.worker_count()
7256 }
7257
7258 #[cfg(debug_assertions)]
7259 #[doc(hidden)]
7260 pub fn live_reader_worker_count_for_test(&self) -> usize {
7261 self.reader_pool.live_count()
7262 }
7263
7264 /// Pack 6.G G.1 — return the `sqlite3_db_config(LOOKASIDE)` rc
7265 /// captured for each reader worker at open time, in worker index
7266 /// order. SQLITE_OK (= 0) means the lookaside was configured
7267 /// before any allocation happened on the connection.
7268 #[cfg(debug_assertions)]
7269 #[doc(hidden)]
7270 pub fn reader_lookaside_config_rcs_for_test(&self) -> Vec<i32> {
7271 self.reader_lookaside_rcs.clone()
7272 }
7273
7274 /// Pack 6.G G.1 — query each reader worker's
7275 /// `SQLITE_DBSTATUS_LOOKASIDE_USED` counter. A value > 0 means at
7276 /// least one allocation was satisfied from the per-connection
7277 /// lookaside arena (proof the configuration was honored before the
7278 /// first prepare).
7279 #[cfg(debug_assertions)]
7280 #[doc(hidden)]
7281 pub fn reader_lookaside_used_per_worker_for_test(&self) -> Vec<i32> {
7282 self.reader_pool.lookaside_used_per_worker()
7283 }
7284
7285 /// Pack 6.G G.3.5 — broadcast a debug-only `CacheStatus` request to
7286 /// every reader worker and collect per-worker
7287 /// `SQLITE_DBSTATUS_CACHE_HIT` / `_CACHE_MISS` / `_CACHE_USED`
7288 /// values. Counters are monotonic (reset flag = 0); callers compute
7289 /// pre/post deltas explicitly.
7290 #[cfg(debug_assertions)]
7291 #[doc(hidden)]
7292 pub fn cache_status_per_worker_for_test(&self, label: &str) -> Vec<CacheStatusReply> {
7293 self.reader_pool.cache_status_per_worker(label)
7294 }
7295
7296 #[cfg(debug_assertions)]
7297 #[doc(hidden)]
7298 pub fn force_next_commit_failure_for_test(&self) {
7299 self.force_next_commit_failure.store(true, Ordering::SeqCst);
7300 }
7301
7302 /// Force the next background projection terminal commit to fail with a
7303 /// synthetic SQLite busy error. Test-only seam for TC-91 rollback and
7304 /// redispatch coverage; it does not affect the caller's write transaction.
7305 #[cfg(debug_assertions)]
7306 #[doc(hidden)]
7307 pub fn force_next_projection_commit_failure_for_test(&self) {
7308 self.projection_runtime.force_next_projection_commit_failure_for_test();
7309 }
7310
7311 /// Force the next background projection terminal commit to fail with a
7312 /// rusqlite-layer storage error. Test-only TC-91 diagnostic classifier seam.
7313 #[cfg(debug_assertions)]
7314 #[doc(hidden)]
7315 pub fn force_next_projection_storage_failure_for_test(&self) {
7316 self.projection_runtime.force_next_projection_storage_failure_for_test();
7317 }
7318
7319 /// Pause a worker after a forced projection-commit error was reported and
7320 /// before its state cleanup. TC-91 test-only shutdown/reopen rendezvous.
7321 #[cfg(debug_assertions)]
7322 #[doc(hidden)]
7323 pub fn pause_projection_commit_failure_cleanup_for_test(
7324 &self,
7325 reported: Arc<Barrier>,
7326 release: Arc<Barrier>,
7327 ) {
7328 self.projection_runtime.pause_projection_commit_failure_cleanup_for_test(reported, release);
7329 }
7330
7331 /// Acknowledge after `Engine::close` marks the projection runtime stopping
7332 /// and before it joins workers. TC-91 test-only shutdown rendezvous.
7333 #[cfg(debug_assertions)]
7334 #[doc(hidden)]
7335 pub fn acknowledge_projection_stop_for_test(&self, acknowledged: Arc<Barrier>) {
7336 self.projection_runtime.acknowledge_projection_stop_for_test(acknowledged);
7337 }
7338
7339 /// Execute an arbitrary SQL statement on the writer connection through
7340 /// the same wall-clock + slow-detect path as `write` / `search`.
7341 ///
7342 /// Test-only helper for the deterministic-slow-cte fixture used by
7343 /// AC-007a / AC-007b. Not part of the public 0.6.0 surface; gated on
7344 /// `debug_assertions` so release builds do not expose it.
7345 #[cfg(debug_assertions)]
7346 #[doc(hidden)]
7347 pub fn execute_for_test(&self, sql: &str) -> Result<(), EngineError> {
7348 self.ensure_open()?;
7349 let started = Instant::now();
7350 {
7351 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7352 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
7353 connection.execute_batch(sql).map_err(|_| EngineError::Storage)?;
7354 }
7355 self.detect_slow(started, lifecycle::EventCategory::Search);
7356 Ok(())
7357 }
7358
7359 /// One-thread-poison robustness fixture (AC-009).
7360 ///
7361 /// Spawns four reader threads + one writer thread that all make
7362 /// forward progress (single canonical write + repeated searches),
7363 /// plus one designated poison thread that runs an empty-batch write
7364 /// — a deterministic `EngineError::WriteValidation`. The captured
7365 /// poison failure is dispatched as a `StressFailureContext` whose
7366 /// `last_error_chain` is `[EngineError::stable_code(),
7367 /// engine_error.to_string()]` per the lifecycle § Stress-failure
7368 /// context payload contract.
7369 #[doc(hidden)]
7370 #[cfg(debug_assertions)]
7371 pub fn run_one_thread_poison_for_test(&self) -> Result<(), EngineError> {
7372 self.ensure_open()?;
7373
7374 // Forward-progress writer seeds a row so readers + the poison
7375 // thread share a non-trivial canonical state.
7376 self.write(&[PreparedWrite::Node {
7377 kind: "doc".to_string(),
7378 body: "poison-fixture-seed".to_string(),
7379 source_id: SourceId::engine_derived("poison-fixture"),
7380 logical_id: None,
7381 state: InitialState::Active,
7382 reason: None,
7383 valid_from: None,
7384 valid_until: None,
7385 }])?;
7386
7387 let poison_outcome: Mutex<Option<EngineError>> = Mutex::new(None);
7388 let poison_thread_id: AtomicU64 = AtomicU64::new(0);
7389
7390 thread::scope(|scope| {
7391 // N=4 reader threads make forward progress.
7392 for _ in 0..4 {
7393 scope.spawn(|| {
7394 for _ in 0..4 {
7395 let _ = self.search("poison-fixture-seed");
7396 }
7397 });
7398 }
7399 // One forward-progress writer thread.
7400 scope.spawn(|| {
7401 let _ = self.write(&[PreparedWrite::Node {
7402 kind: "doc".to_string(),
7403 body: "writer-progress".to_string(),
7404 source_id: SourceId::engine_derived("poison-fixture"),
7405 logical_id: None,
7406 state: InitialState::Active,
7407 reason: None,
7408 valid_from: None,
7409 valid_until: None,
7410 }]);
7411 });
7412 // One poison thread — empty batch is a deterministic
7413 // WriteValidation failure.
7414 scope.spawn(|| {
7415 // Use a non-zero, deterministic group id so subscribers
7416 // see a stable identifier across runs of the fixture.
7417 poison_thread_id.store(1, Ordering::SeqCst);
7418 if let Err(err) = self.write(&[]) {
7419 *poison_outcome.lock().expect("poison_outcome lock") = Some(err);
7420 }
7421 });
7422 });
7423
7424 let err = poison_outcome
7425 .into_inner()
7426 .expect("poison_outcome lock")
7427 .expect("poison thread must produce a deterministic error");
7428
7429 let projection_state = match self.projection_status_for_test("doc") {
7430 Ok(lifecycle::ProjectionStatus::Pending) => "Pending",
7431 Ok(lifecycle::ProjectionStatus::Failed) => "Failed",
7432 Ok(lifecycle::ProjectionStatus::UpToDate) => "UpToDate",
7433 // Default to UpToDate when projection status is unobservable
7434 // (e.g. embedder not configured for the seed kind). The
7435 // value is still one of the documented enum stringifications
7436 // per AC-010.
7437 Err(_) => "UpToDate",
7438 };
7439
7440 let context = lifecycle::StressFailureContext {
7441 thread_group_id: poison_thread_id.load(Ordering::SeqCst),
7442 op_kind: "write".to_string(),
7443 last_error_chain: vec![err.stable_code().to_string(), err.to_string()],
7444 projection_state: projection_state.to_string(),
7445 };
7446 self.subscribers.dispatch_stress_failure(&context);
7447 Ok(())
7448 }
7449
7450 #[doc(hidden)]
7451 pub fn set_projection_scheduler_frozen_for_test(&self, frozen: bool) {
7452 self.projection_runtime.set_frozen(frozen);
7453 }
7454
7455 #[doc(hidden)]
7456 pub fn set_projection_retry_delays_for_test(&self, delays_ms: &[u64]) {
7457 self.projection_runtime.set_retry_delays_for_test(delays_ms);
7458 }
7459
7460 /// PR-9 — lower the ADR-0.6.0 Invariant 5 per-`embed()` watchdog deadline
7461 /// for tests (production default is `DEFAULT_EMBED_TIMEOUT_MS` = 30s).
7462 #[doc(hidden)]
7463 pub fn set_embed_timeout_ms_for_test(&self, timeout_ms: u64) {
7464 self.projection_runtime.set_embed_timeout_ms_for_test(timeout_ms);
7465 }
7466
7467 /// PR-9 — lower the embed circuit-breaker threshold for tests (production
7468 /// default `DEFAULT_EMBED_CIRCUIT_THRESHOLD`); 0 disables the breaker.
7469 #[doc(hidden)]
7470 pub fn set_embed_circuit_threshold_for_test(&self, threshold: u64) {
7471 self.projection_runtime.set_embed_circuit_threshold_for_test(threshold);
7472 }
7473
7474 /// PR-9 — whether the embed circuit breaker has latched open.
7475 #[doc(hidden)]
7476 pub fn embed_circuit_open_for_test(&self) -> bool {
7477 self.projection_runtime.embed_circuit_open_for_test()
7478 }
7479
7480 #[doc(hidden)]
7481 pub fn projection_status_for_test(
7482 &self,
7483 kind: &str,
7484 ) -> Result<lifecycle::ProjectionStatus, EngineError> {
7485 self.ensure_open()?;
7486 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7487 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7488 projection_status(connection, kind)
7489 }
7490
7491 #[doc(hidden)]
7492 pub fn has_vector_for_cursor_for_test(&self, cursor: u64) -> Result<bool, EngineError> {
7493 self.ensure_open()?;
7494 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7495 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7496 terminal_state_for_cursor(connection, cursor)
7497 .map(|state| matches!(state.as_deref(), Some("up_to_date")))
7498 .map_err(|_| EngineError::Storage)
7499 }
7500
7501 #[doc(hidden)]
7502 pub fn projection_failure_count_for_test(&self, cursor: u64) -> Result<u64, EngineError> {
7503 self.ensure_open()?;
7504 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7505 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7506 connection
7507 .query_row(
7508 "SELECT COUNT(*) FROM operational_mutations
7509 WHERE collection_name = 'projection_failures'
7510 AND record_key = ?1",
7511 [cursor.to_string()],
7512 |row| row.get::<_, u64>(0),
7513 )
7514 .map_err(|_| EngineError::Storage)
7515 }
7516
7517 #[doc(hidden)]
7518 pub fn set_provenance_row_cap_for_test(&self, cap: Option<u64>) {
7519 self.provenance_row_cap.store(cap.unwrap_or(0), Ordering::Relaxed);
7520 }
7521
7522 #[doc(hidden)]
7523 pub fn provenance_row_count_for_test(&self) -> Result<u64, EngineError> {
7524 self.ensure_open()?;
7525 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7526 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7527 connection
7528 .query_row("SELECT COUNT(*) FROM operational_mutations", [], |row| row.get::<_, u64>(0))
7529 .map_err(|_| EngineError::Storage)
7530 }
7531
7532 #[doc(hidden)]
7533 pub fn oldest_provenance_record_key_for_test(
7534 &self,
7535 collection: &str,
7536 ) -> Result<Option<String>, EngineError> {
7537 self.ensure_open()?;
7538 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7539 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7540 connection
7541 .query_row(
7542 "SELECT record_key FROM operational_mutations
7543 WHERE collection_name = ?1
7544 ORDER BY id
7545 LIMIT 1",
7546 [collection],
7547 |row| row.get::<_, String>(0),
7548 )
7549 .map(Some)
7550 .or_else(|err| match err {
7551 rusqlite::Error::QueryReturnedNoRows => Ok(None),
7552 _ => Err(EngineError::Storage),
7553 })
7554 }
7555
7556 #[doc(hidden)]
7557 pub fn configure_vector_kind_for_test(&self, kind: &str) -> Result<(), EngineError> {
7558 self.ensure_open()?;
7559 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7560 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
7561 connection
7562 .execute(
7563 "INSERT OR REPLACE INTO _fathomdb_vector_kinds(kind, profile, created_at)
7564 VALUES(?1, ?2, 0)",
7565 params![kind, DEFAULT_VECTOR_PROFILE],
7566 )
7567 .map_err(|_| EngineError::Storage)?;
7568 Ok(())
7569 }
7570
7571 /// OPP-12 Phase-1 (0.8.19 Slice 10) — read the writer connection's
7572 /// `PRAGMA secure_delete` (design §3 gap-4). `true` iff the standing
7573 /// connection-open PRAGMA is in effect, so `purge` freelist erasure is
7574 /// complete without a per-purge `VACUUM`.
7575 #[doc(hidden)]
7576 pub fn secure_delete_enabled_for_test(&self) -> Result<bool, EngineError> {
7577 self.ensure_open()?;
7578 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7579 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7580 let value: i64 = connection
7581 .query_row("PRAGMA secure_delete", [], |r| r.get(0))
7582 .map_err(|_| EngineError::Storage)?;
7583 Ok(value != 0)
7584 }
7585
7586 /// OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — `true` iff EVERY
7587 /// reader-pool connection reports `PRAGMA secure_delete = ON`. Broadcasts a
7588 /// per-worker probe; proves the standing flag is set on the non-writer
7589 /// connections (which perform projection/vector-rewrite DELETEs), closing
7590 /// the GDPR-erasure leak codex flagged.
7591 #[cfg(debug_assertions)]
7592 #[doc(hidden)]
7593 pub fn reader_secure_delete_enabled_for_test(&self) -> Result<bool, EngineError> {
7594 self.ensure_open()?;
7595 let per_worker = self.reader_pool.secure_delete_per_worker();
7596 if per_worker.is_empty() {
7597 return Err(EngineError::Storage);
7598 }
7599 Ok(per_worker.iter().all(|&v| v == 1))
7600 }
7601
7602 /// OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — `true` iff a freshly
7603 /// opened projection/runtime connection (`open_runtime_connection`) reports
7604 /// `PRAGMA secure_delete = ON`. The runtime connection performs the
7605 /// vector-rewrite/projection DELETEs, so its freed pages must be scrubbed too.
7606 #[doc(hidden)]
7607 pub fn runtime_secure_delete_enabled_for_test(&self) -> Result<bool, EngineError> {
7608 self.ensure_open()?;
7609 let connection = open_runtime_connection(&self.path).map_err(|_| EngineError::Storage)?;
7610 let value: i64 = connection
7611 .query_row("PRAGMA secure_delete", [], |r| r.get(0))
7612 .map_err(|_| EngineError::Storage)?;
7613 Ok(value != 0)
7614 }
7615
7616 /// EXP-S (0.8.14 Slice 5, D1) — write one canonical node row carrying an
7617 /// explicit structural `row_kind` (leaf/coverage/graph), routing the index
7618 /// projection through the SAME `row_kind -> index-target` dispatch seam
7619 /// (`project_canonical_node_row`) as the production `leaf` write path.
7620 ///
7621 /// This is the internal-only writer for `coverage`/`graph` rows (there is no
7622 /// public SDK surface for `row_kind` in 0.8.14). Cursor assignment preserves
7623 /// the `rowid == write_cursor == cursor` determinism identity. When the row
7624 /// projects into an async vector index, the worker pool is notified so the
7625 /// embed is scheduled exactly as for a normal write.
7626 #[doc(hidden)]
7627 pub fn write_canonical_row_with_kind_for_test(
7628 &self,
7629 kind: &str,
7630 body: &str,
7631 row_kind: RowKind,
7632 ) -> Result<WriteReceipt, EngineError> {
7633 self.ensure_open()?;
7634 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7635 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
7636
7637 // R-20-E3 / design §4 item 6 — this writer BYPASSES `PreparedWrite`, so
7638 // the `SourceId` newtype cannot reach it; before 0.8.20 it inserted a
7639 // literal NULL `source_id` and produced a row that no `excise_source`
7640 // call could reach. Engine-derived rows instead take a reserved
7641 // `_engine:*` provenance, keyed by the structural role that produced
7642 // them, so they are both erasable and distinguishable from caller data.
7643 let engine_provenance = SourceId::engine_derived(row_kind.as_str());
7644
7645 // 0.8.20 Slice 20c — same late enrolment the governed write path takes
7646 // (`Engine::enrol_batch_vector_kinds`), so this internal writer does not
7647 // silently diverge into the false-ready barrier for `coverage` rows. The
7648 // live-embedder precondition is checked here, as that caller does; the
7649 // `row_kind` gate keeps `graph` rows out of the vector registry.
7650 //
7651 // fix-2 (codex §9 [P2]) — including the un-stranding half, so this door
7652 // cannot diverge from the other one either. fix-5 (codex §9 round 4 [P2])
7653 // — and both halves commit as ONE transaction, via the same shared
7654 // `enrol_and_unstrand`.
7655 let unstranded = if self.runtime_embedder.is_some()
7656 && self.vector_kind_needs_enrolment(connection, kind, row_kind)?
7657 {
7658 self.enrol_and_unstrand(connection, &[kind])?
7659 } else {
7660 false
7661 };
7662
7663 let cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
7664 let enqueued = {
7665 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
7666 // 0.8.20 Slice 15b (TC-34) — this writer takes NO validity window, and
7667 // that is deliberate rather than an oversight. It is a `#[doc(hidden)]`
7668 // test-only writer for the internal `coverage`/`graph` row kinds, which
7669 // have no public SDK surface at all (see the doc comment above); the
7670 // caller-facing authoring path is `PreparedWrite::Node`, handled in
7671 // `commit_batch`. Omitting the columns binds NULL — the migration
7672 // step-22 default and the UNBOUNDED reading — so engine-derived rows
7673 // stay valid at every instant, which is the only correct answer for a
7674 // structural row that no caller can address a window to.
7675 tx.execute(
7676 "INSERT INTO canonical_nodes(write_cursor, kind, body, source_id, logical_id, row_kind)
7677 VALUES(?1, ?2, ?3, ?4, NULL, ?5)",
7678 params![cursor, kind, body, engine_provenance.as_str(), row_kind.as_str()],
7679 )
7680 .map_err(|_| EngineError::Storage)?;
7681 let enqueued = project_canonical_node_row(
7682 &tx,
7683 cursor,
7684 kind,
7685 body,
7686 row_kind,
7687 ProjectionPass::Write,
7688 // This #[doc(hidden)] writer inserts with the column DEFAULT
7689 // `state = 'active'` (no state column in its INSERT), so the row
7690 // is always active and its attributes project.
7691 true,
7692 )
7693 .map_err(|_| EngineError::Storage)?;
7694 advance_projection_cursor(&tx).map_err(|_| EngineError::Storage)?;
7695 tx.commit().map_err(|_| EngineError::Storage)?;
7696 enqueued
7697 };
7698 self.next_cursor.store(cursor, Ordering::SeqCst);
7699 if enqueued || unstranded {
7700 self.projection_runtime.notify_new_work();
7701 }
7702 Ok(WriteReceipt { cursor, row_cursors: vec![cursor], dangling_edge_endpoints: 0 })
7703 }
7704
7705 /// EXP-S (0.8.14 Slice 5, D1) — select the active canonical rows carrying a
7706 /// given `row_kind`, returning their `write_cursor`s in cursor order. Proves
7707 /// the engine can query/select rows by the structural `row_kind` axis.
7708 #[doc(hidden)]
7709 pub fn canonical_rows_with_row_kind_for_test(
7710 &self,
7711 row_kind: RowKind,
7712 ) -> Result<Vec<u64>, EngineError> {
7713 self.ensure_open()?;
7714 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7715 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7716 let mut stmt = connection
7717 .prepare(
7718 "SELECT write_cursor FROM canonical_nodes
7719 WHERE row_kind = ?1 AND superseded_at IS NULL
7720 ORDER BY write_cursor",
7721 )
7722 .map_err(|_| EngineError::Storage)?;
7723 let cursors = stmt
7724 .query_map(params![row_kind.as_str()], |row| row.get::<_, u64>(0))
7725 .map_err(|_| EngineError::Storage)?
7726 .collect::<rusqlite::Result<Vec<u64>>>()
7727 .map_err(|_| EngineError::Storage)?;
7728 Ok(cursors)
7729 }
7730
7731 /// F5 (0.8.14 Slice 10) — the fielded BM25F lexical arm over
7732 /// `search_index_v2`. Recalls candidate rows through the FTS5 index
7733 /// (`search_index_v2 MATCH`) and scores them with a textbook BM25F using the
7734 /// plan's tunable per-field `weights` and tunable `b`/`k1`, returning
7735 /// `(write_cursor, score)` in descending score order (write_cursor asc as the
7736 /// deterministic tiebreak). Superseded node versions are excluded (join to
7737 /// `canonical_nodes WHERE superseded_at IS NULL`).
7738 ///
7739 /// This is the engine-internal `BM25fQueryPlan` compiler path (`ADR-0.8.1`
7740 /// §3.2); there is no public Py/TS SDK surface this release. The score is
7741 /// computed in-engine (not via SQLite's `bm25()`, which cannot express a
7742 /// tunable `b`); the FTS5 index remains load-bearing for candidate recall.
7743 #[doc(hidden)]
7744 pub fn bm25f_search(
7745 &self,
7746 query: &str,
7747 plan: &Bm25fQueryPlan,
7748 ) -> Result<Vec<(u64, f64)>, EngineError> {
7749 self.ensure_open()?;
7750 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7751 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7752 bm25f_search_inner(connection, query, plan).map_err(|_| EngineError::Storage)
7753 }
7754
7755 /// Embed arbitrary text with the engine's configured runtime embedder,
7756 /// returning the raw (un-centered) vector.
7757 ///
7758 /// This is the read-path embed primitive: it mirrors the search
7759 /// query-embedding path — a single, direct [`Embedder::embed`] call. The
7760 /// per-`embed()` watchdog/circuit-breaker guards only the bulk
7761 /// projection/write path (many embeds, fault isolation), not single
7762 /// read-side embeds, so a direct call is consistent with how a query is
7763 /// embedded. Callers get vectors under the engine's *pinned* embedder
7764 /// identity (`fathomdb-bge-small-en-v1.5` by default) rather than a
7765 /// parallel, possibly-divergent embedder.
7766 ///
7767 /// Returns [`EngineError::EmbedderNotConfigured`] if the engine was opened
7768 /// without an embedder (`use_default_embedder = false`).
7769 pub fn embed_text(&self, text: &str) -> Result<Vec<f32>, EngineError> {
7770 self.ensure_open()?;
7771 let embedder =
7772 self.runtime_embedder.as_ref().cloned().ok_or(EngineError::EmbedderNotConfigured)?;
7773 embedder.embed(text).map_err(map_runtime_embedder_error)
7774 }
7775
7776 #[doc(hidden)]
7777 pub fn write_vector_for_test(
7778 &self,
7779 kind: &str,
7780 text: &str,
7781 ) -> Result<WriteReceipt, EngineError> {
7782 self.ensure_open()?;
7783 let embedder =
7784 self.runtime_embedder.as_ref().cloned().ok_or(EngineError::EmbedderNotConfigured)?;
7785
7786 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7787 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
7788 if !kind_is_vector_indexed(connection, kind)? {
7789 return Err(EngineError::KindNotVectorIndexed);
7790 }
7791
7792 let expected = default_profile_dimension(connection)?;
7793 ensure_vector_partition(connection, expected).map_err(|_| EngineError::Storage)?;
7794 let vector = embedder.embed(text).map_err(map_runtime_embedder_error)?;
7795 let actual = u32::try_from(vector.len()).unwrap_or(u32::MAX);
7796 if actual != expected {
7797 return Err(EngineError::EmbedderDimensionMismatch { expected, actual });
7798 }
7799
7800 let cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
7801 // EU-5a2 mean-centering apply path (write side). f32 BLOB stored
7802 // is ALWAYS un-centered; the sign-quant input is the centered
7803 // vector iff the identity is MC-required AND a `mean_vec` is
7804 // pinned. NoopEmbedder identity (the only EU-5a2 live one) is
7805 // NOT MC-required, so this is a no-op until EU-5b's flip.
7806 let blob = encode_vector_blob(&vector);
7807 let bin_blob = if identity_requires_mean_centering(&self.runtime_embedder_identity) {
7808 match read_pinned_mean_vec(connection, self.runtime_embedder_identity.dimension)? {
7809 Some(mean) => encode_vector_blob(&subtract_mean(&vector, &mean)),
7810 None => blob.clone(),
7811 }
7812 } else {
7813 blob.clone()
7814 };
7815 let source_type = resolve_source_type(kind)?;
7816 let now_unix =
7817 SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
7818
7819 // EU-5b — feed the streaming mean accumulator (if live) and detect
7820 // a threshold-crossing pin. The mean materialization, pre-pin
7821 // re-quantize, and `MeanVecPinned` event emission all happen in
7822 // the SAME SQLite transaction as the row INSERT.
7823 let pin_event = {
7824 let runtime = &self.projection_runtime.shared;
7825 let mut accumulator =
7826 runtime.mean_accumulator.lock().map_err(|_| EngineError::Storage)?;
7827 if let Some(acc) = accumulator.as_mut() {
7828 acc.add(&vector);
7829 if acc.count() >= MEAN_VEC_PIN_THRESHOLD {
7830 let mean = acc.materialize();
7831 *accumulator = None;
7832 Some(mean)
7833 } else {
7834 None
7835 }
7836 } else {
7837 None
7838 }
7839 };
7840
7841 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
7842 tx.execute(
7843 "INSERT INTO _fathomdb_vector_rows(rowid, kind, write_cursor) VALUES(?1, ?2, ?3)",
7844 params![cursor, kind, cursor],
7845 )
7846 .map_err(|_| EngineError::Storage)?;
7847 // Slice 10 / G10 — `status` ships an empty-string sentinel only: vec0 TEXT
7848 // metadata columns are NOT NULL-able ("Expected text for TEXT metadata
7849 // column"), so the "no real population yet" state is `''`, not NULL.
7850 //
7851 // 0.8.20 Slice 15e — this test helper carries no JSON body, so every live
7852 // `filterable` `attr_<hex>` column binds the `''` sentinel (an empty body
7853 // extracts nothing). When the table has no attr columns the statement is
7854 // byte-identical to the shipped form.
7855 let (cols_sql, ph_sql, attr_vals) =
7856 vector_attr_insert_fragments(&tx, "", 7).map_err(|_| EngineError::Storage)?;
7857 let sql = format!(
7858 "INSERT INTO vector_default(
7859 rowid, embedding, embedding_bin, source_type, kind, created_at, status{cols_sql}
7860 ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, ''{ph_sql})"
7861 );
7862 let mut pv: Vec<rusqlite::types::Value> = vec![
7863 rusqlite::types::Value::Integer(cursor as i64),
7864 rusqlite::types::Value::Blob(blob.clone()),
7865 rusqlite::types::Value::Blob(bin_blob.clone()),
7866 rusqlite::types::Value::Text(source_type.to_string()),
7867 rusqlite::types::Value::Text(kind.to_string()),
7868 rusqlite::types::Value::Integer(now_unix),
7869 ];
7870 pv.extend(attr_vals);
7871 tx.execute(&sql, rusqlite::params_from_iter(pv.iter()))
7872 .map_err(|_| EngineError::Storage)?;
7873
7874 let mut emitted_event: Option<EmbedderEvent> = None;
7875 if let Some(mean_vec) = pin_event {
7876 let mean_bytes = encode_vector_blob(&mean_vec);
7877 tx.execute(
7878 "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
7879 params![mean_bytes],
7880 )
7881 .map_err(|_| EngineError::Storage)?;
7882 // Read all pre-pin (rowid, embedding) and re-quantize within
7883 // the same tx. The just-inserted row above is also covered.
7884 let rows: Vec<(i64, Vec<u8>)> = {
7885 let mut statement = tx
7886 .prepare("SELECT rowid, embedding FROM vector_default ORDER BY rowid")
7887 .map_err(|_| EngineError::Storage)?;
7888 let mapped = statement
7889 .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)))
7890 .map_err(|_| EngineError::Storage)?;
7891 let mut out = Vec::new();
7892 for r in mapped {
7893 out.push(r.map_err(|_| EngineError::Storage)?);
7894 }
7895 out
7896 };
7897 let (doc_count, _) = run_pin_and_requantize_pass(&tx, &rows, &mean_vec)?;
7898 emitted_event = Some(EmbedderEvent::MeanVecPinned {
7899 dim: u32::try_from(mean_vec.len()).unwrap_or(u32::MAX),
7900 doc_count,
7901 });
7902 }
7903
7904 tx.commit().map_err(|_| EngineError::Storage)?;
7905
7906 if let Some(ev) = emitted_event {
7907 if let Ok(mut events) = self.projection_runtime.shared.pending_events.lock() {
7908 events.push(ev);
7909 }
7910 }
7911
7912 self.next_cursor.store(cursor, Ordering::SeqCst);
7913 // G8 — this path (embedder-profile pin) commits no canonical edges, so
7914 // no endpoint can dangle.
7915 Ok(WriteReceipt { cursor, row_cursors: vec![cursor], dangling_edge_endpoints: 0 })
7916 }
7917
7918 /// EU-5b test seam — drain MeanVecPinned events queued by the
7919 /// projection-commit pin transaction since the last drain. Production
7920 /// callers consume these via `OpenReport.embedder_events`; this seam
7921 /// exists so the EU-5b RED test can observe the live emission.
7922 #[doc(hidden)]
7923 pub fn drain_mean_centering_events_for_test(&self) -> Result<Vec<EmbedderEvent>, EngineError> {
7924 self.ensure_open()?;
7925 let mut events = self
7926 .projection_runtime
7927 .shared
7928 .pending_events
7929 .lock()
7930 .map_err(|_| EngineError::Storage)?;
7931 let out = std::mem::take(&mut *events);
7932 Ok(out)
7933 }
7934
7935 /// 0.7.2 PR-2b — NON-test observation seam. Drains and returns every
7936 /// `EmbedderEvent` queued since the last drain (mean pin, manual mean
7937 /// recompute). Production callers use
7938 /// this to observe the synchronous recompute work; events are queued
7939 /// only AFTER the recompute transaction is durable, so a rolled-back
7940 /// recompute never surfaces. Mirrors the at-open
7941 /// `OpenReport.embedder_events` channel for the steady-state path.
7942 pub fn drain_embedder_events(&self) -> Result<Vec<EmbedderEvent>, EngineError> {
7943 self.ensure_open()?;
7944 let mut events = self
7945 .projection_runtime
7946 .shared
7947 .pending_events
7948 .lock()
7949 .map_err(|_| EngineError::Storage)?;
7950 Ok(std::mem::take(&mut *events))
7951 }
7952
7953 /// 0.7.2 PR-2b — explicit `doctor recompute-mean` path. Re-derives the
7954 /// pinned corpus mean from the current `vector_default` rows and
7955 /// re-quantizes every row, SYNCHRONOUSLY in one transaction. ALWAYS
7956 /// allowed at any corpus size — this is the ONLY mean-refresh path as of
7957 /// 0.7.2 (the automatic in-ingest drift detector was carved out / deferred
7958 /// to 0.8.x; see `dev/design/embedder.md` §0.3).
7959 ///
7960 /// Serializes against the projection workers via `commit_gate` so the
7961 /// re-quantize sees a totally-ordered history, exactly like the at-pin
7962 /// commit. Publishes a `MeanVecRecomputed { trigger: Manual }` event
7963 /// only after the transaction is durable. No-op-safe on a non-MC
7964 /// identity (returns `EmbedderNotConfigured` rather than corrupting an
7965 /// un-centered workspace).
7966 #[cfg(feature = "operator")]
7967 pub fn recompute_mean(&self) -> Result<MeanRecomputeReport, EngineError> {
7968 self.ensure_open()?;
7969 let identity = self.runtime_embedder_identity.clone();
7970 if !identity_requires_mean_centering(&identity) {
7971 return Err(EngineError::EmbedderNotConfigured);
7972 }
7973 let report = {
7974 // Hold the commit gate for the whole recompute so no projection
7975 // worker commit interleaves with the re-quantize.
7976 let _gate = self
7977 .projection_runtime
7978 .shared
7979 .commit_gate
7980 .lock()
7981 .unwrap_or_else(|p| p.into_inner());
7982 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7983 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
7984 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
7985 #[cfg(debug_assertions)]
7986 let fail = self
7987 .projection_runtime
7988 .shared
7989 .force_recompute_failure
7990 .swap(false, Ordering::SeqCst);
7991 #[cfg(not(debug_assertions))]
7992 let fail = false;
7993 let report = recompute_mean_in_tx_inner(&tx, &identity, fail)?;
7994 tx.commit().map_err(|_| EngineError::Storage)?;
7995 report
7996 };
7997 // Post-durable-commit publish.
7998 if let Ok(mut events) = self.projection_runtime.shared.pending_events.lock() {
7999 events.push(EmbedderEvent::MeanVecRecomputed {
8000 dim: report.dim,
8001 doc_count: report.doc_count_requantized,
8002 trigger: MeanRecomputeTrigger::Manual,
8003 });
8004 }
8005 Ok(report)
8006 }
8007
8008 /// 0.7.2 PR-2bc S1 fix-1 test seam — RAISE the phase-2 rerank `LIMIT`
8009 /// above the production `SEARCH_RERANK_LIMIT` (10) so the recall harness
8010 /// can pull top-(10+slack) and exclude the self-retrieving query-source
8011 /// doc before truncating to 10. The search path clamps the stored value
8012 /// to the production floor, so a test can never shrink search fanout
8013 /// below production semantics. Production reads the same atomic and never
8014 /// consults any env var.
8015 #[doc(hidden)]
8016 pub fn set_search_limit_for_test(&self, limit: usize) {
8017 self.projection_runtime.shared.search_limit_override.store(limit, Ordering::SeqCst);
8018 }
8019
8020 /// Slice 10 / G12-recency test seam — flip the dedicated recency-reweight
8021 /// flag (off by default). The reweight runs AFTER bit-KNN on the fused hits;
8022 /// it is never a vec0 predicate and is NOT `fusion_mode`.
8023 #[doc(hidden)]
8024 pub fn set_recency_reweight_enabled_for_test(&self, enabled: bool) {
8025 self.projection_runtime.shared.recency_reweight_enabled.store(enabled, Ordering::SeqCst);
8026 }
8027
8028 /// 0.8.16 Slice 5 / F9 test seam — flip the dedicated importance/confidence
8029 /// reweight flag (off by default). The reweight runs AFTER bit-KNN + RRF on
8030 /// the fused hits (multiplicative-on-fused, `NULL ⇒ neutral`); it is never a
8031 /// vec0 predicate and is NOT `fusion_mode`. Mirrors
8032 /// `set_recency_reweight_enabled_for_test`.
8033 #[doc(hidden)]
8034 pub fn set_importance_reweight_enabled_for_test(&self, enabled: bool) {
8035 self.projection_runtime.shared.importance_reweight_enabled.store(enabled, Ordering::SeqCst);
8036 }
8037
8038 /// 0.8.16 Slice 5 / F9 (R-F9-1) — set the caller-supplied `importance` ranking
8039 /// scalar on the `canonical_nodes` row identified by `write_cursor` (the
8040 /// interim id `SearchHit.id` carries). Validates `importance ∈ [0.0, 1.0]`,
8041 /// mirroring the existing `canonical_edges.confidence` write-path check —
8042 /// an out-of-range value is a deterministic [`EngineError::WriteValidation`].
8043 ///
8044 /// The 3-way sentinel: NOT calling this leaves the column `NULL` (never
8045 /// assigned = graceful-absent, ranks NEUTRAL); `0.0` is the explicit floor;
8046 /// `(0.0, 1.0]` is an explicit importance. Importance is a caller-supplied
8047 /// scalar — the engine does NOT compute graph-centrality importance (ADR §4
8048 /// non-goal). Engine-internal minimal surface for this keystone; SDK (Py/TS)
8049 /// exposure is a Slice-40 concern.
8050 pub fn write_node_importance(
8051 &self,
8052 write_cursor: u64,
8053 importance: f64,
8054 ) -> Result<(), EngineError> {
8055 if !importance.is_finite() || !(0.0..=1.0).contains(&importance) {
8056 return Err(EngineError::WriteValidation);
8057 }
8058 self.ensure_open()?;
8059 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8060 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8061 connection
8062 .execute(
8063 "UPDATE canonical_nodes SET importance = ?1 WHERE write_cursor = ?2",
8064 params![importance, write_cursor],
8065 )
8066 .map_err(|_| EngineError::Storage)?;
8067 Ok(())
8068 }
8069
8070 /// 0.8.16 Slice 5 / F9 (R-F9-1) — read back the `importance` scalar for the
8071 /// `canonical_nodes` row identified by `write_cursor`. `None` = SQL `NULL` =
8072 /// never assigned (graceful-absent). The reciprocal read for
8073 /// [`Engine::write_node_importance`].
8074 pub fn node_importance(&self, write_cursor: u64) -> Result<Option<f64>, EngineError> {
8075 self.ensure_open()?;
8076 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8077 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8078 connection
8079 .query_row(
8080 "SELECT importance FROM canonical_nodes WHERE write_cursor = ?1 LIMIT 1",
8081 params![write_cursor],
8082 |r| r.get::<_, Option<f64>>(0),
8083 )
8084 .map_err(|_| EngineError::Storage)
8085 }
8086
8087 /// GA-2 / Slice-40 (◆ B-1) measurement seam — make `search()` return the
8088 /// pre-fusion VECTOR-branch ranking (the ANN+ bit-KNN K=192 + f32 rerank
8089 /// signal) instead of the unconditional RRF-fused result, so the eu7 recall
8090 /// gate (AC-075) can measure ANN-quantization FIDELITY — vector top-10 vs
8091 /// the exact-f32 VECTOR top-10 ground truth — in isolation. Off by default;
8092 /// never set on any production path. This is NOT a `fusion_mode` knob:
8093 /// production RRF fusion stays unconditional and `fuse_rrf`/`rerank_fused`/
8094 /// recency are unchanged. Mirrors `set_recency_reweight_enabled_for_test`
8095 /// (release-available, since eu7 runs in `--release`).
8096 #[doc(hidden)]
8097 pub fn set_vector_stage_only_for_test(&self, enabled: bool) {
8098 self.projection_runtime.shared.vector_stage_only_for_test.store(enabled, Ordering::SeqCst);
8099 }
8100
8101 /// 0.7.2 PR-2b test seam — arm a one-shot fault inside the NEXT
8102 /// `recompute_mean` so it errors after the `mean_vec` UPDATE but before
8103 /// the re-quantize completes. Proves the recompute tx rolls back whole.
8104 #[doc(hidden)]
8105 #[cfg(debug_assertions)]
8106 pub fn force_next_recompute_failure_for_test(&self) {
8107 self.projection_runtime.shared.force_recompute_failure.store(true, Ordering::SeqCst);
8108 }
8109
8110 #[doc(hidden)]
8111 pub fn vector_row_count_for_test(&self) -> Result<u64, EngineError> {
8112 self.ensure_open()?;
8113 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8114 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8115 connection
8116 .query_row("SELECT COUNT(*) FROM vector_default", [], |row| row.get::<_, u64>(0))
8117 .map_err(|_| EngineError::Storage)
8118 }
8119
8120 #[doc(hidden)]
8121 pub fn read_vector_blob_for_test(&self, rowid: i64) -> Result<Vec<u8>, EngineError> {
8122 self.ensure_open()?;
8123 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8124 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8125 connection
8126 .query_row("SELECT embedding FROM vector_default WHERE rowid = ?1", [rowid], |row| {
8127 row.get::<_, Vec<u8>>(0)
8128 })
8129 .map_err(|_| EngineError::Storage)
8130 }
8131
8132 /// 0.8.20 Slice 15e — read a row's raw `embedding_bin` blob bytes (the
8133 /// sign-quantized vector). Used to prove the non-destructive reshape copies the
8134 /// bits VERBATIM (condition #4): the pre-reshape and post-reshape bytes must be
8135 /// byte-identical.
8136 #[doc(hidden)]
8137 pub fn read_vector_bin_for_test(&self, rowid: i64) -> Result<Vec<u8>, EngineError> {
8138 self.ensure_open()?;
8139 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8140 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8141 connection
8142 .query_row(
8143 "SELECT embedding_bin FROM vector_default WHERE rowid = ?1",
8144 [rowid],
8145 |row| row.get::<_, Vec<u8>>(0),
8146 )
8147 .map_err(|_| EngineError::Storage)
8148 }
8149
8150 /// 0.8.20 Slice 15e — run an arbitrary read-only SELECT on the ENGINE
8151 /// connection (which has the vec0 extension loaded, unlike a bare
8152 /// `Connection::open`) and collect column 0 as `i64`. Lets a test run a
8153 /// phase-1-style KNN `MATCH ... {attr clause}` and observe which `rowid`s
8154 /// survive the pre-KNN filter.
8155 #[doc(hidden)]
8156 pub fn query_i64_col_for_test(&self, sql: &str) -> Result<Vec<i64>, EngineError> {
8157 self.ensure_open()?;
8158 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8159 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8160 let mut stmt = connection.prepare(sql).map_err(|_| EngineError::Storage)?;
8161 let rows =
8162 stmt.query_map([], |row| row.get::<_, i64>(0)).map_err(|_| EngineError::Storage)?;
8163 rows.collect::<rusqlite::Result<Vec<i64>>>().map_err(|_| EngineError::Storage)
8164 }
8165
8166 /// 0.8.20 Slice 15e — as [`query_i64_col_for_test`] but collects column 0 as
8167 /// `String` (e.g. an `attr_<hex>` metadata column's stored value).
8168 #[doc(hidden)]
8169 pub fn query_text_col_for_test(&self, sql: &str) -> Result<Vec<String>, EngineError> {
8170 self.ensure_open()?;
8171 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8172 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8173 let mut stmt = connection.prepare(sql).map_err(|_| EngineError::Storage)?;
8174 let rows =
8175 stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|_| EngineError::Storage)?;
8176 rows.collect::<rusqlite::Result<Vec<String>>>().map_err(|_| EngineError::Storage)
8177 }
8178
8179 #[doc(hidden)]
8180 pub fn default_embedder_profile_for_test(&self) -> Result<EmbedderIdentity, EngineError> {
8181 self.ensure_open()?;
8182 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8183 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8184 load_default_profile(connection).map_err(|_| EngineError::Storage)
8185 }
8186
8187 /// Doctor read-only integrity report. Three-section output per
8188 /// AC-043a/b. `opts.full` adds `PRAGMA integrity_check`. `quick` and
8189 /// `round_trip` are accepted but treated as default for 0.6.0.
8190 #[cfg(feature = "operator")]
8191 pub fn check_integrity(
8192 &self,
8193 opts: CheckIntegrityOpts,
8194 ) -> Result<IntegrityReport, EngineError> {
8195 self.ensure_open()?;
8196 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8197 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8198 Ok(IntegrityReport {
8199 physical: physical_section(connection, opts.full),
8200 logical: logical_section(connection),
8201 semantic: semantic_section(connection),
8202 })
8203 }
8204
8205 /// Doctor bit-preserving export. Runs `VACUUM INTO` to produce a
8206 /// self-contained SQLite file at `out`, computes SHA-256 of the
8207 /// resulting bytes, and writes a JSON manifest at `manifest`. Per
8208 /// AC-039a/b.
8209 #[cfg(feature = "operator")]
8210 pub fn safe_export(
8211 &self,
8212 out: &Path,
8213 manifest: &Path,
8214 ) -> Result<SafeExportArtifact, EngineError> {
8215 self.ensure_open()?;
8216 {
8217 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8218 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8219 let target = out.to_string_lossy().to_string();
8220 connection
8221 .execute("VACUUM INTO ?1", params![target])
8222 .map_err(|_| EngineError::Storage)?;
8223 }
8224 let bytes = std::fs::read(out).map_err(|_| EngineError::Storage)?;
8225 let digest = sha2::Sha256::digest(&bytes);
8226 let sha256_hex = hex_encode(digest.as_slice());
8227 let export_abs = out.canonicalize().unwrap_or_else(|_| out.to_path_buf());
8228 let manifest_json = serde_json::json!({
8229 "export_path": export_abs.to_string_lossy(),
8230 "sha256": sha256_hex,
8231 "byte_count": bytes.len() as u64,
8232 });
8233 let manifest_bytes =
8234 serde_json::to_vec_pretty(&manifest_json).map_err(|_| EngineError::Storage)?;
8235 std::fs::write(manifest, &manifest_bytes).map_err(|_| EngineError::Storage)?;
8236 Ok(SafeExportArtifact {
8237 export_path: out.to_path_buf(),
8238 manifest_path: manifest.to_path_buf(),
8239 manifest_sha256: sha256_hex,
8240 })
8241 }
8242
8243 /// Operator regenerate workflow per `dev/design/projections.md`
8244 /// § Regenerate workflow. Drains in-flight projection work, then
8245 /// truncates FTS5 + vec0 shadow rows, resets the projection cursor,
8246 /// and lets the scheduler re-enqueue every canonical row. Durable
8247 /// `projection_failures` audit rows are preserved per design. AC-044
8248 /// + AC-063c.
8249 #[cfg(feature = "operator")]
8250 pub fn rebuild_projections(&self) -> Result<RebuildReport, EngineError> {
8251 self.ensure_open()?;
8252 self.run_rebuild(true, RebuildKind::Projections)
8253 }
8254
8255 /// Vec0-only variant of [`Engine::rebuild_projections`]. Leaves
8256 /// FTS5 shadow content untouched; per recovery design,
8257 /// `recover --rebuild-vec0` is the surface for vec0-only repair.
8258 #[cfg(feature = "operator")]
8259 pub fn rebuild_vec0(&self) -> Result<RebuildReport, EngineError> {
8260 self.ensure_open()?;
8261 self.run_rebuild(false, RebuildKind::Vec0)
8262 }
8263
8264 /// Phase 9 Pack B / AC-042 source trace. Returns the canonical-row
8265 /// id set produced by `source_id`, ordered by `write_cursor`. Empty
8266 /// string is not a valid `source_id`; rows with NULL `source_id`
8267 /// are excluded from every result.
8268 #[cfg(feature = "operator")]
8269 pub fn trace_source_ref(&self, source_id: &str) -> Result<TraceReport, EngineError> {
8270 self.ensure_open()?;
8271 if source_id.is_empty() {
8272 return Err(EngineError::WriteValidation);
8273 }
8274 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8275 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8276
8277 let mut events: Vec<TraceEvent> = Vec::new();
8278 let mut nodes = connection
8279 .prepare(
8280 "SELECT write_cursor, kind FROM canonical_nodes WHERE source_id = ?1
8281 ORDER BY write_cursor",
8282 )
8283 .map_err(|_| EngineError::Storage)?;
8284 let node_rows = nodes
8285 .query_map([source_id], |row| {
8286 Ok(TraceEvent {
8287 write_cursor: row.get::<_, i64>(0)? as u64,
8288 kind: row.get::<_, String>(1)?,
8289 table: "canonical_nodes",
8290 })
8291 })
8292 .map_err(|_| EngineError::Storage)?;
8293 for row in node_rows {
8294 events.push(row.map_err(|_| EngineError::Storage)?);
8295 }
8296
8297 let mut edges = connection
8298 .prepare(
8299 "SELECT write_cursor, kind FROM canonical_edges WHERE source_id = ?1
8300 ORDER BY write_cursor",
8301 )
8302 .map_err(|_| EngineError::Storage)?;
8303 let edge_rows = edges
8304 .query_map([source_id], |row| {
8305 Ok(TraceEvent {
8306 write_cursor: row.get::<_, i64>(0)? as u64,
8307 kind: row.get::<_, String>(1)?,
8308 table: "canonical_edges",
8309 })
8310 })
8311 .map_err(|_| EngineError::Storage)?;
8312 for row in edge_rows {
8313 events.push(row.map_err(|_| EngineError::Storage)?);
8314 }
8315
8316 events.sort_by_key(|e| e.write_cursor);
8317 Ok(TraceReport { source_ref: source_id.to_string(), events })
8318 }
8319
8320 /// OPP-12 Phase-1 (0.8.19 Slice 10) — resolve a lifecycle-verb id argument to
8321 /// the BARE `logical_id` it addresses, enforcing `Logical`(`l:`)-only
8322 /// addressability (design §3). An untagged string is taken as a bare
8323 /// `logical_id` (the `l:` form); an explicit `l:`-prefixed string is stripped
8324 /// to its value; a `Content`(`h:`) or `Passage`(`p:`) id is a typed
8325 /// [`EngineError::NotLifecycleAddressable`] refusal (never a panic / no-op).
8326 fn resolve_lifecycle_target(id: &str) -> Result<String, EngineError> {
8327 match IdSpace::parse(id) {
8328 Some(parsed) => match parsed.space {
8329 IdSpaceKind::Logical => Ok(parsed.value),
8330 other => Err(EngineError::NotLifecycleAddressable { id_space: other }),
8331 },
8332 // Untagged — no id-space prefix; treat as a bare logical_id (l: space).
8333 None => Ok(id.to_string()),
8334 }
8335 }
8336
8337 /// OPP-12 Phase-1 (0.8.19 Slice 10, R-TR-1/2) — move a governed node between
8338 /// existence states per the engine-enforced legal-transition table (design
8339 /// §2): promote `pending→active`, reject `pending→deleted`, soft-delete
8340 /// `active→deleted`, undelete `deleted→active`. `to_state` is a full
8341 /// [`LifecycleState`], but `Pending` (create-time only) and `Purged`
8342 /// (`purge`-only) are never legal `transition` targets, nor are self-loops or
8343 /// any move from a non-existent/`purged` row — each returns a typed
8344 /// [`EngineError::IllegalTransition`] enumerating the legal targets.
8345 ///
8346 /// `reason` semantics (design §3 gap-6): promote/undelete CLEAR `reason` to
8347 /// `NULL` (the row is admitted; no standing cause); reject/soft-delete SET
8348 /// `reason` to the supplied value (`NULL` allowed but the delete-family
8349 /// expects it). `reason` is advisory — the engine never interprets it.
8350 ///
8351 /// Keys on the BARE `logical_id` (`l:` space only); a `Content`(`h:`) or
8352 /// `Passage`(`p:`) id raises [`EngineError::NotLifecycleAddressable`].
8353 /// The state flip mutates the single active (`superseded_at IS NULL`) row; a
8354 /// `deleted` row STAYS node-FTS / vector indexed (gap-5) — only the
8355 /// `state='active'` default filter excludes those shadows, so an undelete
8356 /// needs no re-projection there.
8357 ///
8358 /// 0.8.20 Slice 15d fix-2 [P2] — the row-owned ATTRIBUTE projection
8359 /// (`canonical_attributes` / `property_search_index`) is the exception: it has
8360 /// NO read-side lifecycle filter (the property-FTS5 table cannot carry one), so
8361 /// it is maintained AT REST to track the backfill's set
8362 /// (projected ⟺ active ∧ non-superseded). Promote/undelete PROJECT the declared
8363 /// attributes; soft-delete PURGES them; reject is a no-op.
8364 pub fn transition(
8365 &self,
8366 logical_id: &str,
8367 to_state: LifecycleState,
8368 reason: Option<String>,
8369 ) -> Result<(), EngineError> {
8370 self.ensure_open()?;
8371 let lid = Self::resolve_lifecycle_target(logical_id)?;
8372
8373 // Settle in-flight projection work first: the async projection worker
8374 // commits vector/FTS shadows on its OWN connection, so a state flip issued
8375 // while a worker holds the write lock would SQLITE_BUSY. Draining
8376 // (unfrozen so any unprojected row completes) leaves the worker idle; a
8377 // bare state flip enqueues no new projection work.
8378 //
8379 // Slice 40 B3 aligns the worker with `commit_batch`:
8380 // `commit_projection_outcomes` acquires `BEGIN IMMEDIATE` before its reads.
8381 // This drain remains load-bearing because the worker owns a separate
8382 // connection while this state flip still reads before its own write; it
8383 // keeps that deferred transaction out of the worker's write window.
8384 self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS)?;
8385
8386 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8387 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8388 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8389
8390 // The lifecycle state lives on the single active (superseded_at IS NULL)
8391 // version; a `deleted` row is still that active version, just flagged.
8392 // fix-2 [P2] — also read its `write_cursor` + `body` so the row-owned
8393 // attribute projection can be maintained after the state flip.
8394 let current: Option<(String, i64, String)> = tx
8395 .query_row(
8396 "SELECT state, write_cursor, body FROM canonical_nodes \
8397 WHERE logical_id = ?1 AND superseded_at IS NULL",
8398 params![lid],
8399 |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, String>(2)?)),
8400 )
8401 .optional()
8402 .map_err(|_| EngineError::Storage)?;
8403
8404 // A missing active row is an absent/purged node — the terminal `Purged`
8405 // state for legality purposes (nothing is a legal target from there).
8406 let from_state = match ¤t {
8407 Some((s, _, _)) => LifecycleState::from_str_opt(s).ok_or(EngineError::Storage)?,
8408 None => LifecycleState::Purged,
8409 };
8410
8411 if !is_legal_transition_move(from_state, to_state) {
8412 return Err(EngineError::IllegalTransition {
8413 from_state,
8414 to_state,
8415 legal: from_state.legal_next_states(),
8416 });
8417 }
8418
8419 // Admit (promote/undelete) → clear reason; exclude (reject/soft-delete) →
8420 // set the supplied reason. `to_state` is Active or Deleted here.
8421 let new_reason: Option<String> = match to_state {
8422 LifecycleState::Active => None,
8423 _ => reason,
8424 };
8425 tx.execute(
8426 "UPDATE canonical_nodes SET state = ?1, reason = ?2 \
8427 WHERE logical_id = ?3 AND superseded_at IS NULL",
8428 params![to_state.as_str(), new_reason, lid],
8429 )
8430 .map_err(|_| EngineError::Storage)?;
8431
8432 // fix-2 [P2] — maintain the row-owned attribute projection so it keeps
8433 // tracking the backfill's set (projected ⟺ active ∧ non-superseded). The
8434 // transitioned row is the single non-superseded version, so the invariant
8435 // reduces to `projected ⟺ to_state == Active`. We PURGE unconditionally
8436 // (idempotent — a no-op on the never-projected pending / already-purged
8437 // deleted arms) then RE-PROJECT when landing `Active`. This covers every
8438 // legal move: promote (pending→active) projects the withheld attributes;
8439 // soft-delete (active→deleted) purges; undelete (deleted→active)
8440 // re-projects; reject (pending→deleted) is a no-op. The property tables
8441 // (`canonical_attributes` / `property_search_index`) carry NO read-side
8442 // lifecycle filter — unlike node-FTS / vector shadows, which the canonical
8443 // read path already excludes when non-active — so they MUST be maintained
8444 // at rest, the same rationale as fix-1's purge-on-supersede. Node-FTS /
8445 // vector shadows are deliberately left intact (gap-5: a deleted row STAYS
8446 // indexed; only the `state='active'` default read filter hides it).
8447 if let Some((cursor, body)) = current.as_ref().map(|(_, c, b)| (*c, b.as_str())) {
8448 purge_row_projections_for_cursor_in(
8449 &tx,
8450 cursor,
8451 &[ProjectionClass::Attribute, ProjectionClass::PropertyFts],
8452 )
8453 .map_err(|_| EngineError::Storage)?;
8454 if matches!(to_state, LifecycleState::Active) {
8455 project_node_attributes(&tx, cursor, body).map_err(|_| EngineError::Storage)?;
8456 }
8457 }
8458 tx.commit().map_err(|_| EngineError::Storage)?;
8459 self.counters.record_admin();
8460 Ok(())
8461 }
8462
8463 /// 0.8.20 Slice 15d (R-20-PR / C-1) — the projection registry as a
8464 /// DECLARATIVE, IDEMPOTENT apply. The engine is the SOLE projection authority
8465 /// (Q3): it diffs the supplied `specs` against the durable registry and
8466 /// backfills the difference in ONE transaction. Cheap projections
8467 /// (`filterable`, `searchable→FTS`) are built same-transaction; `rankable`
8468 /// and the `searchable→vector` sub-target are PERSISTED but deferred (F9 /
8469 /// Slice 20) — declaring them never errors (graceful-absent, Q6a).
8470 ///
8471 /// 0.8.20 Slice 23 (`R-20-SV`) — **SPEC VALIDATION.** A spec that carries an
8472 /// `fts` or `vector` sub-object WITHOUT [`ProjectionRole::Searchable`] is an
8473 /// INVALID SPEC and is refused with [`EngineError::WriteValidation`] (HITL
8474 /// 2026-07-24; see [`apply_projection_config`] for the full rationale). A
8475 /// rejected request is a TOTAL no-op. `read_projections` is unaffected — it
8476 /// is a pure read — so a LEGACY row in that shape still reports verbatim but
8477 /// can no longer be re-applied.
8478 ///
8479 /// `drop` is EXPLICIT (C3, `api-surface.md:27`): omission of a live
8480 /// projection from `specs` does NOT drop it; removal requires naming it in
8481 /// `drop`. An incompatible/destructive change to a live projection that is
8482 /// NOT in `drop` is refused with [`EngineError::ProjectionDestructive`], the
8483 /// destructive delta surfaced — never silent data loss. Re-applying an
8484 /// unchanged spec diffs to a no-op ([`ProjectionDelta::unchanged`]).
8485 ///
8486 /// Pair with [`Engine::read_projections`] to see current state before
8487 /// applying.
8488 pub fn configure_projections(
8489 &self,
8490 specs: &[ProjectionSpec],
8491 drop: &[String],
8492 ) -> Result<ProjectionDelta, EngineError> {
8493 self.ensure_open()?;
8494 // Settle in-flight async projection work first. The worker commits on its
8495 // own connection with `BEGIN IMMEDIATE`; a backfill issued in that write
8496 // window would SQLITE_BUSY.
8497 self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS)?;
8498
8499 // 0.8.20 Slice 20c (R-20-DR remainder) — the backfill is gated on a LIVE
8500 // embedder. With `EmbedderChoice::None` there is no dense arm, so the
8501 // declaration persists and DEFERS (Q6a graceful-absent, exactly like
8502 // `rankable`) rather than queueing embeds that could only retry to a
8503 // `failed` terminal. Re-applying the same spec in a session that HAS an
8504 // embedder grafts the backfill on — the shipped graceful-graft contract.
8505 let dense_arm_live = self.runtime_embedder.is_some();
8506 let (delta, enqueued_backfill) = {
8507 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8508 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8509 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8510 let applied = apply_projection_config(&tx, specs, drop, dense_arm_live)?;
8511 tx.commit().map_err(|_| EngineError::Storage)?;
8512 applied
8513 };
8514 // 0.8.20 Slice 20c (R-20-DR remainder) — the C4 rider's second half. The
8515 // enrolment + terminal-clear committed above; now WAKE the dispatcher, or
8516 // it sleeps on `pending_scan == false` and the very next `drain` burns its
8517 // whole timeout waiting for work nobody scheduled. `drain` itself stays
8518 // PASSIVE (a barrier, never a trigger) — the notify belongs here, on the
8519 // enqueue side. Deliberately after the connection guard is dropped: the
8520 // dispatcher immediately opens its own connection to scan.
8521 if enqueued_backfill {
8522 self.projection_runtime.notify_new_work();
8523 }
8524 self.counters.record_admin();
8525 Ok(delta)
8526 }
8527
8528 /// 0.8.20 Slice 15d (R-20-PR) — read the current projection registry (C5
8529 /// introspection: `read.projections`). Returns every declared
8530 /// [`ProjectionSpec`] sorted by name, so a caller can inspect current state
8531 /// (and the destructive delta a change would cause) BEFORE applying. Pure
8532 /// read; never mutates.
8533 ///
8534 /// 0.8.20 Slice 20 (R-20-DR) — this is ALSO the surface that populates the
8535 /// engine-set [`ProjectionVector::dense_readiness`] READ METADATA. It is
8536 /// derived here, on the way out (see [`derive_dense_readiness`]); the durable
8537 /// registry stores no readiness. Only a spec that declares the
8538 /// `searchable→vector` sub-object carries one — `filterable` and
8539 /// `searchable→FTS` are same-transaction and have no readiness axis.
8540 pub fn read_projections(&self) -> Result<Vec<ProjectionSpec>, EngineError> {
8541 self.ensure_open()?;
8542 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8543 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8544 let registry = load_projection_registry(connection).map_err(|_| EngineError::Storage)?;
8545 // Derived ONCE per call, so every vector projection in one read reports a
8546 // consistent readiness (they are all served by the one vector pipeline).
8547 // Skipped entirely when no vector projection is declared, keeping the
8548 // no-vector default path free of the extra probe.
8549 let mut readiness: Option<DenseReadiness> = None;
8550 let mut specs: Vec<ProjectionSpec> =
8551 registry.iter().map(|(name, stored)| stored.to_spec(name)).collect();
8552 for spec in &mut specs {
8553 if let Some(vector) = spec.vector.as_mut() {
8554 let value = match readiness {
8555 Some(value) => value,
8556 None => {
8557 let value = derive_dense_readiness(connection)?;
8558 readiness = Some(value);
8559 value
8560 }
8561 };
8562 vector.dense_readiness = Some(value);
8563 }
8564 }
8565 Ok(specs)
8566 }
8567
8568 /// OPP-12 Phase-1 (0.8.19 Slice 10, R-PG-1/2) — irreversibly hard-erase a
8569 /// governed node. A SEPARATE verb from [`Engine::transition`] (NOT on the
8570 /// `recovery_denylist`). Precondition: DELETED-FIRST — legal only from
8571 /// `deleted` (else a typed [`EngineError::IllegalTransition`] to `purged`);
8572 /// IDEMPOTENT — purging an already-absent/already-purged id is a no-op
8573 /// success. Keys on the bare `logical_id` (`l:` only); `h:`/`p:` →
8574 /// [`EngineError::NotLifecycleAddressable`].
8575 ///
8576 /// In ONE transaction, physically erases every ROW-OWNED target for the node
8577 /// (design §3 / gap-3): all `canonical_nodes` versions; its `search_index`,
8578 /// `search_index_edges`, `search_index_v2` FTS rows; its `vector_default`
8579 /// (vec0) + `_fathomdb_vector_rows` vectors; its `_fathomdb_projection_terminal`
8580 /// bookkeeping; and — CASCADE-REMOVE, no content-free stubs — every
8581 /// `canonical_edges` row touching it (`from_id`/`to_id`) plus those edges'
8582 /// projection shadows. The global/kind-level registries
8583 /// `_fathomdb_projection_state` and `_fathomdb_vector_kinds` are NOT keyed to
8584 /// a node id and are DELIBERATELY untouched.
8585 ///
8586 /// Erasure completeness relies on the standing `PRAGMA secure_delete=ON`
8587 /// (design §3 gap-4) which zeroes every freed page — so no per-purge `VACUUM`.
8588 /// (Freelist content written on a pre-20 DB before `secure_delete` was on is a
8589 /// documented residual; there is no forced migration-time `VACUUM`.)
8590 pub fn purge(&self, logical_id: &str) -> Result<(), EngineError> {
8591 self.ensure_open()?;
8592 let lid = Self::resolve_lifecycle_target(logical_id)?;
8593
8594 // Drain in-flight projection work before the erase, exactly as
8595 // `excise_source` does: SQLite-WAL would otherwise let a worker that
8596 // already dequeued a job for a purged cursor commit its vec0 /
8597 // `_fathomdb_vector_rows` INSERT after our DELETE releases the writer
8598 // lock, leaving residue that defeats the erasure sweep.
8599 // Settle every pending projection FIRST (unfrozen) so no unprojected row
8600 // is left behind that a subsequent freeze would wedge `drain` on, and so
8601 // the async worker is idle. THEN freeze the scanner (no new work is queued
8602 // while we erase), confirm idle, and erase in one writer transaction.
8603 // Freezing before the first drain would stall projection of any
8604 // just-written row → `database_has_pending_projection_work` never clears →
8605 // `drain` times out into `Scheduler`.
8606 self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS)?;
8607 self.projection_runtime.set_frozen(true);
8608 let outcome = self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS).and_then(|()| self.purge_inner(&lid));
8609 self.projection_runtime.set_frozen(false);
8610 // 0.8.20 Slice 5b (R-20-E5/E6) — the rows are gone from the tables; now
8611 // finish the erasure AT REST (telemetry sink + `-wal` bytes) before
8612 // reporting success. Runs after the connection guard inside
8613 // `purge_inner` has been dropped: `complete_erasure_at_rest` re-acquires
8614 // it for the checkpoint.
8615 outcome?;
8616 self.complete_erasure_at_rest("purge")
8617 }
8618
8619 /// The erased rows' prefixed stable ids ([`IdSpace::to_prefixed`]) are NOT
8620 /// returned: they are enqueued for redaction inside this transaction (see
8621 /// [`enqueue_pending_redaction`]), because a caller-held vector is lost on the
8622 /// retry path that codex §9 P2 found.
8623 fn purge_inner(&self, lid: &str) -> Result<(), EngineError> {
8624 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8625 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8626 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8627
8628 // Precondition on the active row's state. Absent (never-created or
8629 // already-purged) → idempotent no-op success.
8630 let current: Option<String> = tx
8631 .query_row(
8632 "SELECT state FROM canonical_nodes \
8633 WHERE logical_id = ?1 AND superseded_at IS NULL",
8634 params![lid],
8635 |r| r.get::<_, String>(0),
8636 )
8637 .optional()
8638 .map_err(|_| EngineError::Storage)?;
8639 let from_state = match current {
8640 None => {
8641 // Idempotent: nothing to erase.
8642 tx.commit().map_err(|_| EngineError::Storage)?;
8643 return Ok(());
8644 }
8645 Some(s) => LifecycleState::from_str_opt(&s).ok_or(EngineError::Storage)?,
8646 };
8647 if from_state != LifecycleState::Deleted {
8648 // Deleted-first precondition. Dropping `tx` rolls back (no-op read).
8649 return Err(EngineError::IllegalTransition {
8650 from_state,
8651 to_state: LifecycleState::Purged,
8652 legal: from_state.legal_next_states(),
8653 });
8654 }
8655
8656 // Collect every version cursor for the node, plus every cursor of an edge
8657 // that touches it (either endpoint), across ALL versions — the projection
8658 // shadow tables are keyed by these per-row `write_cursor`s.
8659 let node_cursors: Vec<i64> = {
8660 let mut stmt = tx
8661 .prepare("SELECT write_cursor FROM canonical_nodes WHERE logical_id = ?1")
8662 .map_err(|_| EngineError::Storage)?;
8663 let rows = stmt
8664 .query_map(params![lid], |row| row.get::<_, i64>(0))
8665 .map_err(|_| EngineError::Storage)?;
8666 rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
8667 };
8668 let edge_cursors: Vec<i64> = {
8669 let mut stmt = tx
8670 .prepare(
8671 "SELECT write_cursor FROM canonical_edges \
8672 WHERE from_id = ?1 OR to_id = ?1",
8673 )
8674 .map_err(|_| EngineError::Storage)?;
8675 let rows = stmt
8676 .query_map(params![lid], |row| row.get::<_, i64>(0))
8677 .map_err(|_| EngineError::Storage)?;
8678 rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
8679 };
8680
8681 // 0.8.20 Slice 5b (R-20-E6) — the stable ids the telemetry sink may have
8682 // persisted for these rows, collected BEFORE the DELETEs.
8683 let erased_stable_ids = collect_erased_stable_ids(
8684 &tx,
8685 "SELECT logical_id, body FROM canonical_nodes WHERE logical_id = ?1",
8686 "SELECT logical_id, body FROM canonical_edges WHERE from_id = ?1 OR to_id = ?1",
8687 lid,
8688 )?;
8689
8690 // Erase the row-owned projection shadows for every collected cursor.
8691 // 0.8.20 Slice 5a (R-20-E1): registry-driven — the hand-rolled delete
8692 // list is gone, so a newly registered projection table is erased here
8693 // without touching this site. vec0 rowid == the canonical row's
8694 // write_cursor (see `_fathomdb_vector_rows`).
8695 for cursor in node_cursors.iter().chain(edge_cursors.iter()) {
8696 erase_row_projections(&tx, *cursor).map_err(|_| EngineError::Storage)?;
8697 }
8698
8699 // Erase the canonical rows: all node versions + all touching edges
8700 // (gap-3 CASCADE-REMOVE — no content-free stubs in Phase-1).
8701 tx.execute("DELETE FROM canonical_nodes WHERE logical_id = ?1", params![lid])
8702 .map_err(|_| EngineError::Storage)?;
8703 tx.execute("DELETE FROM canonical_edges WHERE from_id = ?1 OR to_id = ?1", params![lid])
8704 .map_err(|_| EngineError::Storage)?;
8705
8706 // 0.8.20 Slice 5 fix-1 (codex §9 P2) — durably record the redaction this
8707 // erasure now owes, atomically with the deletes above. Only when a sink
8708 // is attached: with telemetry never enabled there is no file the ids
8709 // could have leaked into, so there is nothing to owe.
8710 let pending_cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
8711 let enqueued =
8712 self.telemetry_enabled.load(Ordering::Acquire) && !erased_stable_ids.is_empty();
8713 if enqueued {
8714 enqueue_pending_redaction(&tx, "purge", &erased_stable_ids, pending_cursor)?;
8715 }
8716
8717 tx.commit().map_err(|_| EngineError::Storage)?;
8718 if enqueued {
8719 self.next_cursor.store(pending_cursor, Ordering::SeqCst);
8720 }
8721 self.counters.record_admin();
8722 Ok(())
8723 }
8724
8725 /// 0.8.20 Slice 5d (R-20-E4, design §4 item 9b) — the **governed SDK
8726 /// erasure verb**. Deletes every canonical row attributable to `source_id`,
8727 /// plus its row-owned projections, and finishes the erasure at rest.
8728 ///
8729 /// This is NOT `operator`-gated: erasing content a consumer wrote is an
8730 /// application obligation, not a recovery workflow. Before this slice the
8731 /// only erasure path was [`Engine::excise_source`], which lives behind the
8732 /// operator feature (i.e. the CLI), so an SDK-only consumer holding a
8733 /// deletion obligation over ANONYMOUS content — content with no
8734 /// `logical_id`, therefore not reachable by [`Engine::purge`] — had no way
8735 /// to discharge it at all. That gap is what R-20-E4 closes.
8736 ///
8737 /// **One engine path.** `erase_source` and `excise_source` are the SAME
8738 /// operation: both delegate to [`Engine::erase_source_shared`]. They are
8739 /// not competing implementations, and no behaviour is duplicated.
8740 ///
8741 /// **Validation differs, deliberately.** `erase_source` admits only ids
8742 /// [`SourceId::new`] would admit, so a caller cannot aim the governed verb
8743 /// at the engine's reserved `_`-prefixed namespace (`_engine:*` substrate,
8744 /// or the `_legacy:pre-0.8.20` cohort migration step 21 back-filled — a
8745 /// single call against which would erase every pre-0.8.20 anonymous row).
8746 /// `excise_source` stays permissive precisely BECAUSE it is the recovery
8747 /// seam: R-20-E8 requires an operator to be able to excise `_legacy:`.
8748 ///
8749 /// **Not a recovery verb.** `erase_source` carries no REQ-054
8750 /// recovery-denylist name (`{recover, restore, repair, fix, rebuild}`); it
8751 /// is a lifecycle verb alongside `transition`/`purge`. AC-041 is unaffected.
8752 ///
8753 /// # Errors
8754 ///
8755 /// [`EngineError::WriteValidation`] for an empty, whitespace-only or
8756 /// reserved `source_id`; [`EngineError::ErasureIncomplete`] if the erasure
8757 /// could not be completed at rest (see [`Engine::complete_erasure_at_rest`]).
8758 pub fn erase_source(&self, source_id: &str) -> Result<ExciseReport, EngineError> {
8759 // Construct-to-validate: reuse the newtype's rule rather than restating
8760 // it, so the erasure boundary and the write boundary cannot drift.
8761 let _validated = SourceId::new(source_id)?;
8762 self.erase_source_shared("erase_source", source_id)
8763 }
8764
8765 /// Phase 9 Pack B / AC-028a/b/c source excise — the **operator/recovery**
8766 /// spelling of [`Engine::erase_source`], sharing one engine path with it.
8767 ///
8768 /// Kept `operator`-gated and kept permissive about reserved ids: this is
8769 /// the seam an operator uses to excise `_legacy:pre-0.8.20` (R-20-E8) or
8770 /// `_engine:*` substrate, which the governed SDK verb refuses.
8771 #[cfg(feature = "operator")]
8772 pub fn excise_source(&self, source_id: &str) -> Result<ExciseReport, EngineError> {
8773 if source_id.is_empty() {
8774 self.ensure_open()?;
8775 return Err(EngineError::WriteValidation);
8776 }
8777 self.erase_source_shared("excise_source", source_id)
8778 }
8779
8780 /// The single erasure implementation behind [`Engine::erase_source`] and
8781 /// [`Engine::excise_source`]. `verb` names the caller for the telemetry
8782 /// redaction record only; the deletion semantics are identical.
8783 ///
8784 /// Non-perturbation: rows from other sources (and rows with NULL
8785 /// `source_id`) are untouched; the projection cursor is NOT reset
8786 /// and no blanket projection rebuild is issued.
8787 fn erase_source_shared(
8788 &self,
8789 verb: &'static str,
8790 source_id: &str,
8791 ) -> Result<ExciseReport, EngineError> {
8792 self.ensure_open()?;
8793
8794 // Drain MUST succeed before the excise transaction. SQLite-WAL
8795 // would otherwise allow a worker that already dequeued a job
8796 // for an excised cursor to commit its INSERT into vec0 /
8797 // _fathomdb_vector_rows after our DELETE releases the writer
8798 // lock, leaving residue and breaking AC-028b. Surface the
8799 // timeout instead of swallowing it (Pack A pattern).
8800 //
8801 // ORDER IS LOAD-BEARING, exactly as in `purge`: settle every pending
8802 // projection FIRST (UNFROZEN), and only THEN freeze the scanner and
8803 // confirm idle. Freezing first parks the dispatcher, so a row written
8804 // moments ago can never be scanned and enqueued — while `drain` ->
8805 // `wait_for_idle` keeps seeing it via
8806 // `database_has_pending_projection_work`, which reads the DATABASE and
8807 // not the queue. The result is that the ordinary sequence "write a
8808 // vector-indexed row, then erase it" stalls for the whole
8809 // LIFECYCLE_DRAIN_TIMEOUT_MS and fails with `Scheduler`.
8810 // (codex §9 [P2]; `erase_source_drains_before_freezing`.)
8811 self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS)?;
8812 self.projection_runtime.set_frozen(true);
8813 let drain_result = self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS);
8814 let outcome = drain_result.and_then(|()| self.excise_source_inner(verb, source_id));
8815 self.projection_runtime.set_frozen(false);
8816 // 0.8.20 Slice 5b (R-20-E5/E6) — finish the erasure AT REST before
8817 // reporting success: redact the erased stable ids out of the telemetry
8818 // sink, then truncate the `-wal` so the erased bytes are not still
8819 // readable on disk. On persistent checkpoint BUSY this returns
8820 // `ErasureIncomplete` rather than an `ExciseReport`.
8821 let report = outcome?;
8822 self.complete_erasure_at_rest(verb)?;
8823 Ok(report)
8824 }
8825
8826 /// Doctor `verify-embedder` seam (AC-040a). Compares the
8827 /// `_fathomdb_embedder_profiles` row to the operator-supplied
8828 /// `name:revision` identity + dimension; never raises on mismatch.
8829 #[cfg(feature = "operator")]
8830 pub fn verify_embedder(
8831 &self,
8832 supplied_identity: &str,
8833 supplied_dimension: u32,
8834 ) -> Result<VerifyEmbedderReport, EngineError> {
8835 self.ensure_open()?;
8836 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8837 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8838 let stored = load_default_profile(connection).map_err(|_| EngineError::Storage)?;
8839 let stored_identity = format!("{}:{}", stored.name, stored.revision);
8840 let identity_match = stored_identity == supplied_identity;
8841 let dimension_match = stored.dimension == supplied_dimension;
8842 let status = match (identity_match, dimension_match) {
8843 (true, true) => VerifyEmbedderStatus::Match,
8844 (false, true) => VerifyEmbedderStatus::IdentityMismatch,
8845 (true, false) => VerifyEmbedderStatus::DimensionMismatch,
8846 (false, false) => VerifyEmbedderStatus::BothMismatch,
8847 };
8848 Ok(VerifyEmbedderReport {
8849 stored_identity,
8850 stored_dimension: stored.dimension,
8851 supplied_identity: supplied_identity.to_string(),
8852 supplied_dimension,
8853 status,
8854 })
8855 }
8856
8857 /// Doctor `dump-schema` seam (AC-040a). Returns the
8858 /// `PRAGMA user_version` sentinel plus the table + index inventory
8859 /// from `sqlite_schema`, excluding `sqlite_*` internal rows.
8860 /// Canonical tables appear first per [`CANONICAL_TABLES`].
8861 #[cfg(feature = "operator")]
8862 pub fn dump_schema(&self) -> Result<DumpSchemaReport, EngineError> {
8863 self.ensure_open()?;
8864 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8865 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8866 let user_version: u32 = connection
8867 .query_row("PRAGMA user_version", [], |row| row.get(0))
8868 .map_err(|_| EngineError::Storage)?;
8869 let tables = read_schema_objects(connection, "table")?;
8870 let indexes = read_schema_objects(connection, "index")?;
8871 Ok(DumpSchemaReport { user_version, tables: order_canonical_first(tables), indexes })
8872 }
8873
8874 /// Doctor `dump-row-counts` seam (AC-040a). Emits canonical-table
8875 /// counts only; projection / FTS / vec0 shadow tables are excluded.
8876 #[cfg(feature = "operator")]
8877 pub fn dump_row_counts(&self) -> Result<DumpRowCountsReport, EngineError> {
8878 self.ensure_open()?;
8879 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8880 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8881 let mut counts = Vec::with_capacity(CANONICAL_TABLES.len());
8882 for name in CANONICAL_TABLES {
8883 let rows: u64 = connection
8884 .query_row(&format!("SELECT COUNT(*) FROM {name}"), [], |row| row.get(0))
8885 .map_err(|_| EngineError::Storage)?;
8886 counts.push(TableRowCount { name: (*name).to_string(), rows });
8887 }
8888 Ok(DumpRowCountsReport { counts })
8889 }
8890
8891 /// 0.8.20 Slice 5d (R-20-E8, design §4 item 11) — doctor
8892 /// `orphan-provenance` seam: a **read-only** per-`source_id` census over
8893 /// `canonical_nodes` + `canonical_edges`.
8894 ///
8895 /// Answers the operator question the erasure work made askable: *"for this
8896 /// database, is every row actually reachable by some erasure verb?"* A row
8897 /// is reachable by `erase_source` / `excise_source` via `source_id`, or —
8898 /// **if it is a NODE** — by `purge` via `logical_id`. A row with neither is
8899 /// un-erasable, and is counted into
8900 /// [`OrphanProvenanceReport::unerasable_rows`].
8901 ///
8902 /// The node/edge asymmetry is load-bearing and mirrors migration step 21:
8903 /// an EDGE's `logical_id` is a supersession identity only and confers no
8904 /// purge-addressability, so a NULL-`source_id` edge is un-erasable however
8905 /// governed it looks. See the query comment below.
8906 ///
8907 /// CLI-only (no SDK parity), matching the `dump-*` diagnostic family.
8908 ///
8909 /// Read-only by construction: this method issues SELECTs exclusively and
8910 /// opens no transaction.
8911 #[cfg(feature = "operator")]
8912 pub fn orphan_provenance(&self) -> Result<OrphanProvenanceReport, EngineError> {
8913 self.ensure_open()?;
8914 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8915 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8916
8917 // One UNION ALL over both canonical tables so a source that spans nodes
8918 // AND edges reports as a single bucket.
8919 //
8920 // TWO DIFFERENT SUMS, and the difference is the whole point:
8921 //
8922 // * `governed` counts `logical_id` carriers — a reporting figure;
8923 // * `purge_addressable` counts rows that `purge` can actually reach,
8924 // and it is NODE-ONLY (the edge arm contributes a literal 0).
8925 //
8926 // This is the same node/edge asymmetry migration step 21 carries, for
8927 // the same reason, and the two must stay in step: `purge_inner`
8928 // resolves its target exclusively through `canonical_nodes` (`SELECT
8929 // state FROM canonical_nodes WHERE logical_id = ?1`) and then erases
8930 // edges by ENDPOINT (`from_id`/`to_id`). It NEVER resolves an edge by
8931 // edge `logical_id` — an edge `logical_id` is only a SUPERSESSION
8932 // identity and confers no purge-addressability whatsoever.
8933 //
8934 // Crediting an edge's `logical_id` here made the diagnostic subtract
8935 // exactly the rows it exists to find: a NULL-`source_id` edge is
8936 // reachable by no erasure verb at all, yet `orphan-provenance` would
8937 // exit CLEAN on precisely the legacy/corrupt shape step 21 closes.
8938 // False assurance from a governance verb is worse than no verb.
8939 // (codex §9 [P2]; `null_source_governed_edge_counts_as_unerasable`.)
8940 let mut stmt = connection
8941 .prepare(
8942 "SELECT source_id,
8943 COUNT(*) AS rows_total,
8944 SUM(CASE WHEN logical_id IS NOT NULL THEN 1 ELSE 0 END) AS governed,
8945 SUM(purge_addressable) AS purge_addressable
8946 FROM (SELECT source_id,
8947 logical_id,
8948 CASE WHEN logical_id IS NOT NULL THEN 1 ELSE 0 END
8949 AS purge_addressable
8950 FROM canonical_nodes
8951 UNION ALL
8952 SELECT source_id, logical_id, 0 AS purge_addressable
8953 FROM canonical_edges)
8954 GROUP BY source_id
8955 ORDER BY rows_total DESC, source_id",
8956 )
8957 .map_err(|_| EngineError::Storage)?;
8958
8959 let rows = stmt
8960 .query_map([], |row| {
8961 let source_id: Option<String> = row.get(0)?;
8962 let rows: i64 = row.get(1)?;
8963 let governed: i64 = row.get(2)?;
8964 let purge_addressable: i64 = row.get(3)?;
8965 Ok((source_id, rows, governed, purge_addressable))
8966 })
8967 .map_err(|_| EngineError::Storage)?;
8968
8969 let mut sources = Vec::new();
8970 let mut total_rows: u64 = 0;
8971 let mut unerasable_rows: u64 = 0;
8972 for row in rows {
8973 let (source_id, rows, governed, purge_addressable) =
8974 row.map_err(|_| EngineError::Storage)?;
8975 let rows = u64::try_from(rows).unwrap_or(0);
8976 let governed_rows = u64::try_from(governed).unwrap_or(0);
8977 let purge_addressable = u64::try_from(purge_addressable).unwrap_or(0);
8978 total_rows = total_rows.saturating_add(rows);
8979 if source_id.is_none() {
8980 // No provenance: only the PURGE-ADDRESSABLE subset (governed
8981 // NODES) is reachable. The remainder — including every governed
8982 // EDGE, whose `logical_id` reaches nothing — is reachable by no
8983 // erasure verb at all.
8984 unerasable_rows =
8985 unerasable_rows.saturating_add(rows - purge_addressable.min(rows));
8986 }
8987 let reserved = source_id.as_deref().is_some_and(|s| s.starts_with('_'));
8988 sources.push(OrphanProvenanceSource { source_id, rows, governed_rows, reserved });
8989 }
8990
8991 Ok(OrphanProvenanceReport { sources, total_rows, unerasable_rows })
8992 }
8993
8994 /// Doctor `dump-profile` seam (AC-040a). Returns the stored
8995 /// embedder identity + dimension plus the registered vectorized
8996 /// kinds from `_fathomdb_vector_kinds`.
8997 #[cfg(feature = "operator")]
8998 pub fn dump_profile(&self) -> Result<DumpProfileReport, EngineError> {
8999 self.ensure_open()?;
9000 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9001 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9002 let stored = load_default_profile(connection).map_err(|_| EngineError::Storage)?;
9003 let mut stmt = connection
9004 .prepare("SELECT kind FROM _fathomdb_vector_kinds ORDER BY kind")
9005 .map_err(|_| EngineError::Storage)?;
9006 let rows =
9007 stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|_| EngineError::Storage)?;
9008 let mut vectorized_kinds = Vec::new();
9009 for row in rows {
9010 vectorized_kinds.push(row.map_err(|_| EngineError::Storage)?);
9011 }
9012 Ok(DumpProfileReport {
9013 embedder_identity: format!("{}:{}", stored.name, stored.revision),
9014 embedder_dimension: stored.dimension,
9015 vectorized_kinds,
9016 })
9017 }
9018
9019 /// Recover `--truncate-wal` seam. Runs
9020 /// `PRAGMA wal_checkpoint(TRUNCATE)` and returns the three counters
9021 /// SQLite reports. `status = Busy` when SQLite signalled a blocked
9022 /// checkpoint (`busy != 0`); the WAL may still be partially
9023 /// checkpointed in that case.
9024 #[cfg(feature = "operator")]
9025 pub fn truncate_wal(&self) -> Result<TruncateWalReport, EngineError> {
9026 self.ensure_open()?;
9027 // The operator verb keeps SQLite's own busy handler: `recover
9028 // --truncate-wal` is an explicit, foreground operator act, so waiting out
9029 // a transient reader is the helpful behaviour.
9030 self.wal_checkpoint_truncate_once(true)
9031 }
9032
9033 /// One `PRAGMA wal_checkpoint(TRUNCATE)` on the writer connection.
9034 ///
9035 /// NOT operator-gated: the erasure verbs (`purge` is a default-feature verb)
9036 /// need it too, and a `#[cfg(feature = "operator")]` helper would break the
9037 /// default build. Acquires the connection mutex, so callers must NOT already
9038 /// hold it — every erasure verb calls this AFTER its transaction has
9039 /// committed and the guard has been dropped.
9040 ///
9041 /// `honor_busy_timeout = false` suppresses SQLite's busy handler for the
9042 /// duration of the checkpoint. rusqlite installs a **5 s** default
9043 /// `busy_timeout`, so a blocked checkpoint sits for 5 s before reporting
9044 /// `busy` — under the erasure verbs' bounded retry that compounds to a ~25 s
9045 /// stall on a verb that is supposed to fail fast. The erasure path therefore
9046 /// takes the immediate `busy` answer and runs its OWN short backoff; the
9047 /// prior value is restored before returning, on every path.
9048 fn wal_checkpoint_truncate_once(
9049 &self,
9050 honor_busy_timeout: bool,
9051 ) -> Result<TruncateWalReport, EngineError> {
9052 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9053 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9054
9055 let restore_timeout_ms: Option<i64> = if honor_busy_timeout {
9056 None
9057 } else {
9058 let previous: i64 = connection
9059 .query_row("PRAGMA busy_timeout", [], |row| row.get(0))
9060 .map_err(|_| EngineError::Storage)?;
9061 connection.busy_timeout(Duration::ZERO).map_err(|_| EngineError::Storage)?;
9062 Some(previous)
9063 };
9064
9065 let checkpoint: rusqlite::Result<(i64, i64, i64)> =
9066 connection.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
9067 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
9068 });
9069
9070 if let Some(previous) = restore_timeout_ms {
9071 let previous = u64::try_from(previous.max(0)).unwrap_or(0);
9072 connection
9073 .busy_timeout(Duration::from_millis(previous))
9074 .map_err(|_| EngineError::Storage)?;
9075 }
9076
9077 let (busy, log_frames, checkpointed_frames) =
9078 checkpoint.map_err(|_| EngineError::Storage)?;
9079 let status = if busy == 0 { TruncateWalStatus::Done } else { TruncateWalStatus::Busy };
9080 Ok(TruncateWalReport {
9081 status,
9082 busy: busy.max(0) as u32,
9083 log_frames: log_frames.max(0) as u32,
9084 checkpointed_frames: checkpointed_frames.max(0) as u32,
9085 })
9086 }
9087
9088 /// 0.8.20 Slice 5b (R-20-E5) — complete an erasure **at rest** after the
9089 /// erasing transaction has committed. Two obligations, in order:
9090 ///
9091 /// 1. **Telemetry redaction** — drop the erased stable ids out of the opt-in
9092 /// telemetry sink. Driven from the DURABLE pending queue
9093 /// ([`Engine::discharge_pending_redactions`]), NOT from the ids the caller
9094 /// happens to be holding, so a retry after a failed redaction still knows
9095 /// what it owes.
9096 /// 2. **WAL truncation** — `wal_checkpoint(TRUNCATE)` with a BOUNDED retry.
9097 /// `PRAGMA secure_delete=ON` zeroes pages freed inside the database file,
9098 /// but the erased content also lives in the write-ahead log as committed
9099 /// frames from the ORIGINAL insert; the erasure DELETE appends new frames
9100 /// rather than rewriting old ones, so without a truncating checkpoint the
9101 /// erased body stays `grep`-able in `<db>-wal`.
9102 ///
9103 /// A concurrent reader pinning a WAL snapshot makes the checkpoint report
9104 /// `busy`. After [`ERASURE_WAL_TRUNCATE_ATTEMPTS`] tries the verb raises
9105 /// [`EngineError::ErasureIncomplete`] — **an erasure verb must never report
9106 /// success on an incomplete erasure.** The retry budget is deliberately small
9107 /// (~100 ms total): the caller retries the verb, the verb does not block.
9108 fn complete_erasure_at_rest(&self, verb: &'static str) -> Result<(), EngineError> {
9109 // The ids are NOT passed in: they were persisted inside the erasing
9110 // transaction, and this drains that queue. The WAL truncation below then
9111 // runs AFTER the pending rows have been deleted, so the freed pages
9112 // holding them (zeroed by `secure_delete=ON`) are checkpointed out too.
9113 self.discharge_pending_redactions(verb)?;
9114
9115 let mut last: Option<TruncateWalReport> = None;
9116 for attempt in 0..ERASURE_WAL_TRUNCATE_ATTEMPTS {
9117 let report = self.wal_checkpoint_truncate_once(false)?;
9118 if report.status == TruncateWalStatus::Done {
9119 return Ok(());
9120 }
9121 last = Some(report);
9122 if attempt + 1 < ERASURE_WAL_TRUNCATE_ATTEMPTS {
9123 std::thread::sleep(Duration::from_millis(ERASURE_WAL_TRUNCATE_BACKOFF_MS));
9124 }
9125 }
9126 let frames = last.map_or(0, |r| r.log_frames);
9127 Err(EngineError::ErasureIncomplete {
9128 stage: "wal_checkpoint".to_string(),
9129 detail: format!(
9130 "`{verb}` deleted its rows, but `wal_checkpoint(TRUNCATE)` reported BUSY on all \
9131 {ERASURE_WAL_TRUNCATE_ATTEMPTS} attempts ({frames} frames still in the log) — a \
9132 concurrent reader is pinning a WAL snapshot, so the erased bytes remain readable \
9133 in the `-wal` file. Retry once the reader has finished."
9134 ),
9135 })
9136 }
9137
9138 /// 0.8.20 Slice 5 fix-1 (codex §9 P2) — perform every telemetry redaction the
9139 /// engine still OWES, from the durable pending queue.
9140 ///
9141 /// **The defect this closes.** Redaction necessarily runs after the erasing
9142 /// transaction commits (the sink is a file, not a table, so it cannot join
9143 /// the transaction). When it failed, the verb correctly raised
9144 /// `ErasureIncomplete { stage: "telemetry_redaction" }` and told the operator
9145 /// to retry — but the retry recomputed the id set by querying the canonical
9146 /// tables, whose rows the FIRST call had already deleted. It therefore got an
9147 /// EMPTY set, hit the empty-id fast path in
9148 /// [`Engine::redact_telemetry_stable_ids`], and returned success while the
9149 /// leaked `l:`/`h:` ids were still sitting in the sink. An erasure verb
9150 /// reporting success on an incomplete erasure is precisely what R-20-E5
9151 /// forbids, and it is the worst failure mode available to this slice: silent,
9152 /// and indistinguishable from a real erasure.
9153 ///
9154 /// **The mechanism — an intent log.** The ids are captured BEFORE the deletes
9155 /// (they are derived from `logical_id`/`body`, which the deletes destroy) and
9156 /// written into [`ERASURE_PENDING_REDACTION_COLLECTION`] INSIDE the same
9157 /// transaction, so "the rows are gone" and "a redaction is owed for them"
9158 /// commit atomically. There is no window in which the rows are deleted and
9159 /// the obligation is unrecorded. A pending row is deleted only once its
9160 /// redaction has actually been performed, so the obligation survives process
9161 /// death, and the empty-id fast path is unreachable while one is outstanding:
9162 /// this drains the QUEUE, never the caller's id vector.
9163 ///
9164 /// The queue is drained by EVERY erasure verb, not just a retry of the one
9165 /// that failed — an outstanding obligation is the engine's, not one call's.
9166 ///
9167 /// **Honest refusal.** If a redaction is owed but no telemetry sink is
9168 /// attached to this `Engine` (only reachable if the process restarted between
9169 /// the failure and the retry without re-enabling telemetry), the ids really
9170 /// are still in the sink file and this returns `ErasureIncomplete` rather
9171 /// than guessing. Re-enable telemetry on the same sink and retry.
9172 ///
9173 /// **Exposure tradeoff, stated plainly.** A pending row holds the stable ids
9174 /// in the database for the window between the delete and the redaction. That
9175 /// is a strict improvement: those ids are, during exactly that window,
9176 /// already readable in the telemetry sink — which is the leak being closed —
9177 /// and the pending row is deleted the moment the sink is clean, on pages
9178 /// `secure_delete=ON` zeroes and the subsequent `TRUNCATE` checkpoint clears
9179 /// from the log.
9180 fn discharge_pending_redactions(&self, verb: &'static str) -> Result<(), EngineError> {
9181 let pending = self.load_pending_redactions()?;
9182 if pending.is_empty() {
9183 return Ok(());
9184 }
9185
9186 let mut ids: Vec<String> =
9187 pending.iter().flat_map(|(_, ids)| ids.iter().cloned()).collect();
9188 ids.sort_unstable();
9189 ids.dedup();
9190
9191 // A queue entry exists ⇒ a sink was attached when the rows were deleted ⇒
9192 // the ids are in that file. Never clear the queue without redacting.
9193 if !self.telemetry_enabled.load(Ordering::Acquire) {
9194 return Err(EngineError::ErasureIncomplete {
9195 stage: "telemetry_redaction".to_string(),
9196 detail: format!(
9197 "`{verb}` has {} outstanding telemetry redaction(s) covering {} erased \
9198 stable id(s), but no telemetry sink is attached to this engine — the ids \
9199 cannot be removed from the sink file. Re-enable telemetry on the same sink \
9200 path and retry.",
9201 pending.len(),
9202 ids.len()
9203 ),
9204 });
9205 }
9206
9207 // On failure the queue rows stay put and the error propagates: the verb
9208 // does not report success, and the next call retries the same obligation.
9209 self.redact_telemetry_stable_ids(verb, &ids)?;
9210
9211 let row_ids: Vec<i64> = pending.iter().map(|(row_id, _)| *row_id).collect();
9212 self.clear_pending_redactions(&row_ids)
9213 }
9214
9215 /// Read the outstanding redaction queue: `(operational_mutations.id, ids)`.
9216 fn load_pending_redactions(&self) -> Result<Vec<(i64, Vec<String>)>, EngineError> {
9217 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9218 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9219 let mut stmt = connection
9220 .prepare(
9221 "SELECT id, payload_json FROM operational_mutations \
9222 WHERE collection_name = ?1 ORDER BY id",
9223 )
9224 .map_err(|_| EngineError::Storage)?;
9225 let rows = stmt
9226 .query_map([ERASURE_PENDING_REDACTION_COLLECTION], |row| {
9227 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
9228 })
9229 .map_err(|_| EngineError::Storage)?;
9230 let mut pending = Vec::new();
9231 for row in rows {
9232 let (row_id, payload) = row.map_err(|_| EngineError::Storage)?;
9233 // A payload we cannot parse is an obligation we cannot discharge;
9234 // keeping it (empty) is safe — it never unblocks a false success,
9235 // and `discharge_pending_redactions` still refuses.
9236 let ids = serde_json::from_str::<serde_json::Value>(&payload)
9237 .ok()
9238 .and_then(|v| v.get("erased_stable_ids").cloned())
9239 .and_then(|v| serde_json::from_value::<Vec<String>>(v).ok())
9240 .unwrap_or_default();
9241 pending.push((row_id, ids));
9242 }
9243 Ok(pending)
9244 }
9245
9246 /// Retire queue entries whose redaction has been PERFORMED. Committed before
9247 /// the caller's WAL truncation so the freed pages are checkpointed out.
9248 fn clear_pending_redactions(&self, row_ids: &[i64]) -> Result<(), EngineError> {
9249 if row_ids.is_empty() {
9250 return Ok(());
9251 }
9252 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9253 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9254 let mut stmt = connection
9255 .prepare("DELETE FROM operational_mutations WHERE id = ?1")
9256 .map_err(|_| EngineError::Storage)?;
9257 for row_id in row_ids {
9258 stmt.execute([row_id]).map_err(|_| EngineError::Storage)?;
9259 }
9260 Ok(())
9261 }
9262
9263 /// 0.8.20 Slice 5b (R-20-E6) — SELECTIVE redaction of erased stable ids from
9264 /// the opt-in telemetry sink.
9265 ///
9266 /// `capture_telemetry` persists `result_stable_ids` — `l:`/`h:` prefixed ids
9267 /// — into a JSONL file that outlives the erased rows, and nothing in the
9268 /// engine could previously remove them. A retained `l:` id is not inert:
9269 /// [`derive_logical_id`] is `SHA256(lowercase(kind) + ":" + lowercase(name))`,
9270 /// and the case-folding of BOTH inputs shrinks the preimage space, so a
9271 /// surviving id is dictionary-attackable back to the natural key it was
9272 /// derived from. An `h:` id is a plain `SHA256(body)`, confirmable against a
9273 /// guessed body.
9274 ///
9275 /// **This MUST NOT truncate the sink.** `sink_path` is CALLER-SUPPLIED and
9276 /// may hold unrelated operator eval history that the erasure obligation never
9277 /// covered; the v3 truncation approach was rejected as unsafe. Only the
9278 /// matching `result_stable_ids` ELEMENTS are replaced with
9279 /// [`REDACTED_STABLE_ID`], preserving record count, record order and
9280 /// positional alignment with the parallel `result_ids` array. Lines that are
9281 /// not engine-authored JSON events are copied through verbatim.
9282 ///
9283 /// **Crash safety.** The rewrite is write-temp-then-`rename`: a sibling
9284 /// `.redact.tmp` is written and fsynced, then atomically renamed over the
9285 /// sink, so a crash leaves either the old file or the new one — never a
9286 /// half-rewritten sink. The telemetry mutex is held across the whole rewrite,
9287 /// so no in-process `capture_telemetry` can append into the window; an
9288 /// out-of-process appender is handled by re-reading and folding in the tail
9289 /// delta before the rename (bounded retry).
9290 ///
9291 /// The privacy contract is unchanged: query TEXT and `source_id` are never
9292 /// captured (ADR-0.8.8 §C), so there is nothing else in the sink to redact.
9293 /// The fast-OFF atomic guard is preserved — when telemetry was never enabled
9294 /// this is a single relaxed-ordering load and no mutex acquisition.
9295 fn redact_telemetry_stable_ids(
9296 &self,
9297 verb: &'static str,
9298 erased_stable_ids: &[String],
9299 ) -> Result<(), EngineError> {
9300 // Fast OFF path — mirrors `capture_telemetry`. No mutex, no I/O.
9301 if erased_stable_ids.is_empty() || !self.telemetry_enabled.load(Ordering::Acquire) {
9302 return Ok(());
9303 }
9304 let guard = self.telemetry.lock().map_err(|_| EngineError::Storage)?;
9305 let Some(sink) = guard.as_ref() else { return Ok(()) };
9306 let erased: std::collections::HashSet<&str> =
9307 erased_stable_ids.iter().map(String::as_str).collect();
9308
9309 match redact_jsonl_stable_ids(&sink.path, &erased) {
9310 Ok(()) => Ok(()),
9311 // 0.8.20 Slice 5 fix-3 (codex §9 round-3 P2) — `NotFound` is NOT a
9312 // discharge. It previously returned `Ok(())` ("the sink is gone,
9313 // nothing to redact"), which cleared the durable pending queue and
9314 // let the verb report success. That inference does not hold: a path
9315 // cannot distinguish `rm` from `mv`, and log rotation of a
9316 // caller-supplied sink is an ordinary operational event that leaves
9317 // the erased `l:`/`h:` ids fully readable under the rotated name.
9318 //
9319 // The burden of proof is on DISCHARGING the obligation, and the
9320 // engine cannot meet it here: `TelemetrySink` holds a PATH, not an
9321 // open handle, so there is no `nlink == 0` witness that the inode was
9322 // actually unlinked — and even that would not cover a copy taken
9323 // before the deletion. So there is no narrow provable case to carve
9324 // out, and `NotFound` fails closed.
9325 //
9326 // This cannot fire spuriously for a sink that never existed:
9327 // `enable_telemetry` CREATES the file before arming capture, so for
9328 // any engine with telemetry enabled the sink demonstrably existed and
9329 // `NotFound` means it existed and then vanished.
9330 Err(err) => Err(EngineError::ErasureIncomplete {
9331 stage: "telemetry_redaction".to_string(),
9332 detail: if err.kind() == std::io::ErrorKind::NotFound {
9333 format!(
9334 "`{verb}` deleted its rows, but the telemetry sink {} no longer exists, \
9335 so the erased stable ids could not be redacted from it. A missing path \
9336 does NOT prove the sink was deleted — if it was rotated or moved aside, \
9337 the erased ids are still readable under its new name. The pending \
9338 redaction is durable: restore the sink at this path and retry (if the \
9339 sink really was destroyed, an empty file at this path discharges the \
9340 obligation).",
9341 sink.path.display()
9342 )
9343 } else {
9344 format!(
9345 "`{verb}` deleted its rows, but the erased stable ids could not be \
9346 redacted from the telemetry sink {}: {err}",
9347 sink.path.display()
9348 )
9349 },
9350 }),
9351 }
9352 }
9353
9354 /// The erased rows' prefixed stable ids ([`IdSpace::to_prefixed`]) are NOT
9355 /// returned to the caller for redaction (R-20-E6). They are enqueued INSIDE
9356 /// this transaction via [`enqueue_pending_redaction`]: a caller-held vector
9357 /// is lost on the retry path, which is exactly the false-success codex §9 P2
9358 /// found. Only the report comes back.
9359 ///
9360 /// 0.8.20 Slice 5d (R-20-E4): no longer `operator`-gated — it is the shared
9361 /// body behind BOTH `erase_source` (governed SDK) and `excise_source`
9362 /// (operator seam). Still private; the gate that matters is on the two
9363 /// public spellings.
9364 fn excise_source_inner(
9365 &self,
9366 verb: &'static str,
9367 source_id: &str,
9368 ) -> Result<ExciseReport, EngineError> {
9369 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9370 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
9371 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
9372
9373 // Collect the cursor sets up-front so we can targeted-delete
9374 // shadow rows AND emit an accurate audit row in one txn.
9375 let node_cursors: Vec<i64> = {
9376 let mut stmt = tx
9377 .prepare("SELECT write_cursor FROM canonical_nodes WHERE source_id = ?1")
9378 .map_err(|_| EngineError::Storage)?;
9379 let rows = stmt
9380 .query_map([source_id], |row| row.get::<_, i64>(0))
9381 .map_err(|_| EngineError::Storage)?;
9382 rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
9383 };
9384 let edge_cursors: Vec<i64> = {
9385 let mut stmt = tx
9386 .prepare("SELECT write_cursor FROM canonical_edges WHERE source_id = ?1")
9387 .map_err(|_| EngineError::Storage)?;
9388 let rows = stmt
9389 .query_map([source_id], |row| row.get::<_, i64>(0))
9390 .map_err(|_| EngineError::Storage)?;
9391 rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
9392 };
9393
9394 // 0.8.20 Slice 5b (R-20-E6) — stable ids the telemetry sink may hold for
9395 // these rows, collected BEFORE the DELETEs.
9396 let erased_stable_ids = collect_erased_stable_ids(
9397 &tx,
9398 "SELECT logical_id, body FROM canonical_nodes WHERE source_id = ?1",
9399 "SELECT logical_id, body FROM canonical_edges WHERE source_id = ?1",
9400 source_id,
9401 )?;
9402
9403 // 0.8.20 Slice 5a (R-20-E1) — registry-driven erasure. The previous
9404 // hand-rolled list here OMITTED `search_index_v2`, a CONTENT-STORING
9405 // FTS5 table (no `content=''`) that keeps the document body verbatim:
9406 // after `excise_source` the erased body was still on disk, invisible to
9407 // every functional test because both v2 read paths discard candidates
9408 // lacking a live `canonical_nodes` row. `erase_row_projections` covers
9409 // every registered projection, so the omission cannot recur.
9410 let mut shadow_invalidated: u64 = 0;
9411 for cursor in node_cursors.iter().chain(edge_cursors.iter()) {
9412 shadow_invalidated = shadow_invalidated.saturating_add(
9413 erase_row_projections(&tx, *cursor).map_err(|_| EngineError::Storage)?,
9414 );
9415 }
9416
9417 let nodes_excised = tx
9418 .execute("DELETE FROM canonical_nodes WHERE source_id = ?1", [source_id])
9419 .map_err(|_| EngineError::Storage)? as u64;
9420 let edges_excised = tx
9421 .execute("DELETE FROM canonical_edges WHERE source_id = ?1", [source_id])
9422 .map_err(|_| EngineError::Storage)? as u64;
9423
9424 // AC-028a audit row: a single append on the
9425 // `excise_source_audit` collection naming the excised source.
9426 //
9427 // DURABILITY (0.8.20 Slice 5b, design v5 §2 defect D-A; HITL-ruled
9428 // 2026-07-19: *"there must be an auditable record of deletion event."*).
9429 // This row lands in `operational_mutations`, the same table the retention
9430 // sweep drains — and it is written BEFORE the workload that follows it,
9431 // so an oldest-`id`-first sweep evicted it FIRST. It is now protected:
9432 // `excise_source_audit` is in `ERASURE_AUDIT_COLLECTIONS`, which
9433 // `enforce_provenance_retention` excludes. The proof of erasure is no
9434 // longer destructible by ordinary retention pressure.
9435 //
9436 // NON-PII `source_id` (rationale corrected in this slice). v4 §3.6
9437 // justified the "`source_id` must not be PII" rule by claiming the audit
9438 // row retains it *permanently, by design*. That premise was FALSE — the
9439 // row was sweepable. The rule stands on a different and simpler footing:
9440 // this row persists the caller's raw `source_id` verbatim, and an
9441 // `excise_source` that erased the payload while keeping an identifying
9442 // source label would not be an erasure. The exemption above makes the
9443 // retention now genuinely indefinite, which makes the rule MORE
9444 // load-bearing, not less.
9445 //
9446 // `next_cursor` after a prior write holds the LAST committed cursor;
9447 // mirror the vec writer pattern (load + 1, then store post-commit)
9448 // so the audit row's `write_cursor` is strictly greater than every
9449 // canonical row that preceded it.
9450 let excised_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
9451 let payload = serde_json::json!({
9452 "source_id": source_id,
9453 "excised_at": excised_at,
9454 "nodes_excised": nodes_excised,
9455 "edges_excised": edges_excised,
9456 "projections_invalidated": shadow_invalidated,
9457 })
9458 .to_string();
9459 let audit_cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
9460 tx.execute(
9461 "INSERT INTO operational_mutations(
9462 collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
9463 ) VALUES('excise_source_audit', ?1, 'append', ?2, NULL, ?3)",
9464 params![source_id, payload, audit_cursor],
9465 )
9466 .map_err(|_| EngineError::Storage)?;
9467
9468 // 0.8.20 Slice 5 fix-1 (codex §9 P2) — durably record the redaction this
9469 // erasure owes, atomically with the deletes. Shares `audit_cursor`: one
9470 // erasure event, and this row is retired as soon as the sink is clean.
9471 if self.telemetry_enabled.load(Ordering::Acquire) {
9472 enqueue_pending_redaction(&tx, verb, &erased_stable_ids, audit_cursor)?;
9473 }
9474
9475 tx.commit().map_err(|_| EngineError::Storage)?;
9476 self.next_cursor.store(audit_cursor, Ordering::SeqCst);
9477 Ok(ExciseReport {
9478 source_ref: source_id.to_string(),
9479 nodes_excised,
9480 edges_excised,
9481 projections_invalidated: shadow_invalidated,
9482 })
9483 }
9484
9485 /// 0.8.20 Slice 5b (R-20-E7) — erase ONE op-store record, by collection and
9486 /// record key, from both op-store shapes: every `operational_mutations`
9487 /// version of the key (append-only-log collections) and its
9488 /// `operational_state` row (latest-state collections).
9489 ///
9490 /// **Refuses the engine's own erasure bookkeeping** (0.8.20 Slice 5 fix-3,
9491 /// codex §9 round-3 P1): a `collection` for which
9492 /// [`is_erasure_bookkeeping_collection`] holds raises
9493 /// [`EngineError::InvalidArgument`] before anything is deleted. Aimed at the
9494 /// pending-redaction queue this verb otherwise destroys an outstanding
9495 /// erasure obligation, after which the next erasure verb reports success with
9496 /// the erased ids still in the telemetry sink; aimed at the audit trail it
9497 /// destroys the auditable record of the deletion event.
9498 ///
9499 /// Before this slice the op-store had NO record-level delete at all:
9500 /// [`enforce_provenance_retention`] is a cap sweep, not an erasure verb, so a
9501 /// caller holding an erasure obligation over an op-store record had no way to
9502 /// discharge it. Idempotent — erasing an absent key is a zero-count success.
9503 ///
9504 /// Like the other erasure verbs this finishes at rest (telemetry is not
9505 /// involved — op-store record keys never reach the telemetry sink — but the
9506 /// `-wal` is), so it can return [`EngineError::ErasureIncomplete`].
9507 ///
9508 /// AUDIT (D-A). Appends a row to the retention-exempt `excise_record_audit`
9509 /// collection. Unlike `source_id`, a `record_key` carries NO non-PII rule:
9510 /// it is arbitrary caller-supplied text and may itself be the identifier
9511 /// being erased. The audit therefore records a SHA-256 digest of
9512 /// `collection` + `record_key`, never the key — enough to prove *that* a
9513 /// specific record was erased to anyone who already knows the key, and
9514 /// useless to anyone who does not.
9515 #[cfg(feature = "operator")]
9516 pub fn excise_collection_record(
9517 &self,
9518 collection: &str,
9519 record_key: &str,
9520 ) -> Result<ExciseRecordReport, EngineError> {
9521 self.ensure_open()?;
9522 if collection.is_empty() || record_key.is_empty() {
9523 return Err(EngineError::WriteValidation);
9524 }
9525 // 0.8.20 Slice 5 fix-3 (codex §9 round-3 P1) — the engine's own erasure
9526 // bookkeeping is not caller data and is not excisable. See
9527 // `is_erasure_bookkeeping_collection` for why each member is protected.
9528 // Checked BEFORE any deletion so the refusal is total, not partial.
9529 if is_erasure_bookkeeping_collection(collection) {
9530 return Err(EngineError::InvalidArgument {
9531 msg: format!(
9532 "`{collection}` is engine-internal erasure bookkeeping and cannot be excised \
9533 by `excise_collection_record`. The pending-redaction queue records an \
9534 erasure the engine still owes (deleting it would let a later verb report \
9535 success on an incomplete erasure, R-20-E5), and the erasure-audit \
9536 collections are the auditable record of the deletion event. Pending \
9537 redactions retire themselves once performed; retry the erasure verb instead."
9538 ),
9539 });
9540 }
9541 let report = self.excise_collection_record_inner(collection, record_key)?;
9542 self.complete_erasure_at_rest("excise_collection_record")?;
9543 Ok(report)
9544 }
9545
9546 #[cfg(feature = "operator")]
9547 fn excise_collection_record_inner(
9548 &self,
9549 collection: &str,
9550 record_key: &str,
9551 ) -> Result<ExciseRecordReport, EngineError> {
9552 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9553 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
9554 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
9555
9556 let records_excised = tx
9557 .execute(
9558 "DELETE FROM operational_mutations
9559 WHERE collection_name = ?1 AND record_key = ?2",
9560 params![collection, record_key],
9561 )
9562 .map_err(|_| EngineError::Storage)? as u64;
9563 let state_rows_excised = tx
9564 .execute(
9565 "DELETE FROM operational_state
9566 WHERE collection_name = ?1 AND record_key = ?2",
9567 params![collection, record_key],
9568 )
9569 .map_err(|_| EngineError::Storage)? as u64;
9570
9571 let record_digest = digest_record_identity(collection, record_key);
9572 let excised_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
9573 let payload = serde_json::json!({
9574 "collection": collection,
9575 "record_digest": record_digest,
9576 "excised_at": excised_at,
9577 "records_excised": records_excised,
9578 "state_rows_excised": state_rows_excised,
9579 })
9580 .to_string();
9581 let audit_cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
9582 tx.execute(
9583 "INSERT INTO operational_mutations(
9584 collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
9585 ) VALUES('excise_record_audit', ?1, 'append', ?2, NULL, ?3)",
9586 params![record_digest, payload, audit_cursor],
9587 )
9588 .map_err(|_| EngineError::Storage)?;
9589
9590 tx.commit().map_err(|_| EngineError::Storage)?;
9591 self.next_cursor.store(audit_cursor, Ordering::SeqCst);
9592 self.counters.record_admin();
9593 Ok(ExciseRecordReport {
9594 collection: collection.to_string(),
9595 record_digest,
9596 records_excised,
9597 state_rows_excised,
9598 })
9599 }
9600
9601 #[cfg(feature = "operator")]
9602 fn run_rebuild(
9603 &self,
9604 include_fts: bool,
9605 kind: RebuildKind,
9606 ) -> Result<RebuildReport, EngineError> {
9607 self.projection_runtime.set_frozen(true);
9608 // Drain MUST succeed: rebuild_shadow_state truncates shadow rows,
9609 // and SQLite-WAL allows a worker that already dequeued a job to
9610 // commit its `INSERT OR IGNORE INTO _fathomdb_vector_rows / vec0`
9611 // after our truncate releases the writer lock, leaving stale
9612 // rows. Surfacing the timeout (instead of swallowing it) lets the
9613 // operator retry rather than silently corrupt the rebuild.
9614 let drain_result = self.drain(REBUILD_DRAIN_TIMEOUT_MS);
9615 let result = drain_result.and_then(|()| self.rebuild_shadow_state(include_fts, kind));
9616 self.projection_runtime.set_frozen(false);
9617 result
9618 }
9619
9620 #[cfg(feature = "operator")]
9621 fn rebuild_shadow_state(
9622 &self,
9623 include_fts: bool,
9624 kind: RebuildKind,
9625 ) -> Result<RebuildReport, EngineError> {
9626 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9627 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
9628 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
9629 // 0.8.20 Slice 5a (R-20-E1) — registry-driven invalidation. A full
9630 // rebuild truncates EVERY row-owned projection (the previous hand-rolled
9631 // list omitted `search_index_v2`, so a rebuild neither dropped stale v2
9632 // rows nor repopulated the table); a vec0-only rebuild truncates the
9633 // vector + readiness classes exactly as before. Kind-owned watermark
9634 // state (`_fathomdb_projection_state`) is deliberately NOT truncated —
9635 // readiness is reset by rewinding the projection cursor below.
9636 let rows_invalidated = if include_fts {
9637 truncate_all_row_projections(&tx).map_err(|_| EngineError::Storage)?
9638 } else {
9639 truncate_row_projections_in(&tx, &[ProjectionClass::Vector, ProjectionClass::Readiness])
9640 .map_err(|_| EngineError::Storage)?
9641 };
9642 store_projection_cursor(&tx, 0).map_err(|_| EngineError::Storage)?;
9643 // 0.8.20 Slice 5a (R-20-E1, work item 1) — the replay runs through the
9644 // SAME two projectors the write path uses, so the rebuilt projections
9645 // are identical to what a re-write would have produced. `include_fts`
9646 // selects the pass: a vec0-only rebuild must not write FTS rows.
9647 let pass = if include_fts { ProjectionPass::Write } else { ProjectionPass::VectorOnly };
9648 let mut rows_rebuilt: u64 = 0;
9649 for row in canonical_node_rows(&tx).map_err(|_| EngineError::Storage)? {
9650 project_canonical_node_row(
9651 &tx,
9652 row.cursor,
9653 &row.kind,
9654 &row.body,
9655 row.row_kind,
9656 pass,
9657 // fix-2 [P2]: the attribute half of the replay tracks the backfill's
9658 // active-and-non-superseded row set; FTS / vector shadows still
9659 // rebuild for every row (read-side lifecycle filter, unchanged).
9660 row.attr_projected,
9661 )
9662 .map_err(|_| EngineError::Storage)?;
9663 if include_fts {
9664 rows_rebuilt = rows_rebuilt.saturating_add(1);
9665 }
9666 }
9667 // fix-26 [P2]: rebuild the edge shadows from active canonical_edges
9668 // (G11 search_index_edges).
9669 // 0.8.12 Slice A (R-CON-2 named default-ON blocker; Slice-20 codex
9670 // §9 [P2]): mirror the graph-traversal recency filter
9671 // (`edge_validity_sql`) here too, so a full rebuild does not re-surface
9672 // an edge that recency consolidation already invalidated.
9673 // 0.8.20 Slice 5a: body-less structural edges are now included in the
9674 // replay. They project no FTS/vector row, but the write path DOES record
9675 // their readiness terminal — which this rebuild truncated and (before
9676 // this slice) never restored, stalling `advance_projection_cursor`.
9677 // TC-33: the filter is generated by `edge_validity_sql` and `:now` is
9678 // bound (?1) rather than inlined as `datetime('now')`.
9679 let edge_rows: Vec<(i64, String, Option<String>)> = {
9680 let edge_sql = format!(
9681 "SELECT write_cursor, kind, body FROM canonical_edges \
9682 WHERE superseded_at IS NULL{}",
9683 edge_validity_sql("canonical_edges", 1)
9684 );
9685 let mut edge_stmt = tx.prepare(&edge_sql).map_err(|_| EngineError::Storage)?;
9686 let rows = edge_stmt
9687 .query_map(params![current_epoch_seconds()], |row| {
9688 Ok((
9689 row.get::<_, i64>(0)?,
9690 row.get::<_, String>(1)?,
9691 row.get::<_, Option<String>>(2)?,
9692 ))
9693 })
9694 .map_err(|_| EngineError::Storage)?
9695 .collect::<rusqlite::Result<_>>()
9696 .map_err(|_| EngineError::Storage)?;
9697 rows
9698 };
9699 for (cursor, kind, body) in edge_rows {
9700 let has_body = body.is_some();
9701 project_canonical_edge_row(&tx, cursor as u64, &kind, body.as_deref(), pass)
9702 .map_err(|_| EngineError::Storage)?;
9703 if include_fts && has_body {
9704 rows_rebuilt = rows_rebuilt.saturating_add(1);
9705 }
9706 }
9707 let projection_cursor_after =
9708 load_projection_cursor(&tx).map_err(|_| EngineError::Storage)?;
9709 tx.commit().map_err(|_| EngineError::Storage)?;
9710 Ok(RebuildReport { kind, rows_invalidated, rows_rebuilt, projection_cursor_after })
9711 }
9712
9713 fn ensure_open(&self) -> Result<(), EngineError> {
9714 if self.closed.load(Ordering::SeqCst) {
9715 return Err(EngineError::Closing);
9716 }
9717
9718 Ok(())
9719 }
9720}
9721
9722fn batch_is_admin(batch: &[PreparedWrite]) -> bool {
9723 !batch.is_empty() && batch.iter().all(|w| matches!(w, PreparedWrite::AdminSchema { .. }))
9724}
9725
9726// 0.7.0 Pack 2 (ADR-0.7.0-vector-binary-quant § 2; handoff § 2.2):
9727// bit-KNN candidate-set size for the two-phase read path. Tuned with
9728// the recall@10 floor in tests/perf_gates.rs::ac_013b_recall_at_10_floor.
9729//
9730// Bumped from 64 → 192 in EU-5a2 per the HITL 2026-05-29 fine-grained
9731// K-sweep result (dev/notes/0.7.1-default-embedder-research.md §5.4):
9732// K=192 sits above the recall-plateau knee for the default embedder.
9733// Public-visible so the EU-5a2 machinery test can assert the value.
9734pub const TOP_K_BIT_CANDIDATES: usize = 192;
9735
9736/// EU-5a2 — number of documents required before the workspace's
9737/// `_fathomdb_embedder_profiles.mean_vec` is pinned for the default
9738/// profile. Per `dev/design/embedder.md` §0.3 (compute-once-on-first-
9739/// ingest lifecycle). Public-visible so the EU-5a2 machinery test can
9740/// assert the value.
9741pub const MEAN_VEC_PIN_THRESHOLD: u64 = 256;
9742
9743/// 0.7.2 PR-2bc S1 fix-1 — production phase-2 rerank `LIMIT` for engine
9744/// search. This is the original hardcoded `LIMIT 10`; it is the default and
9745/// the floor for `search_limit_override` (a test seam may RAISE it but never
9746/// shrink it below this). There is NO env-var override on the hot path.
9747pub const SEARCH_RERANK_LIMIT: usize = 10;
9748
9749/// EU-5a2 — streaming f64 accumulator for the mean-centering pipeline,
9750/// per `dev/design/embedder.md` §0.3 (f64 chosen to bound numerical
9751/// drift across `MEAN_VEC_PIN_THRESHOLD` adds). Owned by the projection
9752/// worker; materialized into the schema column at the threshold cross.
9753#[derive(Clone, Debug)]
9754struct MeanAccumulator {
9755 sum: Vec<f64>,
9756 count: u64,
9757}
9758
9759impl MeanAccumulator {
9760 fn new(dim: usize) -> Self {
9761 Self { sum: vec![0.0; dim], count: 0 }
9762 }
9763
9764 fn add(&mut self, v: &[f32]) {
9765 debug_assert_eq!(v.len(), self.sum.len(), "accumulator dim mismatch");
9766 for (slot, value) in self.sum.iter_mut().zip(v.iter()) {
9767 *slot += f64::from(*value);
9768 }
9769 self.count = self.count.saturating_add(1);
9770 }
9771
9772 fn materialize(&self) -> Vec<f32> {
9773 if self.count == 0 {
9774 return vec![0.0; self.sum.len()];
9775 }
9776 let denom = self.count as f64;
9777 self.sum.iter().map(|s| (s / denom) as f32).collect()
9778 }
9779
9780 fn count(&self) -> u64 {
9781 self.count
9782 }
9783}
9784
9785/// 0.7.2 PR-2b — cosine similarity between two equal-length vectors.
9786/// Returns 1.0 for a pair with a zero-norm operand (treated as "no drift
9787/// signal"), so the detector never fires on a degenerate all-zero mean.
9788fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
9789 if a.len() != b.len() {
9790 return 1.0;
9791 }
9792 let mut dot = 0.0f64;
9793 let mut na = 0.0f64;
9794 let mut nb = 0.0f64;
9795 for (x, y) in a.iter().zip(b.iter()) {
9796 dot += f64::from(*x) * f64::from(*y);
9797 na += f64::from(*x) * f64::from(*x);
9798 nb += f64::from(*y) * f64::from(*y);
9799 }
9800 if na == 0.0 || nb == 0.0 {
9801 return 1.0;
9802 }
9803 (dot / (na.sqrt() * nb.sqrt())) as f32
9804}
9805
9806/// EU-5b — at-pin pin-and-requantize pass per `dev/design/embedder.md`
9807/// §0.5. Runs INSIDE the caller's SQLite transaction so the mean_vec
9808/// INSERT/UPDATE + the per-row sign-bit UPDATEs commit atomically.
9809///
9810/// For each pre-pin row, recomputes `bits' = sign_quantize(f32 - mean)`
9811/// via the SQL extension's `vec_quantize_binary`, then UPDATEs the
9812/// row's `embedding_bin` column.
9813fn run_pin_and_requantize_pass(
9814 tx: &rusqlite::Transaction<'_>,
9815 rows: &[(i64, Vec<u8>)],
9816 mean: &[f32],
9817) -> Result<(u64, Vec<EmbedderEvent>), EngineError> {
9818 let mut updated: u64 = 0;
9819 let dim = mean.len();
9820 // sqlite-vec's vec0 xUpdate path discards SQL-function result subtypes
9821 // (see sqlite-vec.c §vec0Update_UpdateVectorColumn — "subtypes don't
9822 // appear to survive xColumn -> xUpdate, it's always 0"), so a direct
9823 // `UPDATE ... SET embedding_bin = vec_quantize_binary(?)` reads the
9824 // bound value as a float32-tagged vector and trips the column-type
9825 // check. We work around by DELETE+INSERT inside the same transaction:
9826 // INSERT preserves the BIT subtype on `vec_quantize_binary`. The
9827 // surrounding pin-commit tx keeps the rewrite atomic.
9828 for (rowid, blob) in rows {
9829 if blob.len() != dim * 4 {
9830 return Err(EngineError::Storage);
9831 }
9832 let un_centered = decode_vector_blob(blob);
9833 let centered = subtract_mean(&un_centered, mean);
9834 let centered_blob = encode_vector_blob(¢ered);
9835
9836 let (source_type, kind, created_at): (String, String, i64) = tx
9837 .query_row(
9838 "SELECT source_type, kind, created_at FROM vector_default WHERE rowid = ?1",
9839 params![rowid],
9840 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
9841 )
9842 .map_err(|_| EngineError::Storage)?;
9843
9844 // 0.8.20 Slice 15e — this DELETE+INSERT re-quantize (its BIT-subtype
9845 // workaround, condition #4's SEPARATE same-shape op) must PRESERVE every
9846 // `filterable` `attr_<hex>` value, not just re-`''` them: a projection may
9847 // have been declared before the pin. Read the live attr columns + this
9848 // row's values BEFORE the DELETE, then re-bind them. Empty ⇒ the INSERT is
9849 // byte-identical to the shipped statement.
9850 let attr_cols = actual_vector_attr_columns(tx).map_err(|_| EngineError::Storage)?;
9851 let attr_vals: Vec<String> = if attr_cols.is_empty() {
9852 Vec::new()
9853 } else {
9854 let select = attr_cols.join(", ");
9855 tx.query_row(
9856 &format!("SELECT {select} FROM vector_default WHERE rowid = ?1"),
9857 params![rowid],
9858 |row| {
9859 let mut vals = Vec::with_capacity(attr_cols.len());
9860 for i in 0..attr_cols.len() {
9861 vals.push(row.get::<_, String>(i)?);
9862 }
9863 Ok(vals)
9864 },
9865 )
9866 .map_err(|_| EngineError::Storage)?
9867 };
9868
9869 // TC-76: the attr VALUES were read above, so neutralizing them inside
9870 // [`delete_vector_partition_row`] cannot lose them; the re-INSERT below
9871 // re-binds `attr_vals` verbatim.
9872 delete_vector_partition_row(tx, *rowid).map_err(|_| EngineError::Storage)?;
9873
9874 // Slice 10 / G10 — `status` ships the empty-string sentinel (vec0 TEXT
9875 // metadata is NOT NULL-able).
9876 let mut cols_sql = String::new();
9877 let mut ph_sql = String::new();
9878 for (i, col) in attr_cols.iter().enumerate() {
9879 cols_sql.push_str(&format!(", {col}"));
9880 ph_sql.push_str(&format!(", ?{}", 7 + i));
9881 }
9882 let sql = format!(
9883 "INSERT INTO vector_default(
9884 rowid, embedding, embedding_bin, source_type, kind, created_at, status{cols_sql}
9885 ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, ''{ph_sql})"
9886 );
9887 let mut pv: Vec<rusqlite::types::Value> = vec![
9888 rusqlite::types::Value::Integer(*rowid),
9889 rusqlite::types::Value::Blob(blob.clone()),
9890 rusqlite::types::Value::Blob(centered_blob),
9891 rusqlite::types::Value::Text(source_type),
9892 rusqlite::types::Value::Text(kind),
9893 rusqlite::types::Value::Integer(created_at),
9894 ];
9895 for v in attr_vals {
9896 pv.push(rusqlite::types::Value::Text(v));
9897 }
9898 tx.execute(&sql, rusqlite::params_from_iter(pv.iter()))
9899 .map_err(|_| EngineError::Storage)?;
9900
9901 updated = updated.saturating_add(1);
9902 }
9903 let events = vec![EmbedderEvent::MeanVecPinned {
9904 dim: u32::try_from(dim).unwrap_or(u32::MAX),
9905 doc_count: updated,
9906 }];
9907 Ok((updated, events))
9908}
9909
9910/// EU-5a2 — back-compat test-only count+emit helper. Preserved so the
9911/// EU-5a2 machinery test stays green; the EU-5b production path uses
9912/// `run_pin_and_requantize_pass`.
9913fn run_requantize_pass(rows: &[(i64, Vec<u8>)], mean: &[f32]) -> (u64, Vec<EmbedderEvent>) {
9914 let mut updated: u64 = 0;
9915 let dim = mean.len();
9916 for (_rowid, blob) in rows {
9917 if blob.len() != dim * 4 {
9918 continue;
9919 }
9920 updated = updated.saturating_add(1);
9921 }
9922 let events = vec![EmbedderEvent::MeanVecPinned {
9923 dim: u32::try_from(dim).unwrap_or(u32::MAX),
9924 doc_count: updated,
9925 }];
9926 (updated, events)
9927}
9928
9929/// EU-5a2 — test-visible re-exports of the mean-centering internals.
9930/// Per the handoff RED tests; the production accumulator and re-quantize
9931/// pass are otherwise crate-private.
9932#[doc(hidden)]
9933pub mod mean_centering_internals_for_test {
9934 use super::{EmbedderEvent, MeanAccumulator};
9935
9936 pub struct AccumulatorHandle(MeanAccumulator);
9937
9938 #[must_use]
9939 pub fn new_mean_accumulator(dim: usize) -> AccumulatorHandle {
9940 AccumulatorHandle(MeanAccumulator::new(dim))
9941 }
9942
9943 pub fn accumulator_add(handle: &mut AccumulatorHandle, v: &[f32]) {
9944 handle.0.add(v);
9945 }
9946
9947 #[must_use]
9948 pub fn accumulator_materialize(handle: &AccumulatorHandle) -> Vec<f32> {
9949 handle.0.materialize()
9950 }
9951
9952 #[must_use]
9953 pub fn accumulator_count(handle: &AccumulatorHandle) -> u64 {
9954 handle.0.count()
9955 }
9956
9957 #[must_use]
9958 pub fn run_requantize_pass(rows: &[(i64, Vec<u8>)], mean: &[f32]) -> (u64, Vec<EmbedderEvent>) {
9959 super::run_requantize_pass(rows, mean)
9960 }
9961}
9962
9963/// G9 — Reciprocal Rank Fusion constant. IR-C (2026-06-10b,
9964/// `performance-output-and-compare.md`) found the standard `k≈60` slightly too
9965/// high: the recall gain is concentrated at the top of the list, where a lower
9966/// `k` sharpens rank-1/2 contributions. `k=30` is the validated operating point
9967/// (`k10 > k30 > k60 > k100` on the sweep, `30` the conservative middle).
9968/// Fusion is on **rank**, never raw score.
9969pub const RRF_K: f64 = 30.0;
9970
9971/// G9 / IR-C — per-branch RRF weights. The sweep's optimum is strongly
9972/// **text-dominant** (`text:vector ≈ 3:1`): the lexical (BM25) arm carries
9973/// exact-fact recall and the dense arm, over-weighted, is a net drag on
9974/// exploratory recall (`performance-output-and-compare.md`, 2026-06-10b/e). A
9975/// branch contributes `weight / (RRF_K + rank)`.
9976pub const RRF_WEIGHT_VECTOR: f64 = 1.0;
9977pub const RRF_WEIGHT_TEXT: f64 = 3.0;
9978/// R3 (Slice 30) — graph arm RRF weight. Conservative starting value (equal to
9979/// `RRF_WEIGHT_VECTOR`). Without R2 per-class delta data the graph arm weight
9980/// cannot be calibrated; 1.0 is the minimum non-zero contribution. The graph
9981/// arm surfaces newly-reachable nodes from BFS traversal; it is not meant to
9982/// override the primary text/vector signals. Revisable after R2 data arrives.
9983/// See `dev/design/slice-30-design.md` §Q2.
9984pub const RRF_WEIGHT_GRAPH: f64 = 1.0;
9985
9986/// G12-recency — additive recency weight. Must satisfy two constraints:
9987/// 1. Small enough to never override a clear RRF signal: a gap of > RECENCY_WEIGHT
9988/// between two hits' RRF scores means the stronger RRF hit always wins.
9989/// 2. Large enough to break exact ties: any hit with a higher `write_cursor` (more
9990/// recent) gets RECENCY_WEIGHT × 1.0 > 0 nudge and wins a tied comparison.
9991///
9992/// Value 0.002 satisfies the near-tie-nudge contract with respect to the
9993/// committed test (`recency_does_not_override_a_clear_rrf_signal`):
9994/// the test's RRF gap is 0.01, which is larger than 0.002, so recency
9995/// never overrides it. Note: this value is larger than the minimum
9996/// vector-only rank-step at deep ranks (~0.00101 for adjacent ranks near
9997/// the bottom), so recency can flip a single-rank vector difference at
9998/// deep ranks — by design, recency is a near-tie nudge, and "near-tie"
9999/// is scoped to the test gap (0.01), not to every possible rank step.
10000///
10001/// 0.8.1 Slice 10 fix: the previous value `0.5/RRF_K ≈ 0.01667` violated
10002/// the test gap constraint (it exceeded 0.01). Lowered to 0.002.
10003pub const RECENCY_WEIGHT: f64 = 0.002;
10004
10005/// 0.8.8 Slice 15 — the lowercase wire string for a retrieval arm (telemetry +
10006/// the same spelling `SearchHit.branch` crosses every binding).
10007fn branch_str(branch: SoftFallbackBranch) -> &'static str {
10008 match branch {
10009 SoftFallbackBranch::Vector => "vector",
10010 SoftFallbackBranch::Text => "text",
10011 SoftFallbackBranch::TextEdge => "text_edge",
10012 SoftFallbackBranch::GraphArm => "graph_arm",
10013 }
10014}
10015
10016/// 0.8.8 Slice 15 — append one JSON value as a line to the telemetry sink
10017/// (append-only, local file; no network). Best-effort caller handles the error.
10018fn append_jsonl(path: &Path, value: &serde_json::Value) -> std::io::Result<()> {
10019 let mut file = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
10020 writeln!(file, "{value}")?;
10021 Ok(())
10022}
10023
10024/// 0.8.20 Slice 5b (R-20-E6) — rewrite a telemetry JSONL sink with every
10025/// `result_stable_ids` element in `erased` replaced by [`REDACTED_STABLE_ID`].
10026///
10027/// **Selective, never truncating.** Every line is carried across: records that
10028/// reference no erased id are byte-identical, records that do keep their shape
10029/// and only lose the matching id VALUES, and a line that is not an
10030/// engine-authored JSON event (an operator note, a hand-appended record) is
10031/// copied through verbatim. The sink is a caller-supplied path that may hold
10032/// unrelated eval history; destroying it is not part of any erasure obligation.
10033///
10034/// **Crash safety.** Write-temp-then-`rename`: the redacted content goes to a
10035/// sibling `<sink>.redact.tmp`, is `sync_all`ed, and is then atomically renamed
10036/// over the sink. A crash at any point leaves either the intact old file or the
10037/// complete new one — never a half-rewritten sink. `rename` is atomic because
10038/// the temp file is a sibling (same directory ⇒ same filesystem).
10039///
10040/// **Concurrent appends.** The caller holds the telemetry mutex, so no
10041/// in-process `capture_telemetry` can append into the window. An out-of-process
10042/// appender is still possible (the sink is just a file), so before renaming we
10043/// re-check the source length: if it grew, the tail delta is read, redacted and
10044/// appended, and the check repeats — bounded, so a pathologically hot external
10045/// writer surfaces as an error rather than an unbounded loop.
10046fn redact_jsonl_stable_ids(
10047 path: &Path,
10048 erased: &std::collections::HashSet<&str>,
10049) -> std::io::Result<()> {
10050 /// Bound on the re-check loop for an out-of-process appender.
10051 const MAX_TAIL_FOLDS: usize = 8;
10052
10053 let mut source = std::fs::read(path)?;
10054 let mut redacted = redact_jsonl_bytes(&source, erased);
10055
10056 for _ in 0..MAX_TAIL_FOLDS {
10057 let current = std::fs::read(path)?;
10058 if current.len() == source.len() {
10059 let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
10060 tmp_name.push(".redact.tmp");
10061 let tmp = path.with_file_name(tmp_name);
10062 {
10063 let mut file = std::fs::File::create(&tmp)?;
10064 file.write_all(&redacted)?;
10065 file.sync_all()?;
10066 }
10067 std::fs::rename(&tmp, path)?;
10068 return Ok(());
10069 }
10070 // Someone appended while we were building the replacement: fold the
10071 // delta in (redacted) rather than dropping it, then re-check.
10072 if current.len() > source.len() && current.starts_with(&source) {
10073 redacted.extend_from_slice(&redact_jsonl_bytes(¤t[source.len()..], erased));
10074 } else {
10075 // The file was rewritten under us, not appended to. Start over.
10076 redacted = redact_jsonl_bytes(¤t, erased);
10077 }
10078 source = current;
10079 }
10080 Err(std::io::Error::other(format!(
10081 "telemetry sink {} is being appended to faster than it can be redacted",
10082 path.display()
10083 )))
10084}
10085
10086/// Line-wise redaction of a JSONL byte buffer. Non-JSON and non-event lines are
10087/// passed through unchanged, as is a trailing partial line (no terminating
10088/// newline) — the sink is append-only, so a partial tail is a torn write, not
10089/// ours to normalize.
10090fn redact_jsonl_bytes(bytes: &[u8], erased: &std::collections::HashSet<&str>) -> Vec<u8> {
10091 let mut out = Vec::with_capacity(bytes.len());
10092 let mut rest = bytes;
10093 while !rest.is_empty() {
10094 let (line, tail) = match rest.iter().position(|b| *b == b'\n') {
10095 Some(idx) => (&rest[..idx], &rest[idx + 1..]),
10096 // No trailing newline: a torn/partial final line. Pass it through.
10097 None => {
10098 out.extend_from_slice(rest);
10099 break;
10100 }
10101 };
10102 match redact_jsonl_line(line, erased) {
10103 Some(replacement) => out.extend_from_slice(replacement.as_bytes()),
10104 None => out.extend_from_slice(line),
10105 }
10106 out.push(b'\n');
10107 rest = tail;
10108 }
10109 out
10110}
10111
10112/// `Some(replacement)` when the line is an engine-authored telemetry event whose
10113/// `result_stable_ids` referenced an erased id; `None` to pass it through.
10114fn redact_jsonl_line(line: &[u8], erased: &std::collections::HashSet<&str>) -> Option<String> {
10115 let text = std::str::from_utf8(line).ok()?;
10116 let mut value: serde_json::Value = serde_json::from_str(text).ok()?;
10117 let ids = value.get_mut("result_stable_ids")?.as_array_mut()?;
10118 let mut touched = false;
10119 for id in ids.iter_mut() {
10120 if id.as_str().is_some_and(|s| erased.contains(s)) {
10121 *id = serde_json::Value::from(REDACTED_STABLE_ID);
10122 touched = true;
10123 }
10124 }
10125 touched.then(|| value.to_string())
10126}
10127
10128/// G9 — fuse the vector and text branches with Reciprocal Rank Fusion.
10129///
10130/// Delegates to [`fuse_three_arms`] with an empty graph arm. The two-arm
10131/// contract is preserved: `fuse_rrf(v, t)` == `fuse_three_arms(v, t, vec![])`.
10132/// All existing callers are unaffected.
10133///
10134/// See [`fuse_three_arms`] for the full RRF formula documentation.
10135#[doc(hidden)]
10136#[must_use]
10137pub fn fuse_rrf(vector_hits: Vec<SearchHit>, text_hits: Vec<SearchHit>) -> Vec<SearchHit> {
10138 fuse_three_arms(vector_hits, text_hits, vec![])
10139}
10140
10141/// R3 (Slice 30) — fuse vector, text, and graph arms with Reciprocal Rank Fusion.
10142///
10143/// Each branch contributes `weight / (RRF_K + rank)` (1-based rank within that
10144/// branch; `weight` = [`RRF_WEIGHT_VECTOR`] / [`RRF_WEIGHT_TEXT`] /
10145/// [`RRF_WEIGHT_GRAPH`], text-dominant per IR-C), accumulated **keyed on
10146/// `SearchHit.body`**, so a body surfaced by multiple branches accumulates all
10147/// terms (agreement boosts it). The fused value is written into `SearchHit.score`.
10148/// A body in multiple branches surfaces **once** with the **vector** branch's
10149/// identity (vector-first), then graph arm identity for non-vector hits, then
10150/// text. Output is sorted by score descending, then vector-first, then insertion
10151/// order — a pure, deterministic function of the three input lists.
10152///
10153/// With an empty `graph_hits` (`vec![]`), the output is byte-identical to the
10154/// pre-Slice-30 two-arm `fuse_rrf`. This is the backward-compatibility contract.
10155///
10156/// This is the **unconditional** new ranking (HITL Q3 — no `fusion_mode` knob,
10157/// no legacy path). Graph arm is opt-in via `use_graph_arm=true`.
10158#[doc(hidden)]
10159#[must_use]
10160pub fn fuse_three_arms(
10161 vector_hits: Vec<SearchHit>,
10162 text_hits: Vec<SearchHit>,
10163 graph_hits: Vec<SearchHit>,
10164) -> Vec<SearchHit> {
10165 struct Entry {
10166 hit: SearchHit,
10167 score: f64,
10168 in_vector: bool,
10169 order: usize,
10170 }
10171 let mut entries: Vec<Entry> = Vec::new();
10172 let mut accumulate = |hit: SearchHit, rank0: usize, in_vector: bool, weight: f64| {
10173 let contrib = weight / (RRF_K + (rank0 as f64 + 1.0));
10174 if let Some(existing) = entries.iter_mut().find(|e| e.hit.body == hit.body) {
10175 // Dedup on body; the representative hit (vector-first) is retained.
10176 existing.score += contrib;
10177 } else {
10178 let order = entries.len();
10179 entries.push(Entry { hit, score: contrib, in_vector, order });
10180 }
10181 };
10182 for (rank0, hit) in vector_hits.into_iter().enumerate() {
10183 accumulate(hit, rank0, true, RRF_WEIGHT_VECTOR);
10184 }
10185 for (rank0, hit) in text_hits.into_iter().enumerate() {
10186 accumulate(hit, rank0, false, RRF_WEIGHT_TEXT);
10187 }
10188 for (rank0, hit) in graph_hits.into_iter().enumerate() {
10189 // Graph arm: vector-first=false (never overrides an existing vector hit's
10190 // representative identity; only new bodies from the graph arm get GraphArm
10191 // as their branch identity). The in_vector=false ensures graph arm hits
10192 // never sort ahead of vector hits on exact score ties.
10193 accumulate(hit, rank0, false, RRF_WEIGHT_GRAPH);
10194 }
10195 entries.sort_by(|a, b| {
10196 b.score
10197 .partial_cmp(&a.score)
10198 .unwrap_or(std::cmp::Ordering::Equal)
10199 // vector-first on equal score (true sorts before false).
10200 .then_with(|| b.in_vector.cmp(&a.in_vector))
10201 .then_with(|| a.order.cmp(&b.order))
10202 });
10203 entries
10204 .into_iter()
10205 .map(|mut e| {
10206 e.hit.score = e.score;
10207 e.hit
10208 })
10209 .collect()
10210}
10211
10212/// G12-recency — reweight fused hits toward the more recent (higher
10213/// `write_cursor`/`id`) AFTER bit-KNN (never a vec0 predicate). Gated by the
10214/// caller's dedicated recency flag; `enabled=false` is a no-op (pure RRF).
10215#[doc(hidden)]
10216#[must_use]
10217pub fn apply_recency_reweight(hits: Vec<SearchHit>, enabled: bool) -> Vec<SearchHit> {
10218 if !enabled || hits.len() < 2 {
10219 return hits;
10220 }
10221 let min_id = hits.iter().map(|h| h.write_cursor).min().unwrap_or(0);
10222 let max_id = hits.iter().map(|h| h.write_cursor).max().unwrap_or(0);
10223 if max_id == min_id {
10224 return hits;
10225 }
10226 let span = (max_id - min_id) as f64;
10227 let mut reweighted: Vec<SearchHit> = hits
10228 .into_iter()
10229 .map(|mut h| {
10230 let norm = (h.write_cursor - min_id) as f64 / span;
10231 h.score += RECENCY_WEIGHT * norm;
10232 h
10233 })
10234 .collect();
10235 // Stable sort preserves the fused order on exact ties.
10236 reweighted.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
10237 reweighted
10238}
10239
10240/// 0.8.16 Slice 5 / F9 — OFF-by-default importance/confidence reweight, applied
10241/// to the fused hits AFTER bit-KNN + RRF (mirrors [`apply_recency_reweight`]).
10242///
10243/// Multiplicative-on-fused (ADR-0.8.16 §2.2, HITL-SIGNED 2026-07-08): a hit's
10244/// score is scaled by node `importance` (`importance_by_id`) × edge `confidence`
10245/// (`confidence_by_id`), each keyed by the hit's interim id (`write_cursor`). A
10246/// missing key = `NULL` = never assigned = graceful-absent ⇒ neutral (1.0), the
10247/// OPP-12 Q6a graceful-absent state. Node hits carry `importance`; graph/edge hits
10248/// carry `confidence`; the two id-spaces never collide (cursors are globally
10249/// unique), so each hit gets exactly one non-neutral factor.
10250///
10251/// **R-F9-4 graceful-neutral identity:** when `enabled` but *no* hit has a
10252/// non-neutral factor (every importance/confidence absent), the input is returned
10253/// **unchanged** — byte-identical to the `enabled == false` result (no re-sort),
10254/// so declaring the mechanism never perturbs an all-absent corpus.
10255#[must_use]
10256pub fn apply_importance_reweight(
10257 hits: Vec<SearchHit>,
10258 importance_by_id: &HashMap<u64, f64>,
10259 confidence_by_id: &HashMap<u64, f64>,
10260 enabled: bool,
10261) -> Vec<SearchHit> {
10262 if !enabled {
10263 return hits;
10264 }
10265 // Graceful-neutral fast path (R-F9-4): if nothing is weighted, do not touch
10266 // order or scores — identical to the reweight-OFF result.
10267 let any_weighted = hits.iter().any(|h| {
10268 importance_by_id.contains_key(&h.write_cursor)
10269 || confidence_by_id.contains_key(&h.write_cursor)
10270 });
10271 if !any_weighted {
10272 return hits;
10273 }
10274 let mut reweighted: Vec<SearchHit> = hits
10275 .into_iter()
10276 .map(|mut h| {
10277 let importance = importance_by_id.get(&h.write_cursor).copied().unwrap_or(1.0);
10278 let confidence = confidence_by_id.get(&h.write_cursor).copied().unwrap_or(1.0);
10279 h.score *= importance * confidence;
10280 h
10281 })
10282 .collect();
10283 // Stable sort preserves the fused order on exact ties.
10284 reweighted.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
10285 reweighted
10286}
10287
10288/// 0.8.16 Slice 5 / F9 — build the per-hit importance/confidence weight maps for
10289/// the candidate `hits` from the durable columns (`canonical_nodes.importance`,
10290/// `canonical_edges.confidence`). Only NON-NULL values are inserted; an absent
10291/// value stays out of the map (graceful-absent ⇒ neutral in
10292/// [`apply_importance_reweight`]). Prepared statements are guarded so a pre-step-18
10293/// / pre-step-14 schema (no column) yields empty maps rather than an error.
10294fn build_importance_confidence_maps(
10295 tx: &rusqlite::Connection,
10296 hits: &[SearchHit],
10297) -> rusqlite::Result<(HashMap<u64, f64>, HashMap<u64, f64>)> {
10298 let mut importance_by_id: HashMap<u64, f64> = HashMap::new();
10299 let mut confidence_by_id: HashMap<u64, f64> = HashMap::new();
10300 if let Ok(mut stmt) =
10301 tx.prepare("SELECT importance FROM canonical_nodes WHERE write_cursor = ?1 LIMIT 1")
10302 {
10303 for h in hits {
10304 if let Ok(Some(v)) = stmt.query_row([h.write_cursor], |r| r.get::<_, Option<f64>>(0)) {
10305 importance_by_id.insert(h.write_cursor, v);
10306 }
10307 }
10308 }
10309 if let Ok(mut stmt) = tx.prepare(
10310 "SELECT confidence FROM canonical_edges \
10311 WHERE write_cursor = ?1 AND superseded_at IS NULL LIMIT 1",
10312 ) {
10313 for h in hits {
10314 if let Ok(Some(v)) = stmt.query_row([h.write_cursor], |r| r.get::<_, Option<f64>>(0)) {
10315 confidence_by_id.insert(h.write_cursor, v);
10316 }
10317 }
10318 }
10319 Ok((importance_by_id, confidence_by_id))
10320}
10321
10322/// 0.8.1 Slice 10 (R1) — CE rerank seam.
10323///
10324/// `rerank_depth = 0` (or model absent / `default-reranker` feature off): returns
10325/// `hits` **unchanged** — byte-identical to the old identity stub. This is the
10326/// soft-fallback contract.
10327///
10328/// `rerank_depth > 0` with the `default-reranker` feature on and the model
10329/// loaded: scores the top-`rerank_depth` (query, passage) pairs with the
10330/// TinyBERT-L-2 cross-encoder, blends CE score with the RRF score using the
10331/// formula from the design memo (Decision 5), re-sorts the top-N, and appends
10332/// the remainder in their original RRF order.
10333///
10334/// Score-blend (Decision 5): `α × sigmoid(ce_logit) + (1−α) × rrf_score_normalized`
10335/// where both CE and RRF scores are normalized to [0,1] over the reranked pool.
10336///
10337/// 0.8.5 (EXP-0): `alpha` (clamped to `[0,1]`) and `pool_n` (the reranked-pool
10338/// size, clamped to `hits.len()`) are caller-supplied. The defaults
10339/// `alpha = 0.3, pool_n = rerank_depth` reproduce the pre-slice blend exactly.
10340/// `rerank_depth == 0` remains the identity gate regardless of `pool_n`.
10341///
10342/// This is the rerank hook, **not** the dropped `fusion_mode` knob.
10343#[doc(hidden)]
10344#[must_use]
10345pub fn rerank_fused(
10346 _query: &str,
10347 hits: Vec<SearchHit>,
10348 rerank_depth: usize,
10349 alpha: f64,
10350 pool_n: usize,
10351) -> Vec<SearchHit> {
10352 // Soft-fallback: depth=0 → identity (byte-identical to old stub). NOTE this
10353 // early gate is independent of `pool_n`: `rerank_depth == 0, pool_n = 10`
10354 // does NOT rerank (0.8.5 D4).
10355 if rerank_depth == 0 {
10356 return hits;
10357 }
10358
10359 // Feature-gated CE inference. In the default build (no feature) this block
10360 // compiles away and `hits` is returned unchanged regardless of `rerank_depth`.
10361 // FIX-1: pass `&hits` (borrow) so `hits` remains owned for the soft-fallback path.
10362 #[cfg(feature = "default-reranker")]
10363 {
10364 if let Some(reranked) = ce_rerank(_query, &hits, rerank_depth, alpha, pool_n) {
10365 return reranked;
10366 }
10367 }
10368
10369 // 0.8.5: the bindings/default callers pass `alpha = 0.3, pool_n = rerank_depth`;
10370 // referenced here so the no-feature build does not warn on unused params.
10371 #[cfg(not(feature = "default-reranker"))]
10372 let _ = (alpha, pool_n);
10373
10374 // Model absent (feature off, weights not loaded, or CE returned None) →
10375 // soft-fallback: return input unchanged.
10376 hits
10377}
10378
10379/// 0.8.2 Slice E2 — standalone CE rerank of a caller-supplied passage list.
10380///
10381/// The pure, testable core that the `fathomdb.rerank` pyo3 binding is a thin
10382/// wrapper over. Slice 5's `fused_rerank` comparator must CE-rerank its OWN
10383/// in-harness fused(bm25+dense) pool — a pool the engine's `search()` never
10384/// constructs — so the CE has to be reachable over an arbitrary passage list,
10385/// not just the engine's capped text-only pool. This adapts `(id, body, score)`
10386/// passages into `SearchHit`s (`kind = "passage"`, `branch = Vector`,
10387/// `source_id = None`; only `body` and `score` feed the blend), runs them
10388/// through [`rerank_fused`], and projects back to `(id, score, ce_score)` in the reranked
10389/// order.
10390///
10391/// Contract (inherited verbatim from `rerank_fused`): `rerank_depth == 0` OR an
10392/// empty list returns the input order WITH the input scores, byte-identical — no
10393/// model load, no network. With `--features default-reranker` and
10394/// `rerank_depth > 0` the CE blends the top-`depth` and may reorder; with the
10395/// feature off the CE path compiles away and this is always identity.
10396///
10397/// 0.8.2 Slice E2 fix-1 [P2]: returns `Err` when any passage carries a non-finite
10398/// score (NaN / ±inf), mirroring the malformed-passage loud-fail contract.
10399/// Callers (pyo3 `rerank` binding, tests) must handle `Result`.
10400/// (`#[must_use]` removed: `Result` is already `#[must_use]`.)
10401pub fn rerank_passages(
10402 query: &str,
10403 passages: Vec<(u64, String, f64)>,
10404 rerank_depth: usize,
10405 alpha: f64,
10406 pool_n: usize,
10407) -> Result<Vec<(u64, f64, Option<f64>)>, String> {
10408 // [P2] guard: reject non-finite scores before they reach normalization/sort.
10409 // A NaN or ±inf score would produce NaN blended scores and an unstable sort
10410 // order — surface the error early as the typed WriteValidationError at the
10411 // pyo3 boundary (mirroring the malformed-passage loud-fail contract).
10412 for (id, _, score) in &passages {
10413 if !score.is_finite() {
10414 return Err(format!(
10415 "rerank: non-finite score for passage id={id}: {score} \
10416 (NaN/\u{00b1}inf must not reach the normalization/sort step)"
10417 ));
10418 }
10419 }
10420 let hits: Vec<SearchHit> = passages
10421 .into_iter()
10422 .map(|(id, body, score)| SearchHit {
10423 // C-2: synthetic passages carry no canonical identity — mint the
10424 // `Passage` (`p:`) id from the caller-supplied ordinal. The ordinal
10425 // is ALSO kept as the engine-internal positional cursor so the
10426 // projection below returns it byte-unchanged.
10427 id: IdSpace::passage(id.to_string()),
10428 write_cursor: id,
10429 kind: "passage".to_string(),
10430 body,
10431 score,
10432 branch: SoftFallbackBranch::Vector,
10433 source_id: None,
10434 ce_score: None,
10435 })
10436 .collect();
10437 // 0.8.5 — project `(id, score, ce_score)` so the binding can surface the CE
10438 // score per candidate; `ce_score` is `None` for the identity / out-of-pool path.
10439 // The projected id is the caller's ordinal (the engine-internal `write_cursor`).
10440 Ok(rerank_fused(query, hits, rerank_depth, alpha, pool_n)
10441 .into_iter()
10442 .map(|h| (h.write_cursor, h.score, h.ce_score))
10443 .collect())
10444}
10445
10446/// 0.8.1 Slice 10 — score-blend reranking when CE model is loaded.
10447///
10448/// Returns `Some(reranked)` if the model is available, `None` otherwise
10449/// (caller then applies the soft-fallback).
10450///
10451/// Design memo Decision 5:
10452/// - CE normalized = sigmoid(raw_logit) ∈ [0,1]
10453/// - RRF normalized = min-max of `hit.score` over the top-K pool
10454/// - `final_score = 0.3 × ce_norm + 0.7 × rrf_norm`
10455/// - Hits beyond `rerank_depth` keep their original RRF scores and order.
10456#[cfg(feature = "default-reranker")]
10457fn ce_rerank(
10458 _query: &str,
10459 hits: &[SearchHit], // FIX-1: borrow, not move — caller retains ownership for soft-fallback
10460 _rerank_depth: usize, // 0.8.5: pool sizing moved to `pool_n`; depth gate stays in `rerank_fused`.
10461 alpha: f64,
10462 pool_n: usize,
10463) -> Option<Vec<SearchHit>> {
10464 // 0.8.5 (D3) — clamp α to [0,1] silently here so EVERY path (engine search,
10465 // `rerank_passages`, the bindings) is covered by one clamp, matching the
10466 // existing `pool_n.min(len)` clamp idiom.
10467 // codex §9 P2-1: `f64::clamp(NaN)` returns NaN (clamp does NOT map NaN into
10468 // range) — a non-finite α would then make every blended score NaN and destroy
10469 // the ranking. The high-level SDKs reject non-finite α, but the low-level
10470 // `rerank()` / direct-Rust callers don't, so fall back to the documented
10471 // default α=0.3 here for any non-finite input.
10472 let alpha = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 0.3 };
10473 // fix-1 [P2]: short-circuit before touching the singleton when there is
10474 // nothing to rerank — avoids loading/downloading the ~17 MB model for an
10475 // empty result set and prevents memoizing a transient load failure.
10476 if hits.is_empty() {
10477 return Some(vec![]);
10478 }
10479
10480 // Try to get the loaded model. Returns None when weights are absent.
10481 let model = CandleCrossEncoder::try_get_loaded()?;
10482
10483 // 0.8.5 (D4) — the reranked pool is the top `pool_n` (caller resolves the
10484 // `unwrap_or(rerank_depth)` default at the binding), clamped to the hit count.
10485 let n = pool_n.min(hits.len());
10486 let top = &hits[..n]; // no split_at_mut needed; borrow slices directly
10487 let rest = &hits[n..];
10488
10489 // --- RRF min-max normalization over the top-N pool ---
10490 let rrf_min = top.iter().map(|h| h.score).fold(f64::INFINITY, f64::min);
10491 let rrf_max = top.iter().map(|h| h.score).fold(f64::NEG_INFINITY, f64::max);
10492 let rrf_span = rrf_max - rrf_min;
10493
10494 // Batched CE scoring: ONE forward over the whole top-N pool instead of N
10495 // per-pair forwards. The ranking math below (RRF min-max norm, sigmoid,
10496 // ALPHA blend, sort) is byte-unchanged — only the scoring is batched.
10497 let bodies: Vec<&str> = top.iter().map(|h| h.body.as_str()).collect();
10498 let raw_logits = model.score_batch(_query, &bodies);
10499
10500 let mut scored: Vec<(f64, SearchHit)> = top
10501 .iter()
10502 .zip(raw_logits)
10503 .map(|(h, raw_logit)| {
10504 let rrf_norm = if rrf_span > 0.0 { (h.score - rrf_min) / rrf_span } else { 1.0 };
10505 // Sigmoid for CE normalization: 1/(1+exp(-x)).
10506 let ce_norm = 1.0 / (1.0 + (-raw_logit).exp());
10507 // 0.8.5 — α is the caller-supplied (clamped) blend weight; default 0.3
10508 // reproduces the pre-slice `const ALPHA = 0.3` blend exactly.
10509 let blended = alpha * ce_norm + (1.0 - alpha) * rrf_norm;
10510 // 0.8.5 (D1) — expose the per-candidate CE score on in-pool hits.
10511 let mut hit = h.clone();
10512 hit.ce_score = Some(ce_norm);
10513 (blended, hit)
10514 })
10515 .collect();
10516
10517 // Sort top-N by blended score descending (stable within ties by original order).
10518 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
10519
10520 let mut result: Vec<SearchHit> = scored
10521 .into_iter()
10522 .map(|(score, mut h)| {
10523 h.score = score;
10524 h
10525 })
10526 .collect();
10527
10528 // Append hits beyond rerank_depth in their original RRF order.
10529 result.extend_from_slice(rest);
10530 Some(result)
10531}
10532
10533/// 0.8.1 Slice 10 (R1) / 0.8.2 Slice E1 — CPU TinyBERT-L-2 cross-encoder.
10534///
10535/// Thin engine-side handle over the embedder crate's `CandleTinyBertReranker`
10536/// (Candle BERT stack + `tokenizers`, pinned `cross-encoder/ms-marco-TinyBERT-
10537/// L2-v2`). The model is loaded once, process-wide, the first time
10538/// `rerank_depth > 0` reaches the CE path (lazy init via the `OnceLock` below);
10539/// on cache miss that first load fetches the ~17 MB weights over the network
10540/// (sha256-verified). When the weights are absent and the network is
10541/// unavailable, the load fails and `try_get_loaded()` returns `None` so the
10542/// caller soft-falls-back to RRF order — it never panics.
10543///
10544/// Footprint: this whole type compiles ONLY under `default-reranker`. With the
10545/// feature off the CE path compiles away and `rerank_fused` is always identity.
10546/// With the feature on, `rerank_depth == 0` short-circuits in `rerank_fused`
10547/// BEFORE this is ever touched, so depth-0 stays byte-identical and no-network.
10548/// Similarly, an empty hit set short-circuits in `ce_rerank` before the singleton
10549/// is consulted (fix-1 [P2]).
10550#[cfg(feature = "default-reranker")]
10551struct CandleCrossEncoder {
10552 inner: &'static fathomdb_embedder::CandleTinyBertReranker,
10553}
10554
10555/// Process-wide lazily-initialized reranker. `None` once initialization has
10556/// been attempted and failed (no weights + no network) — memoized so a failed
10557/// load is not retried on every query.
10558#[cfg(feature = "default-reranker")]
10559fn reranker_singleton() -> Option<&'static fathomdb_embedder::CandleTinyBertReranker> {
10560 static CELL: std::sync::OnceLock<Option<fathomdb_embedder::CandleTinyBertReranker>> =
10561 std::sync::OnceLock::new();
10562 CELL.get_or_init(|| fathomdb_embedder::CandleTinyBertReranker::try_load().ok()).as_ref()
10563}
10564
10565#[cfg(feature = "default-reranker")]
10566impl CandleCrossEncoder {
10567 /// Returns a model handle if the reranker is (or can be) loaded, `None`
10568 /// otherwise. The first call drives the lazy load (cache probe → gated
10569 /// download); subsequent calls reuse the memoized result.
10570 fn try_get_loaded() -> Option<Self> {
10571 Some(Self { inner: reranker_singleton()? })
10572 }
10573
10574 /// Score a (query, passage) pair. Returns the raw cross-encoder logit, or
10575 /// `0.0` (a neutral logit → sigmoid 0.5) if the forward pass errors, so a
10576 /// single bad pair degrades to a neutral CE contribution rather than
10577 /// panicking in the reader thread.
10578 fn score(&self, query: &str, passage: &str) -> f64 {
10579 self.inner.score(query, passage).map(f64::from).unwrap_or(0.0)
10580 }
10581
10582 /// Batched [`score`](Self::score): score every `(query, passage_i)` pair in a
10583 /// single forward pass. Returns one logit per passage in input order, each
10584 /// honoring the same neutral-`0.0`-on-error contract as [`score`](Self::score).
10585 ///
10586 /// Fallback: if the batched forward errors as a whole (e.g. an OOM or a
10587 /// tokenize failure on one pair surfaces as a batch `Err`), we DO NOT
10588 /// neutralize the entire pool — we fall back to per-pair [`score`](Self::score),
10589 /// so a single bad pair degrades only its own element to a neutral logit while
10590 /// the rest keep their real scores. Empty input → empty output (no forward).
10591 fn score_batch(&self, query: &str, passages: &[&str]) -> Vec<f64> {
10592 match self.inner.score_batch(query, passages) {
10593 Ok(logits) => logits.into_iter().map(f64::from).collect(),
10594 Err(_) => passages.iter().map(|p| self.score(query, p)).collect(),
10595 }
10596 }
10597}
10598
10599/// 0.8.20 Slice 15e fix-2 finding 1 [P2] + keystone closeout fix-3 (codex §9 [P2],
10600/// TOCTOU) — reject a search filter that names an attribute with NO declared
10601/// `filterable` projection, ON THE READER'S OWN SNAPSHOT, before the vec0 SQL is
10602/// built.
10603///
10604/// The vector arm lowers each `filter.attributes` term to `AND attr_<hex>=?`
10605/// against a vec0 metadata column that exists ONLY for a declared `filterable`
10606/// projection (the reshape in [`reconcile_vector_attr_columns`] tracks exactly the
10607/// registry's `filterable` set; see [`desired_vector_attr_columns`]). A name that
10608/// is not a declared `filterable` projection therefore has no column: the vec0 KNN
10609/// would fail with `no such column` (surfacing as an opaque `Storage` error),
10610/// while the FTS arm ([`hit_attributes_pass_filter`]) would silently no-match. That
10611/// divergence violates ADR-0.8.11 D3 (every filter term has a DEFINED, arm-uniform
10612/// outcome).
10613///
10614/// fix-3 snapshot contract: this is called from [`read_search_in_tx`] INSIDE the
10615/// reader's `DEFERRED` transaction, so the `_fathomdb_projection_registry` it reads
10616/// and the `vector_default` columns [`vector_filter_clause`] compiles against are
10617/// the SAME WAL snapshot. A concurrent `configure_projections` DROP either commits
10618/// before this snapshot is pinned (then BOTH the registry and the vec0 columns show
10619/// the attribute gone ⇒ a consistent `InvalidFilter`) or after (then it is invisible
10620/// to this transaction and BOTH still show it declared ⇒ the query runs against the
10621/// column it validated). The registry's `filterable` set is the arm-INDEPENDENT
10622/// authority (correct even with no embedder / no `vector_default`, where a
10623/// declared-`filterable` term still filters legitimately via the row-owned
10624/// `canonical_attributes` EAV store). The caller re-raises
10625/// [`SearchReaderError::InvalidFilter`] as the EXISTING typed
10626/// [`EngineError::InvalidFilter`], so both arms see the SAME rejection because it is
10627/// raised before either runs.
10628fn validate_filter_attributes_on_snapshot(
10629 conn: &Connection,
10630 filter: &SearchFilter,
10631) -> Result<(), SearchReaderError> {
10632 if filter.attributes.is_empty() {
10633 return Ok(());
10634 }
10635 // `?` maps a registry-read failure to `SearchReaderError::Sqlite` (unchanged
10636 // `Storage` semantics for a genuine backend fault) via the `From` impl.
10637 let registry = load_projection_registry(conn)?;
10638 for (name, _value) in &filter.attributes {
10639 let declared_filterable =
10640 registry.get(name).is_some_and(|s| s.roles.contains(&ProjectionRole::Filterable));
10641 if !declared_filterable {
10642 return Err(SearchReaderError::InvalidFilter(format!(
10643 "filter attribute {name:?} is not a declared `filterable` projection; \
10644 declare it via configure_projections before filtering on it"
10645 )));
10646 }
10647 }
10648 Ok(())
10649}
10650
10651/// G10 — the `AND col=?n` predicate fragment appended to the phase-1 candidates
10652/// `WHERE` for the present filter fields. Placeholders are numbered from `?3`
10653/// (`?1` = sign-quant query, `?2` = f32 rerank query). Field order is canonical
10654/// (`source_type`, `kind`, `created_after`, `status`), THEN the Slice-15e
10655/// `filterable`-attribute predicates (`attr_<hex>=?n`) in `attributes` order, and
10656/// is mirrored exactly by [`vector_filter_values`]. Empty for `None`/all-`None`
10657/// (byte-identity path).
10658fn vector_filter_clause(filter: Option<&SearchFilter>) -> String {
10659 let Some(filter) = filter else {
10660 return String::new();
10661 };
10662 if filter.is_unfiltered() {
10663 return String::new();
10664 }
10665 // 0.8.20 Slice 15e — the attribute predicates encode the (arbitrary,
10666 // possibly space/unicode-bearing) registry name into the byte-safe
10667 // `attr_<hex>` column vec0 accepts (vec0 rejects quoted identifiers). Owned
10668 // strings so they live past the closure; the shipped metadata columns are
10669 // static `&str`.
10670 let mut cols: Vec<(String, &str)> = Vec::new();
10671 if filter.source_type.is_some() {
10672 cols.push(("source_type".to_string(), "="));
10673 }
10674 if filter.kind.is_some() {
10675 cols.push(("kind".to_string(), "="));
10676 }
10677 if filter.created_after.is_some() {
10678 cols.push(("created_at".to_string(), ">="));
10679 }
10680 if filter.status.is_some() {
10681 cols.push(("status".to_string(), "="));
10682 }
10683 for (name, _value) in &filter.attributes {
10684 cols.push((attr_vec0_column(name), "="));
10685 }
10686 let mut clause = String::new();
10687 for (i, (col, op)) in cols.iter().enumerate() {
10688 clause.push_str(&format!(" AND {col}{op}?{}", i + 3));
10689 }
10690 clause
10691}
10692
10693/// G10 — the bound values for the present filter fields, in the SAME canonical
10694/// order as [`vector_filter_clause`] so placeholder `?{n}` lines up with value
10695/// `n-3`.
10696fn vector_filter_values(filter: Option<&SearchFilter>) -> Vec<rusqlite::types::Value> {
10697 use rusqlite::types::Value;
10698 let mut out = Vec::new();
10699 let Some(filter) = filter else {
10700 return out;
10701 };
10702 if filter.is_unfiltered() {
10703 return out;
10704 }
10705 if let Some(s) = &filter.source_type {
10706 out.push(Value::Text(s.clone()));
10707 }
10708 if let Some(s) = &filter.kind {
10709 out.push(Value::Text(s.clone()));
10710 }
10711 if let Some(c) = filter.created_after {
10712 out.push(Value::Integer(c));
10713 }
10714 if let Some(s) = &filter.status {
10715 out.push(Value::Text(s.clone()));
10716 }
10717 // 0.8.20 Slice 15e — attribute values, in the SAME order the clause appended
10718 // the `attr_<hex>` columns (after the four metadata fields). fix-3 [P2] — the
10719 // filter value is encoded `\x01 || V` to match the encoded PRESENT column
10720 // value, so `attr_<hex> = enc("")` matches present-empty but NEVER the
10721 // `''`-absent rows.
10722 for (_name, value) in &filter.attributes {
10723 out.push(Value::Text(encode_attr_vec0_present(value)));
10724 }
10725 out
10726}
10727
10728/// G10 — build the single phase-1 candidates statement. With `filter=None` (or
10729/// all-`None`) the `{filter_clause}` is empty and the SQL is **byte-identical to
10730/// 0.7.2** (the documented behavior-compat invariant; pinned by
10731/// `pr_g10_filtered_knn.rs`). The KNN form (`ORDER BY distance LIMIT top_k`, no
10732/// `k=`) is preserved.
10733fn build_vector_phase1_sql(filter: Option<&SearchFilter>, final_limit: usize) -> String {
10734 let filter_clause = vector_filter_clause(filter);
10735 format!(
10736 "WITH candidates AS (
10737 SELECT rowid
10738 FROM vector_default
10739 WHERE embedding_bin MATCH vec_quantize_binary(vec_f32(?1)){filter_clause}
10740 ORDER BY distance
10741 LIMIT {top_k}
10742 )
10743 SELECT c.rowid, vec_distance_l2(v.embedding, vec_f32(?2)) AS l2
10744 FROM candidates c
10745 JOIN vector_default v ON v.rowid = c.rowid
10746 ORDER BY l2
10747 LIMIT {final_limit}",
10748 top_k = TOP_K_BIT_CANDIDATES,
10749 )
10750}
10751
10752/// Test seam — exposes [`build_vector_phase1_sql`] at the production
10753/// `SEARCH_RERANK_LIMIT` so `pr_g10_filtered_knn.rs` can pin the `filter=None`
10754/// byte-identity and the appended predicates.
10755#[doc(hidden)]
10756#[must_use]
10757pub fn vector_phase1_sql_for_test(filter: Option<&SearchFilter>) -> String {
10758 build_vector_phase1_sql(filter, SEARCH_RERANK_LIMIT)
10759}
10760
10761/// G10 — does a text-branch hit satisfy the filter? The vector branch is
10762/// pruned in-SQL; the text branch is constrained here against the same metadata:
10763/// `kind` directly, `source_type` via [`resolve_source_type`], and
10764/// `created_after`/`status` from `vector_default` by `rowid == write_cursor`. A
10765/// text-only row absent from the vector partition cannot satisfy a
10766/// `created_after`/`status` predicate, so it is excluded — filtered semantic
10767/// search is a vector-metadata capability.
10768fn text_hit_passes_filter(
10769 tx: &rusqlite::Transaction<'_>,
10770 id: u64,
10771 kind: &str,
10772 filter: Option<&SearchFilter>,
10773) -> rusqlite::Result<bool> {
10774 let Some(filter) = filter else {
10775 return Ok(true);
10776 };
10777 if filter.is_unfiltered() {
10778 return Ok(true);
10779 }
10780 if let Some(k) = &filter.kind {
10781 if kind != k {
10782 return Ok(false);
10783 }
10784 }
10785 if let Some(st) = &filter.source_type {
10786 match resolve_source_type(kind) {
10787 Ok(resolved) if resolved == st.as_str() => {}
10788 _ => return Ok(false),
10789 }
10790 }
10791 if filter.created_after.is_some() || filter.status.is_some() {
10792 let meta: Option<(i64, Option<String>)> = tx
10793 .query_row(
10794 "SELECT created_at, status FROM vector_default WHERE rowid = ?1 LIMIT 1",
10795 [id as i64],
10796 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?)),
10797 )
10798 .optional()?;
10799 let Some((created_at, status)) = meta else {
10800 // No vector-partition row: cannot satisfy a vec-metadata predicate.
10801 return Ok(false);
10802 };
10803 if let Some(bound) = filter.created_after {
10804 if created_at < bound {
10805 return Ok(false);
10806 }
10807 }
10808 if let Some(want) = &filter.status {
10809 if status.as_deref() != Some(want.as_str()) {
10810 return Ok(false);
10811 }
10812 }
10813 }
10814 // 0.8.20 Slice 15e fix-1 finding 1 [P2] — enforce the declared-`filterable`
10815 // attribute-equality predicates on the TEXT/FTS arm too (D3 total dispatch).
10816 if !hit_attributes_pass_filter(tx, id, filter)? {
10817 return Ok(false);
10818 }
10819 Ok(true)
10820}
10821
10822/// 0.8.20 Slice 15e fix-1 finding 1 [P2] — do a TEXT/FTS hit's `filterable`
10823/// attributes satisfy `filter.attributes`?
10824///
10825/// The vector arm enforces each `(attr_name, value)` pre-KNN as `attr_<hex>=?`
10826/// against the vec0 metadata column. The FTS/text arm must enforce the SAME
10827/// equality so a hybrid RRF fusion is coherent (ADR-0.8.11 D3 — every filter term
10828/// has a defined outcome on EVERY surface; no arm silently ignores it). Without
10829/// this, a doc returned by the FTS arm that FAILS the attribute filter still
10830/// surfaces in a hybrid search — a false positive.
10831///
10832/// The value is read from the row-owned `canonical_attributes` EAV table (keyed
10833/// by the hit's `write_cursor` + `attr_name`), which Slice 15d keeps active-only
10834/// and populates via the SAME [`extract_scalar_attribute`] that fills the vec0
10835/// `attr_<hex>` column — so the two arms see IDENTICAL values by construction.
10836///
10837/// # fix-3 [P2]: ABSENT vs PRESENT-EMPTY
10838///
10839/// A real empty-string value (`{"status":""}`) is DISTINCT from an absent
10840/// attribute. On this (FTS/text) arm the distinction is ROW EXISTENCE: a present
10841/// attribute (even value `''`) has a `canonical_attributes` row; an absent one has
10842/// NONE. So a filter `("status","")` matches present-empty (row exists, RAW value
10843/// `''`) but NOT absent (no row), and `("status","open")` matches only the
10844/// `open` row. The vec0 arm reaches the SAME verdict via its `\x01`-marker
10845/// encoding (see [`ATTR_VEC0_PRESENT_MARKER`]): present-empty is the bare marker,
10846/// absent is `''`, so `attr_<hex> = enc("")` matches present-empty but never
10847/// absent. `canonical_attributes.attr_value` and `property_search_index` stay RAW.
10848///
10849/// # 0.8.20 semantics (Finding 1 → HITL ruling (A)): attribute filters are NODE-scoped
10850///
10851/// Attribute projection is `PreparedWrite::Node`-gated (see `collect_projection_jobs`
10852/// / [`project_one_attribute`]): an EDGE is never projected into
10853/// `canonical_attributes`, and its `vector_default` row (kind `edge_fact`) carries
10854/// the `''` sentinel in every `attr_<hex>` column (the async worker reads the body
10855/// from `canonical_nodes`, which has no row for an edge cursor). Therefore an
10856/// attribute filter **excludes every edge hit** — on BOTH the edge-FTS arm (this
10857/// helper, keyed by the edge's write_cursor, reads `''`) and the edge-vector arm
10858/// (the pre-KNN `attr_<hex>='…'` predicate prunes the `''`-sentinel edge row) —
10859/// even when the edge body itself names the attribute. This is the intended
10860/// 0.8.20 behaviour, pinned by `attribute_filter_excludes_edge_hits_on_both_arms`.
10861///
10862/// The reserved widening is **(D) endpoint-node filtering** (an edge passes iff its
10863/// endpoint node(s) satisfy the attribute predicate): **(A) is (D) with an empty
10864/// endpoint rule.** (B) edges-pass-through and (C) project-edge-attributes are the
10865/// other reserved options. None are implemented in 0.8.20 — do not add a per-query
10866/// flag; a widening is a deliberate, separately-governed later slice.
10867fn hit_attributes_pass_filter(
10868 tx: &rusqlite::Transaction<'_>,
10869 id: u64,
10870 filter: &SearchFilter,
10871) -> rusqlite::Result<bool> {
10872 for (name, want) in &filter.attributes {
10873 // fix-3 [P2] — distinguish ABSENT from PRESENT-EMPTY by ROW EXISTENCE: a
10874 // present attribute (including one whose value is a real empty string `''`)
10875 // has a `canonical_attributes` row; an absent one has NONE. The outer
10876 // `Option` is row existence; the RAW `attr_value` is compared verbatim.
10877 // This mirrors the vec0 arm exactly (present-empty matches `("k","")`;
10878 // absent matches nothing, including `""`), so a fused hybrid search is
10879 // coherent. `canonical_attributes.attr_value` stays RAW (unencoded).
10880 let stored: Option<Option<String>> = tx
10881 .query_row(
10882 "SELECT attr_value FROM canonical_attributes \
10883 WHERE write_cursor = ?1 AND attr_name = ?2 LIMIT 1",
10884 params![id as i64, name],
10885 |row| row.get::<_, Option<String>>(0),
10886 )
10887 .optional()?;
10888 match stored {
10889 // Present (row exists) and the RAW value equals the filter value.
10890 Some(Some(v)) if v.as_str() == want.as_str() => {}
10891 // Absent (no row), present-but-NULL, or present-but-different ⇒ fail.
10892 _ => return Ok(false),
10893 }
10894 }
10895 Ok(true)
10896}
10897
10898/// G11 (Slice 15) — does an edge FTS hit satisfy the filter?
10899///
10900/// Edge FTS hits always have `source_type = "edge_fact"` (the partition
10901/// discriminant). Their `row.kind` is the **relation** kind (e.g. `"owns"`,
10902/// `"works_for"`), not a node kind, so [`text_hit_passes_filter`] MUST NOT be
10903/// used for edge hits: `resolve_source_type(relation_kind)` returns `Err` for
10904/// unknown kinds, causing every edge hit to be silently rejected when a
10905/// `source_type` filter is set — the exact inverse of correct behaviour.
10906///
10907/// Edge bodies ARE projected into `vector_default` (rowid = `write_cursor`),
10908/// so `created_after` / `status` are satisfied by querying `vector_default`
10909/// exactly as [`text_hit_passes_filter`] does for node hits.
10910///
10911/// Rules:
10912/// - `source_type`: pass iff `None` **or** `== "edge_fact"`.
10913/// - `kind`: filter on the relation kind (`row.kind`) if specified.
10914/// - `created_after` / `status`: query `vector_default WHERE rowid = write_cursor`;
10915/// if absent from the vector partition the hit cannot satisfy a vec-metadata
10916/// predicate and is excluded.
10917fn edge_fts_hit_passes_filter(
10918 tx: &rusqlite::Transaction<'_>,
10919 write_cursor: u64,
10920 row_kind: &str,
10921 filter: Option<&SearchFilter>,
10922) -> rusqlite::Result<bool> {
10923 let Some(filter) = filter else {
10924 return Ok(true);
10925 };
10926 if filter.is_unfiltered() {
10927 return Ok(true);
10928 }
10929 if let Some(ref st) = filter.source_type {
10930 if st != "edge_fact" {
10931 return Ok(false); // filter targets a specific non-edge source_type
10932 }
10933 }
10934 if let Some(ref k) = filter.kind {
10935 if k != row_kind {
10936 return Ok(false); // kind filter applies to the relation kind
10937 }
10938 }
10939 // Edge bodies are projected into vector_default; check created_after/status
10940 // there, the same way text_hit_passes_filter does for node hits.
10941 if filter.created_after.is_some() || filter.status.is_some() {
10942 let meta: Option<(i64, Option<String>)> = tx
10943 .query_row(
10944 "SELECT created_at, status FROM vector_default WHERE rowid = ?1 LIMIT 1",
10945 [write_cursor as i64],
10946 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?)),
10947 )
10948 .optional()?;
10949 let Some((created_at, status)) = meta else {
10950 // No vector-partition row: cannot satisfy a vec-metadata predicate.
10951 return Ok(false);
10952 };
10953 if let Some(bound) = filter.created_after {
10954 if created_at < bound {
10955 return Ok(false);
10956 }
10957 }
10958 if let Some(want) = &filter.status {
10959 if status.as_deref() != Some(want.as_str()) {
10960 return Ok(false);
10961 }
10962 }
10963 }
10964 // 0.8.20 Slice 15e fix-1 finding 1 [P2] — an edge body is projected into
10965 // vector_default (rowid = write_cursor) with the same `attr_<hex>` pre-KNN
10966 // columns as a node, and Slice 15d projects an edge's `filterable` attributes
10967 // into `canonical_attributes` keyed by its write_cursor. Enforce the same
10968 // attribute equality here so the edge-FTS arm matches the vector arm (D3 total
10969 // dispatch). An edge that carries no such attribute reads the `''` sentinel and
10970 // fails a non-empty equality, exactly as on the vector arm.
10971 if !hit_attributes_pass_filter(tx, write_cursor, filter)? {
10972 return Ok(false);
10973 }
10974 Ok(true)
10975}
10976
10977/// Read projection cursor and matching body rows inside one read tx.
10978// The 8th parameter (`vector_stage_only`) is the additive GA-2 / ◆ B-1
10979// measurement seam; the reader-worker call site threads each field through
10980// explicitly (mirroring the existing `recency_enabled` plumbing), so a wrapper
10981// struct would only obscure that 1:1 mapping for a test-only flag.
10982#[allow(clippy::too_many_arguments)]
10983fn read_search_in_tx(
10984 reader: &mut Connection,
10985 compiled: &fathomdb_query::CompiledQuery,
10986 query_vector: Option<&str>,
10987 query_vector_bin: Option<&str>,
10988 final_limit: usize,
10989 filter: Option<&SearchFilter>,
10990 recency_enabled: bool,
10991 importance_enabled: bool,
10992 vector_stage_only: bool,
10993 raw_query: &str,
10994 rerank_depth: usize,
10995 use_graph_arm: bool,
10996 alpha: f64,
10997 pool_n: usize,
10998 explain: bool,
10999 view: ReadView,
11000) -> ReaderResponse {
11001 // 0.8.20 Slice 15b fix-2 (R-20-NV) — the `:now` instant is read HERE, in
11002 // Rust, ONCE per query, and bound positionally into every node-hydration
11003 // SELECT. Never `datetime('now')` / `strftime('%s','now')`: an inline clock
11004 // would make the query non-deterministic, untestable, and re-evaluated per
11005 // row. `None` ⇒ the view relaxes validity ⇒ no conjunct is emitted and
11006 // nothing is bound (`validity_sql` returns the empty string).
11007 //
11008 // fix-3 (F2): FREEZE the view here, at the single point every arm flows
11009 // through. `freeze()` is the only place on this path that reads the clock;
11010 // downstream arms hold a `FrozenView` and have no way to resolve a second,
11011 // different instant. Previously the graph arm re-derived it from the raw
11012 // `ReadView`, so a boundary-straddling query could have its arms disagree.
11013 let view = view.freeze();
11014 let now_param = view.now_param();
11015 // fix-3 (codex §9 [P2], TOCTOU) — test-only rendezvous: parks the worker here,
11016 // BEFORE the deferred snapshot is pinned, so a test can commit a concurrent
11017 // `configure_projections` DROP in the exact race window. Disarmed (no-op) in
11018 // production and on every non-race test.
11019 reader_search_hook::fire();
11020 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
11021 let cursor = load_projection_cursor(&tx)?;
11022 // fix-3 (codex §9 [P2], TOCTOU) — validate every filter attribute name on THIS
11023 // reader transaction's snapshot, before `build_vector_phase1_sql` emits
11024 // `AND attr_<hex>=?` and before the FTS arm probes `canonical_attributes`. The
11025 // registry read and the vec0 query now share ONE snapshot (see
11026 // `validate_filter_attributes_on_snapshot`), so a `configure_projections` DROP
11027 // racing this search yields a consistent typed `InvalidFilter` — never the
11028 // opaque `no such column` `Storage` error fix-2's writer-connection check could
11029 // still leak. Skipped when the filter carries no attribute terms (common path).
11030 if let Some(filter) = filter {
11031 validate_filter_attributes_on_snapshot(&tx, filter)?;
11032 }
11033 let vector_results = if let Some(query_vector) = query_vector {
11034 let mut rowids = Vec::new();
11035 let bin_vector = query_vector_bin.unwrap_or(query_vector);
11036 {
11037 // Phase 1: bit-KNN over `embedding_bin` to a top-K candidate
11038 // set; Phase 2: f32 rerank on the candidate set via
11039 // vec_distance_l2 against the retained `embedding` column.
11040 // EU-5a2: ?1 is the (possibly centered) sign-quant input,
11041 // ?2 is the un-centered f32 for vec_distance_l2 — both sides
11042 // of the f32 cosine use un-centered vectors.
11043 // PR-2bc S1 fix-1: the phase-2 rerank LIMIT is `SEARCH_RERANK_LIMIT`
11044 // (10) in production. `final_limit` is supplied by the caller from
11045 // `ProjectionRuntimeShared::search_limit_override` (default 10,
11046 // clamped >=10) — there is NO env-var read on this hot path. A test
11047 // seam (`set_search_limit_for_test`) may RAISE it so the recall
11048 // harness can pull top-(10+slack) and exclude the self-retrieving
11049 // query-source doc BEFORE truncating to 10 (standard ANN-recall
11050 // practice); it can never shrink below production semantics.
11051 // G10: the metadata filter is appended to this single phase-1
11052 // statement (`AND col=?n` from ?3); `filter=None` keeps the SQL
11053 // byte-identical to 0.7.2. `?1`/`?2` are the sign-quant + f32 query
11054 // vectors; filter values bind at ?3.. in `vector_filter_clause`
11055 // order.
11056 // fix-3 (F1, codex §9 [P2]) — OVERFETCH the phase-2 rerank so the
11057 // validity/existence filter applied at hydration cannot starve the
11058 // result set.
11059 //
11060 // The defect: hydration drops rows that are expired, superseded or
11061 // inactive, but it ran on candidates ALREADY truncated to
11062 // `final_limit`. If the nearest `final_limit` neighbours were all
11063 // out-of-window they consumed every slot and were then dropped, so
11064 // valid rows just below the cutoff were never considered — a
11065 // default search silently returned too few hits, or none.
11066 //
11067 // Why overfetch rather than filtering in SQL: the natural fix is to
11068 // join `canonical_nodes` into the candidate query, but (i) phase 1
11069 // is a `vec0` KNN and ADR-0.8.11 D3 forbids demoting it with
11070 // non-metadata predicates, and (ii) there is NO index on
11071 // `canonical_nodes(write_cursor)`, so an `EXISTS` per candidate
11072 // would be a full scan × the whole pool on EVERY query — a
11073 // guaranteed cost to fix a degenerate case.
11074 //
11075 // Overfetching is free by comparison: phase 2 already computes
11076 // `vec_distance_l2` for all `TOP_K_BIT_CANDIDATES` in order to sort
11077 // them, so raising the LIMIT only returns more of a result set that
11078 // was already materialized. No extra vec0 work, no schema change,
11079 // no new index, no second query. The hydration loop below then
11080 // stops at `final_limit` SURVIVING hits, so the common case does
11081 // exactly as many hydration probes as before.
11082 //
11083 // `max` (not a bare constant) because `set_search_limit_for_test`
11084 // may raise `final_limit` above the pool for the recall harness;
11085 // this must never request FEWER candidates than the caller wants.
11086 let candidate_limit = final_limit.max(TOP_K_BIT_CANDIDATES);
11087 let sql = build_vector_phase1_sql(filter, candidate_limit);
11088 let mut params: Vec<rusqlite::types::Value> = vec![
11089 rusqlite::types::Value::Text(bin_vector.to_string()),
11090 rusqlite::types::Value::Text(query_vector.to_string()),
11091 ];
11092 params.extend(vector_filter_values(filter));
11093 let mut statement = tx.prepare(&sql)?;
11094 let rows = statement.query_map(rusqlite::params_from_iter(params.iter()), |row| {
11095 Ok((row.get::<_, i64>(0)?, row.get::<_, f64>(1)?))
11096 })?;
11097 for row in rows.flatten() {
11098 rowids.push(row);
11099 }
11100 }
11101 // G1: carry the canonical row's `write_cursor` (interim id), `kind`,
11102 // `body`, and the `vec_distance_l2` rerank score per hit. The
11103 // `_fathomdb_vector_rows.rowid` equals the canonical `write_cursor`,
11104 // so the candidate rowid IS the hit id.
11105 //
11106 // G11 (Slice 15) fix: edge bodies are projected into vector_default under
11107 // kind = "edge_fact"; their write_cursor is in canonical_edges, not
11108 // canonical_nodes. Try canonical_nodes first; fall back to canonical_edges
11109 // for edge-fact hits so they are not silently dropped.
11110 let mut results = Vec::new();
11111 // Cause-A: the two node/edge SELECTs additively fetch `logical_id` so the
11112 // hit can carry a stable cross-session id (derive_stable_id). Read-only
11113 // additive column — ordering/scores are untouched.
11114 // fix-1 (codex §9): co-locate BOTH existence guards. Node supersession
11115 // is tombstone-then-insert (`commit_batch`) — the prior `canonical_nodes`
11116 // row is kept (same `write_cursor`, `state = 'active'`, `superseded_at`
11117 // set) and, unlike the edge path (fix-30), its stale `vector_default` row
11118 // is NOT pruned, so the phase-1 bit-KNN can still surface the OLD cursor.
11119 // Without `superseded_at IS NULL` here that superseded version would
11120 // hydrate and leak stale content through vector search. This matches the
11121 // edge branch below and every other retrieval site (design §2: enforce
11122 // the exclusion at EVERY retrieval site). It only drops already-superseded
11123 // rows → a no-op on the all-active / non-superseded corpus.
11124 // TC-31 (0.8.20 Slice 10a): both hydration SELECTs additively fetch the
11125 // canonical row's OWN `source_id` so a vector hit carries the provenance
11126 // `erase_source` consumes. These statements already read the canonical
11127 // row by `write_cursor`, so this is one extra COLUMN on an existing
11128 // lookup — NOT an extra query. (A per-hit `WHERE write_cursor = ?`
11129 // probe would be a full scan: there is no index on
11130 // `canonical_nodes(write_cursor)`. This site already pays that cost by
11131 // construction; TC-31 must not add a second one.) Read-only additive
11132 // column — row-set, ordering and scores are untouched.
11133 // fix-2 (codex §9 [P2]): the validity conjunct comes from
11134 // `ReadView::validity_sql` — the SAME generator the five read verbs use.
11135 // It is NOT hand-rolled here: Slice 10's whole design is that the
11136 // predicate exists in exactly ONE place, so no retrieval site can drift
11137 // from another. `?1` is the candidate rowid, so `:now` binds at `?2`.
11138 // On a corpus that never authored a window every row is NULL/NULL and
11139 // the conjunct matches everything ⇒ default behaviour is unchanged.
11140 let node_validity = view.validity_sql("canonical_nodes", 2);
11141 let mut node_stmt = tx.prepare(&format!(
11142 "SELECT kind, body, logical_id, source_id FROM canonical_nodes \
11143 WHERE write_cursor = ?1 AND superseded_at IS NULL AND state = 'active'\
11144 {node_validity} LIMIT 1"
11145 ))?;
11146 // fix-2 (codex §9 [P2]): an edge body projected into `vector_default`
11147 // (kind = "edge_fact") is hydrated HERE by write_cursor. Gating on
11148 // `superseded_at` alone let an EXPIRED edge (`t_invalid <= :now`) surface
11149 // its body through the VECTOR arm — the same "validity enforced on
11150 // traversal, not on search" gap Slice 15b closed for nodes, now on the
11151 // edge-vector read path. Apply the shared `edge_validity_sql` predicate
11152 // (the ONE generator every edge read site uses) so no arm can drift.
11153 // `?1` is the rowid, so the edge `:now` binds at `?2`; the instant is the
11154 // frozen `view.edge_now()` — a bound value, never `datetime('now')`
11155 // (the :9161 no-inline-clock rule). edge_now is ALWAYS present, so unlike
11156 // node validity this conjunct is unconditional (an edge invalidated in the
11157 // past stays excluded even when node existence is relaxed).
11158 let edge_validity = edge_validity_sql("canonical_edges", 2);
11159 let mut edge_stmt = tx.prepare(&format!(
11160 "SELECT body, logical_id, source_id FROM canonical_edges \
11161 WHERE write_cursor = ?1 AND superseded_at IS NULL AND body IS NOT NULL\
11162 {edge_validity} LIMIT 1"
11163 ))?;
11164 // The bound parameter list for the node lookup: the candidate rowid,
11165 // plus `:now` when (and only when) the view emitted a validity conjunct.
11166 // One instant for the whole query — resolved once, above, not per row.
11167 let node_params = |rowid: i64| -> Vec<rusqlite::types::Value> {
11168 let mut p = vec![rusqlite::types::Value::Integer(rowid)];
11169 if let Some(now) = now_param {
11170 p.push(rusqlite::types::Value::Integer(now));
11171 }
11172 p
11173 };
11174 for (rowid, score) in rowids {
11175 // fix-3 (F1): the candidate list is now the OVERFETCHED pool in
11176 // exact-L2 order, so the caller's cutoff is applied HERE — after
11177 // the validity/existence filter, not before it. Bounded worst case:
11178 // at most `TOP_K_BIT_CANDIDATES` hydration probes when nearly every
11179 // candidate is filtered out; exactly `final_limit` (i.e. unchanged)
11180 // when nothing is. Ordering is unchanged — the surviving rows are
11181 // still emitted nearest-first — so on a corpus with no windows this
11182 // loop yields byte-identical results to the pre-fix code.
11183 if results.len() >= final_limit {
11184 break;
11185 }
11186 if let Ok((kind, body, logical_id, source_id)) =
11187 node_stmt.query_row(rusqlite::params_from_iter(node_params(rowid)), |row| {
11188 Ok((
11189 row.get::<_, String>(0)?,
11190 row.get::<_, String>(1)?,
11191 row.get::<_, Option<String>>(2)?,
11192 row.get::<_, Option<String>>(3)?,
11193 ))
11194 })
11195 {
11196 let id = derive_stable_id(logical_id.as_deref(), &body);
11197 results.push(SearchHit {
11198 id,
11199 write_cursor: rowid as u64,
11200 kind,
11201 body,
11202 score,
11203 branch: SoftFallbackBranch::Vector,
11204 // TC-31: the NODE's own provenance (a node hit is erased by
11205 // the document it was written from).
11206 source_id,
11207 ce_score: None,
11208 });
11209 } else if let Ok((body, logical_id, source_id)) =
11210 edge_stmt.query_row(rusqlite::params![rowid, view.edge_now()], |row| {
11211 Ok((
11212 row.get::<_, String>(0)?,
11213 row.get::<_, Option<String>>(1)?,
11214 row.get::<_, Option<String>>(2)?,
11215 ))
11216 })
11217 {
11218 let id = derive_stable_id(logical_id.as_deref(), &body);
11219 results.push(SearchHit {
11220 id,
11221 write_cursor: rowid as u64,
11222 kind: "edge_fact".to_string(),
11223 body,
11224 score,
11225 branch: SoftFallbackBranch::TextEdge,
11226 // TC-31: the EDGE's own provenance — consistent with the
11227 // graph arm's existing edge-source semantics.
11228 source_id,
11229 ce_score: None,
11230 });
11231 }
11232 }
11233 results
11234 } else {
11235 Vec::new()
11236 };
11237 let vector_rows_visible = !vector_results.is_empty();
11238 let soft_fallback = if query_vector.is_some() && !vector_rows_visible {
11239 tx.query_row(
11240 "SELECT 1
11241 FROM search_index
11242 JOIN _fathomdb_vector_kinds ON _fathomdb_vector_kinds.kind = search_index.kind
11243 LEFT JOIN _fathomdb_projection_terminal
11244 ON _fathomdb_projection_terminal.write_cursor = search_index.write_cursor
11245 WHERE search_index MATCH ?1
11246 AND _fathomdb_projection_terminal.write_cursor IS NULL
11247 LIMIT 1",
11248 [compiled.match_expression.as_str()],
11249 |_row| Ok(SoftFallback { branch: SoftFallbackBranch::Vector }),
11250 )
11251 .ok()
11252 } else {
11253 None
11254 };
11255 // Collect the text branch (ranked by `write_cursor`, as 0.7.2), then
11256 // post-filter it against the same metadata the vector branch was pruned by
11257 // in SQL (the vector branch is filtered in phase 1; the text branch has no
11258 // metadata columns of its own).
11259 let text_candidates: Vec<SearchHit> = {
11260 // 0.7.0 perf-experiments: optional FTS5 LIMIT cap. Gated on
11261 // FATHOMDB_PERF_EXPERIMENTS=1; opt-in via
11262 // FATHOMDB_PERF_SEARCH_LIMIT=<k>. No-op by default — preserves
11263 // 0.6.x unbounded result-set semantics. Removed (or made the
11264 // hardcoded default) at Wave 5 landing per
11265 // dev/plans/0.7.0-perf-experiments.md.
11266 let perf_limit: Option<usize> = if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_some() {
11267 std::env::var("FATHOMDB_PERF_SEARCH_LIMIT").ok().and_then(|s| s.parse().ok())
11268 } else {
11269 None
11270 };
11271 // G1: SELECT body + kind + write_cursor (interim id) and the
11272 // `bm25()` text-relevance score. IR-C (2026-06-10,
11273 // `performance-output-and-compare.md`): the per-branch rank RRF fuses on
11274 // must be **`bm25()` relevance**, not `write_cursor` (insertion order) —
11275 // the prior `ORDER BY write_cursor` meant the lexical arm never ranked by
11276 // relevance, the single biggest fusion bug. `bm25()` is more-negative ⇒
11277 // better, so ascending puts best matches first; `write_cursor` is the
11278 // deterministic tiebreak. The filter is applied as a Rust post-filter so
11279 // the unfiltered path is untouched.
11280 let limit_clause = perf_limit.map(|k| format!(" LIMIT {k}")).unwrap_or_default();
11281 // Cause-A: PREFER a logical_id-bearing query — LEFT JOIN canonical_nodes so
11282 // node hits carry the `l:`-tagged stable id. The join is 1:1 on
11283 // `write_cursor` (search_index holds node bodies only; edge bodies live in
11284 // search_index_edges), so the row-set and the `bm25(search_index),
11285 // write_cursor` ordering are byte-unchanged — only `cn.logical_id` is added.
11286 // FALL BACK to the original (logical_id-free) query on pre-step-12 schemas
11287 // (v10) whose `canonical_nodes` lacks `logical_id`: those hits key by the
11288 // `h:` content-hash. This keeps old-schema search byte-identical to the
11289 // pre-Cause-A behaviour (the prepare of the plain SQL is the exact prior
11290 // statement). Columns are qualified because both tables expose `write_cursor`.
11291 // CORRECTNESS (0.8.11.2 pico): `AND cn.superseded_at IS NULL` drops
11292 // superseded node versions. Node supersession is tombstone-then-insert
11293 // (`commit_batch`): the prior `canonical_nodes` row is UPDATEd to set
11294 // `superseded_at` (row kept, same write_cursor) and a NEW `search_index`
11295 // row is inserted for the new cursor — the OLD `search_index` row is
11296 // never deleted, so without this filter both versions stay live in FTS
11297 // and the stale one is returned. The other arms already filter this way
11298 // (edge branch, graph-arm node seed, point-recall `read_get_by_id`);
11299 // only this default node-text branch was missing it. The `LEFT JOIN` is
11300 // KEPT (not switched to inner): an active row joins to its `cn` with
11301 // `superseded_at = NULL` (kept); a superseded row joins to its tombstoned
11302 // `cn` with `superseded_at` NOT NULL (dropped); a legacy/orphan
11303 // `search_index` row with no `cn` gets `superseded_at = NULL` via the
11304 // LEFT JOIN (KEPT — preserves prior behaviour for ownerless rows).
11305 // TC-31 (0.8.20 Slice 10a): `cn.source_id` is selected off the SAME
11306 // already-present 1:1 LEFT JOIN that supplies `cn.logical_id` — one extra
11307 // column, no extra query, no row-set or ordering change.
11308 // fix-2 (codex §9 [P2]): the node-body FTS branch takes the SAME
11309 // validity conjunct, generated by `ReadView::validity_sql` rather than
11310 // hand-rolled — the predicate lives in exactly one place (Slice 10).
11311 // `?1` is the MATCH expression, so `:now` binds at `?2`.
11312 //
11313 // The generated conjunct is NULL-PERMISSIVE by construction
11314 // (`valid_from IS NULL OR ...`), which is exactly what this LEFT JOIN
11315 // needs: an ownerless `search_index` row with no `cn` reads NULL on both
11316 // columns and is KEPT, preserving the deliberate keep-ownerless
11317 // behaviour the `superseded_at` / `state` conjuncts above encode with
11318 // their explicit `OR ... IS NULL`. No extra `OR IS NULL` is needed here,
11319 // and none may be added: that would be a second, drifting copy of the
11320 // predicate.
11321 //
11322 // NO-REGRESSION: on a corpus that never authored a window every
11323 // `cn.valid_from` / `cn.valid_until` is NULL (step 22 back-filled NULL
11324 // with no DEFAULT), so both disjuncts are TRUE for every row and the
11325 // row-set, the `bm25(search_index), write_cursor` ordering and the
11326 // scores are all byte-unchanged.
11327 let text_validity = view.validity_sql("cn", 2);
11328 let join_sql = format!(
11329 "SELECT search_index.body, search_index.kind, search_index.write_cursor, \
11330 bm25(search_index), cn.logical_id, cn.source_id FROM search_index \
11331 LEFT JOIN canonical_nodes cn ON cn.write_cursor = search_index.write_cursor \
11332 WHERE search_index MATCH ?1 \
11333 AND cn.superseded_at IS NULL \
11334 AND (cn.state = 'active' OR cn.state IS NULL)\
11335 {text_validity} \
11336 ORDER BY bm25(search_index), search_index.write_cursor{limit_clause}"
11337 );
11338 // `:now` rides at ?2 only when the view emitted a conjunct; the relaxed
11339 // view produces the byte-identical single-parameter statement.
11340 let mut text_params: Vec<rusqlite::types::Value> =
11341 vec![rusqlite::types::Value::Text(compiled.match_expression.clone())];
11342 if let Some(now) = now_param {
11343 text_params.push(rusqlite::types::Value::Integer(now));
11344 }
11345 if let Ok(mut statement) = tx.prepare(&join_sql) {
11346 let rows =
11347 statement.query_map(rusqlite::params_from_iter(text_params.iter()), |row| {
11348 let body = row.get::<_, String>(0)?;
11349 let logical_id = row.get::<_, Option<String>>(4)?;
11350 Ok(SearchHit {
11351 id: derive_stable_id(logical_id.as_deref(), &body),
11352 body,
11353 kind: row.get::<_, String>(1)?,
11354 write_cursor: row.get::<_, i64>(2)? as u64,
11355 score: row.get::<_, f64>(3)?,
11356 branch: SoftFallbackBranch::Text,
11357 // TC-31: the NODE's own provenance. NULL for a legacy /
11358 // TC-11-spared governed row, and NULL for an ownerless
11359 // `search_index` row the LEFT JOIN keeps with no `cn`.
11360 source_id: row.get::<_, Option<String>>(5)?,
11361 ce_score: None,
11362 })
11363 })?;
11364 rows.flatten().collect()
11365 } else {
11366 // No `superseded_at IS NULL` filter here (and none is possible): this
11367 // fallback fires only on pre-step-12 schemas whose `canonical_nodes`
11368 // lacks `logical_id` — and step-12 adds `logical_id` and
11369 // `superseded_at` in the SAME migration, so this schema has neither
11370 // column. Supersession (`commit_batch`) is a no-op without
11371 // `logical_id`, so no superseded node rows can exist on this path.
11372 //
11373 // TC-31 (0.8.20 Slice 10a): `source_id` arrived in step 8, `logical_id`
11374 // in step 12, so a schema that lands HERE (no `logical_id`) may still
11375 // HAVE `source_id` — steps 8..11. Try a provenance-bearing variant
11376 // first, adding only `cn.source_id` over the SAME 1:1 LEFT JOIN shape
11377 // used above (row-set and ordering unchanged; a missing `cn` row keeps
11378 // NULL as before). Fall back to the historical, byte-identical
11379 // provenance-free statement on a pre-step-8 schema, where the column
11380 // genuinely does not exist and `None` is the only truthful answer.
11381 let source_sql = format!(
11382 "SELECT search_index.body, search_index.kind, search_index.write_cursor, \
11383 bm25(search_index), cn.source_id FROM search_index \
11384 LEFT JOIN canonical_nodes cn ON cn.write_cursor = search_index.write_cursor \
11385 WHERE search_index MATCH ?1 \
11386 ORDER BY bm25(search_index), search_index.write_cursor{limit_clause}"
11387 );
11388 if let Ok(mut statement) = tx.prepare(&source_sql) {
11389 let rows = statement.query_map([compiled.match_expression.as_str()], |row| {
11390 let body = row.get::<_, String>(0)?;
11391 Ok(SearchHit {
11392 // No logical_id column on this schema → content-hash id.
11393 id: derive_stable_id(None, &body),
11394 body,
11395 kind: row.get::<_, String>(1)?,
11396 write_cursor: row.get::<_, i64>(2)? as u64,
11397 score: row.get::<_, f64>(3)?,
11398 branch: SoftFallbackBranch::Text,
11399 source_id: row.get::<_, Option<String>>(4)?,
11400 ce_score: None,
11401 })
11402 })?;
11403 rows.flatten().collect()
11404 } else {
11405 // Pre-step-8: no `source_id` column anywhere. Byte-identical to
11406 // the historical statement.
11407 let plain_sql = format!(
11408 "SELECT body, kind, write_cursor, bm25(search_index) FROM search_index \
11409 WHERE search_index MATCH ?1 \
11410 ORDER BY bm25(search_index), write_cursor{limit_clause}"
11411 );
11412 let mut statement = tx.prepare(&plain_sql)?;
11413 let rows = statement.query_map([compiled.match_expression.as_str()], |row| {
11414 let body = row.get::<_, String>(0)?;
11415 Ok(SearchHit {
11416 // No logical_id column on this schema → content-hash id.
11417 id: derive_stable_id(None, &body),
11418 body,
11419 kind: row.get::<_, String>(1)?,
11420 write_cursor: row.get::<_, i64>(2)? as u64,
11421 score: row.get::<_, f64>(3)?,
11422 branch: SoftFallbackBranch::Text,
11423 source_id: None,
11424 ce_score: None,
11425 })
11426 })?;
11427 rows.flatten().collect()
11428 }
11429 }
11430 };
11431 let mut text_results: Vec<SearchHit> = Vec::with_capacity(text_candidates.len());
11432 for hit in text_candidates {
11433 if text_hit_passes_filter(&tx, hit.write_cursor, &hit.kind, filter)? {
11434 text_results.push(hit);
11435 }
11436 }
11437
11438 // G11 (Slice 15) — edge-body FTS branch from `search_index_edges`.
11439 // Appended to text_results; tagged with SoftFallbackBranch::TextEdge so
11440 // callers can distinguish edge hits from node hits.
11441 //
11442 // fix-1 [P2]: JOIN canonical_edges to exclude superseded edge rows
11443 // (invalidate-not-accumulate can leave a superseded body in the FTS index).
11444 // fix-2 [P2]: use edge_fts_hit_passes_filter (NOT text_hit_passes_filter).
11445 // Edge hits always have source_type="edge_fact"; text_hit_passes_filter
11446 // calls resolve_source_type(relation_kind) which returns Err for unknown
11447 // relation kinds, silently rejecting every edge hit when a source_type
11448 // filter is set — the exact inverse of correct behaviour.
11449 // fix-3 [P2]: edge_fts_hit_passes_filter now queries vector_default for
11450 // created_after/status (mirroring text_hit_passes_filter). Collect edge
11451 // candidates into a Vec first (drops stmt borrow on tx) so we can pass
11452 // &tx to edge_fts_hit_passes_filter without a borrow conflict.
11453 let edge_candidates: Vec<SearchHit> = {
11454 // Cause-A: the JOIN to canonical_edges already exists; additively select
11455 // `ce.logical_id` (edges always carry one) for the stable hit-id. No
11456 // ordering/row-set change.
11457 // TC-31 (0.8.20 Slice 10a): `ce.source_id` rides the SAME existing inner
11458 // JOIN as `ce.logical_id` — one extra column, no extra query, no
11459 // row-set/ordering change. An edge hit carries the EDGE's own provenance,
11460 // matching the graph arm's edge-source semantics.
11461 // fix-2 (codex §9 [P2]): the JOIN already dropped superseded edge rows,
11462 // but a body-bearing edge written with `t_invalid <= :now` (expired /
11463 // invalidated) still MATCHed and surfaced its body through ordinary
11464 // search — edge temporal validity was enforced on the graph-traversal and
11465 // projection paths but NOT on this FTS read path. Apply the shared
11466 // `edge_validity_sql` conjunct (the ONE generator every edge read site
11467 // uses, so no path can drift). `?1` is the MATCH expression, so the edge
11468 // `:now` binds at `?2`; the instant is the frozen `view.edge_now()` — a
11469 // bound value, never `datetime('now')` (the :9161 no-inline-clock rule),
11470 // and always present (edge invalidation is not relaxed by node existence
11471 // relaxation).
11472 let edge_validity = edge_validity_sql("ce", 2);
11473 let edge_sql = format!(
11474 "SELECT sei.body, sei.kind, sei.write_cursor, bm25(search_index_edges), \
11475 ce.logical_id, ce.source_id \
11476 FROM search_index_edges sei \
11477 JOIN canonical_edges ce ON ce.write_cursor = sei.write_cursor \
11478 WHERE search_index_edges MATCH ?1 \
11479 AND ce.superseded_at IS NULL{edge_validity} \
11480 ORDER BY bm25(search_index_edges), sei.write_cursor"
11481 );
11482 // search_index_edges may not exist on very old DBs not yet at step-14;
11483 // ignore the error gracefully (returns empty slice).
11484 if let Ok(mut stmt) = tx.prepare(&edge_sql) {
11485 if let Ok(rows) = stmt.query_map(
11486 rusqlite::params![compiled.match_expression.as_str(), view.edge_now()],
11487 |row| {
11488 let body = row.get::<_, String>(0)?;
11489 let logical_id = row.get::<_, Option<String>>(4)?;
11490 Ok(SearchHit {
11491 id: derive_stable_id(logical_id.as_deref(), &body),
11492 body,
11493 kind: row.get::<_, String>(1)?,
11494 write_cursor: row.get::<_, i64>(2)? as u64,
11495 score: row.get::<_, f64>(3)?,
11496 branch: SoftFallbackBranch::TextEdge,
11497 // TC-31: the EDGE's own provenance.
11498 source_id: row.get::<_, Option<String>>(5)?,
11499 ce_score: None,
11500 })
11501 },
11502 ) {
11503 rows.flatten().collect()
11504 } else {
11505 Vec::new()
11506 }
11507 } else {
11508 Vec::new()
11509 }
11510 };
11511 for row in edge_candidates {
11512 if edge_fts_hit_passes_filter(&tx, row.write_cursor, &row.kind, filter)? {
11513 text_results.push(row);
11514 }
11515 }
11516 tx.commit()?;
11517
11518 // GA-2 / Slice-40 (◆ B-1) measurement seam: when `vector_stage_only` is set
11519 // (only ever by the eu7 recall harness via `set_vector_stage_only_for_test`,
11520 // off for every production caller), return the pre-fusion VECTOR-branch
11521 // ranking (bit-KNN K=192 + f32 rerank) verbatim, skipping `fuse_rrf` /
11522 // recency / `rerank_fused`. This exposes the ANN-quantization FIDELITY
11523 // signal — vector top-N vs the exact-f32 VECTOR top-10 ground truth — that
11524 // the AC-075 0.90 floor is defined to measure. It is NOT a `fusion_mode`
11525 // knob: the production branch below is byte-unchanged and RRF stays
11526 // unconditional.
11527 // G0 Phase-2 (BLOCK-1) side-channel meter — default (all-zero, rate 0.0) on
11528 // the non-graph-arm paths; populated by the BFS seed phase when graph-arm runs.
11529 let mut graph_stats = GraphFrontierStats::default();
11530
11531 // 0.8.8 EXP-OBS (Slice 5) — capture per-arm rank maps + counts BEFORE the arms
11532 // are consumed by fusion. All reads; only when `explain` (else zero work).
11533 // `body_rank_map` keeps the FIRST occurrence (== the rank `fuse_three_arms`
11534 // uses, which dedups keeping the first). `*_fused_scores` is captured from the
11535 // post-recency / pre-CE intermediate so `fused_score` is faithful to what
11536 // `ce_rerank` normalizes.
11537 let body_rank_map = |hits: &[SearchHit]| -> HashMap<String, u32> {
11538 let mut m: HashMap<String, u32> = HashMap::new();
11539 for (i, h) in hits.iter().enumerate() {
11540 m.entry(h.body.clone()).or_insert(i as u32);
11541 }
11542 m
11543 };
11544 let body_score_map = |hits: &[SearchHit]| -> HashMap<String, f64> {
11545 hits.iter().map(|h| (h.body.clone(), h.score)).collect()
11546 };
11547
11548 let (exp_vector_ranks, exp_text_ranks, exp_vector_n, exp_text_n) = if explain {
11549 (
11550 Some(body_rank_map(&vector_results)),
11551 Some(body_rank_map(&text_results)),
11552 vector_results.len() as u32,
11553 text_results.len() as u32,
11554 )
11555 } else {
11556 (None, None, 0, 0)
11557 };
11558 let mut exp_graph_ranks: Option<HashMap<String, u32>> = None;
11559 let mut exp_fused_scores: Option<HashMap<String, f64>> = None;
11560 let mut exp_graph_n: u32 = 0;
11561 // F9 (0.8.16 Slice 5) — per-hit importance/confidence contribution maps
11562 // (keyed by hit id == write_cursor), captured for the explain sidecar.
11563 let mut exp_importance: Option<HashMap<u64, f64>> = None;
11564 let mut exp_confidence: Option<HashMap<u64, f64>> = None;
11565
11566 let results = if vector_stage_only {
11567 vector_results
11568 } else if use_graph_arm {
11569 // R3 (Slice 30) — graph arm: BFS over temporal fact-edges seeded from
11570 // the top-10 two-arm fused candidates, depth ≤ 3, cap 50.
11571 // Temporal filter: superseded_at IS NULL AND (t_invalid IS NULL OR t_invalid > now).
11572 // Synthesized-node penalty: kind = 'unknown' → score *= 0.3.
11573 //
11574 // Approach: compute the two-arm fused result first (for BFS seeding),
11575 // then fuse three arms: the two-arm result (as "vector" arm), an empty
11576 // text arm, and the graph candidates. The two-arm result preserves all
11577 // existing ranking semantics; the graph arm contributes new candidates.
11578 let two_arm_fused = fuse_rrf(vector_results, text_results);
11579 // C1: seed the graph arm from the query's FTS match expression (entities /
11580 // edge-facts), not the doc-node fused hits. `fused_hits` is still passed for
11581 // the seed-body exclusion set.
11582 let (graph_candidates, stats, graph_edge_confidence) = bfs_graph_arm_candidates(
11583 reader,
11584 &two_arm_fused,
11585 compiled.match_expression.as_str(),
11586 3,
11587 50,
11588 view,
11589 )?;
11590 graph_stats = stats;
11591 if explain {
11592 exp_graph_ranks = Some(body_rank_map(&graph_candidates));
11593 exp_graph_n = graph_candidates.len() as u32;
11594 }
11595 // Named intermediate (byte-identical to the prior nested call) so explain
11596 // can read the pre-CE fused scores without perturbing the ranking.
11597 let fused = apply_recency_reweight(
11598 fuse_three_arms(two_arm_fused, vec![], graph_candidates),
11599 recency_enabled,
11600 );
11601 // F9 — importance (node) / confidence (edge) reweight, OFF by default.
11602 // Order: AFTER recency (consistent placement), BEFORE the CE rerank seam.
11603 let (imp_map, mut conf_map) = if importance_enabled || explain {
11604 build_importance_confidence_maps(reader, &fused).unwrap_or_default()
11605 } else {
11606 (HashMap::new(), HashMap::new())
11607 };
11608 // F9 FIX-1: `build_importance_confidence_maps` keys edge confidence on the
11609 // EDGE `write_cursor`, which never matches a graph-arm NODE hit's cursor —
11610 // so it alone leaves graph-arm hits with no edge confidence. Merge the
11611 // BFS-collected per-node traversing-edge confidence (node cursor ⇒ conf).
11612 // Node/edge cursors are globally unique, so there is never a key collision
11613 // with the edge-fact confidence above; `or_insert` documents that intent.
11614 if importance_enabled || explain {
11615 for (cursor, conf) in &graph_edge_confidence {
11616 conf_map.entry(*cursor).or_insert(*conf);
11617 }
11618 }
11619 let fused = apply_importance_reweight(fused, &imp_map, &conf_map, importance_enabled);
11620 if explain {
11621 exp_importance = Some(imp_map);
11622 exp_confidence = Some(conf_map);
11623 exp_fused_scores = Some(body_score_map(&fused));
11624 }
11625 rerank_fused(raw_query, fused, rerank_depth, alpha, pool_n)
11626 } else {
11627 // G9 + G12: RRF-fuse the two ranked branches (keyed on body, vector-first
11628 // tiebreak) into the unconditional new ranking, recency-reweight (gated,
11629 // off by default), then pass through the identity rerank seam. The
11630 // vector-empty `soft_fallback` signal was computed above, BEFORE this
11631 // branch-collapse.
11632 let fused = apply_recency_reweight(fuse_rrf(vector_results, text_results), recency_enabled);
11633 // F9 — importance (node) / confidence (edge) reweight, OFF by default.
11634 // Same placement as the graph-arm branch: after recency, before CE rerank.
11635 let (imp_map, conf_map) = if importance_enabled || explain {
11636 build_importance_confidence_maps(reader, &fused).unwrap_or_default()
11637 } else {
11638 (HashMap::new(), HashMap::new())
11639 };
11640 let fused = apply_importance_reweight(fused, &imp_map, &conf_map, importance_enabled);
11641 if explain {
11642 exp_importance = Some(imp_map);
11643 exp_confidence = Some(conf_map);
11644 exp_fused_scores = Some(body_score_map(&fused));
11645 }
11646 rerank_fused(raw_query, fused, rerank_depth, alpha, pool_n)
11647 };
11648
11649 // 0.8.8 EXP-OBS — assemble the sidecar `Explanation` from the captured maps +
11650 // the final `results`. `embedder_id` is left empty here (the worker has no
11651 // identity) and filled by `search_inner_with_stats`.
11652 let explanation = if explain {
11653 let fused_scores = exp_fused_scores.unwrap_or_default();
11654 let per_hit: Vec<PerHitExplain> = results
11655 .iter()
11656 .map(|h| PerHitExplain {
11657 // `PerHitExplain.id` carries the engine-internal positional
11658 // `write_cursor` (the pre-C-2 `SearchHit.id`), matching the
11659 // telemetry `result_ids` / importance-map key space; the typed
11660 // `SearchHit.id` is the separate caller-facing identity.
11661 id: h.write_cursor,
11662 arm: h.branch,
11663 vector_rank: exp_vector_ranks.as_ref().and_then(|m| m.get(&h.body).copied()),
11664 text_rank: exp_text_ranks.as_ref().and_then(|m| m.get(&h.body).copied()),
11665 graph_rank: exp_graph_ranks.as_ref().and_then(|m| m.get(&h.body).copied()),
11666 fused_score: fused_scores.get(&h.body).copied().unwrap_or(h.score),
11667 ce_score: h.ce_score,
11668 blended: h.score,
11669 importance: exp_importance.as_ref().and_then(|m| m.get(&h.write_cursor).copied()),
11670 confidence: exp_confidence.as_ref().and_then(|m| m.get(&h.write_cursor).copied()),
11671 })
11672 .collect();
11673 let ce_active = rerank_depth > 0 && per_hit.iter().any(|p| p.ce_score.is_some());
11674 Some(Explanation {
11675 trace: QueryTrace {
11676 query_chars: raw_query.chars().count() as u32,
11677 k: final_limit as u32,
11678 rerank_depth: rerank_depth as u32,
11679 pool_n: pool_n as u32,
11680 alpha,
11681 use_graph_arm,
11682 recency: recency_enabled,
11683 embedder_id: String::new(),
11684 ce_active,
11685 vector_hits: exp_vector_n,
11686 text_hits: exp_text_n,
11687 graph_hits: exp_graph_n,
11688 },
11689 per_hit,
11690 })
11691 } else {
11692 None
11693 };
11694
11695 Ok((cursor, soft_fallback, results, graph_stats, explanation))
11696}
11697
11698/// R3 (Slice 30) + C1 (0.8.1 graph-arm seeding) — graph-arm BFS candidate generation.
11699///
11700/// **C1 seeding (the BLOCK-1 fix):** the frontier is seeded from the graph's OWN
11701/// query-matched text surfaces — NOT from doc-node hits (doc nodes carry
11702/// `logical_id = NULL`, so the old doc-seeding produced an empty frontier). Two
11703/// seed sources are unioned on `match_expression` (the compiled FTS query):
11704/// A. **edge-fact FTS** (`search_index_edges`) — both endpoints (`from_id`,
11705/// `to_id`) of matched, temporally-live, non-fallback edges;
11706/// B. **entity-node FTS** (`search_index` ⋈ `canonical_nodes`) — matched nodes
11707/// with `logical_id IS NOT NULL` (excludes doc nodes — the bug surface).
11708/// Each distinct candidate `logical_id` is counted in `seeds_considered`; those
11709/// confirmed active in `canonical_nodes` are `seeds_resolved` and pushed onto the
11710/// frontier (dangling edge endpoints count considered-but-unresolved).
11711///
11712/// Phase 2 is unchanged: BFS over `canonical_edges` with the temporal filter,
11713/// carrying each traversed edge's `source_id` (G0 BLOCK-2) onto the emitted hit.
11714/// Collects reachable node bodies (up to `cap`) as [`SearchHit`]s tagged
11715/// `SoftFallbackBranch::GraphArm`. Score = `1.0 / (1.0 + hop_count)` with a
11716/// synthesized-node penalty (`kind = 'unknown'` → score *= 0.3). Bodies already
11717/// present in `fused_hits` are excluded (already covered by the two-arm result).
11718///
11719/// **F9 (0.8.16 Slice 5) confidence carry:** the third tuple element maps each
11720/// emitted graph-arm hit's `write_cursor` (its `SearchHit.id`, a NODE cursor) to
11721/// the `confidence` of the EDGE traversed to reach that node — the input the F9
11722/// reweight (`graph_rrf_score(edge) = confidence × 1/(K+bfs_rank)`) consumes.
11723/// `build_importance_confidence_maps` keys edge confidence on the EDGE
11724/// `write_cursor`, which never equals a reached node's cursor, so without this
11725/// carry edge confidence never reaches a graph-arm hit. **Determinism rule (matches
11726/// the BLOCK-2 provenance carry):** when several edges reach the same node, the
11727/// FIRST edge to claim the node in the `visited` dedup wins — i.e. the edge that
11728/// produced the node's winning `bfs_rank` (seeds are considered before Phase-2
11729/// neighbors; within a phase, `ORDER BY write_cursor` makes the earliest-written
11730/// edge win). A NULL edge confidence is simply not inserted ⇒ neutral (1.0).
11731fn bfs_graph_arm_candidates(
11732 reader: &mut Connection,
11733 fused_hits: &[SearchHit],
11734 match_expression: &str,
11735 max_depth: u32,
11736 cap: usize,
11737 view: FrozenView,
11738) -> rusqlite::Result<(Vec<SearchHit>, GraphFrontierStats, HashMap<u64, f64>)> {
11739 // fix-2 (codex §9 [P2]): the opt-in graph arm hydrates NODES too, so it takes
11740 // the same validity conjunct as the vector and FTS branches — otherwise
11741 // `search_reranked(.., use_graph_arm = true)` would keep the exact leak the
11742 // other two branches just closed. Same generator, same bound `:now`.
11743 //
11744 // fix-3 (F2): the instant arrives ALREADY RESOLVED in the `FrozenView` — it
11745 // is the identical value the vector and FTS arms bound. This arm cannot
11746 // re-read the clock: a `FrozenView` carries no route to one.
11747 let now_param = view.now_param();
11748 // C1 — seed-FTS fan-out cap per source (A: edge endpoints, B: entity nodes).
11749 const SEED_FTS_N: usize = 10;
11750 const SYNTHESIZED_PENALTY: f64 = 0.3;
11751
11752 // Bodies already in the fused result — exclude these from graph arm output.
11753 let seed_bodies: std::collections::HashSet<&str> =
11754 fused_hits.iter().map(|h| h.body.as_str()).collect();
11755
11756 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
11757
11758 let mut frontier: VecDeque<(String, u32)> = VecDeque::new(); // (logical_id, depth)
11759 let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
11760 let mut candidates: Vec<SearchHit> = Vec::new();
11761 // F9 (0.8.16 Slice 5) — per-hit traversing-edge confidence, keyed by the
11762 // emitted hit's NODE `write_cursor`. First edge to reach a node wins (visited
11763 // dedup); NULL confidence is never inserted (⇒ neutral in the reweight).
11764 let mut edge_confidence_by_cursor: HashMap<u64, f64> = HashMap::new();
11765 // G0 Phase-2 (BLOCK-1) frontier meter — distinct seed candidates considered vs
11766 // resolved-active; `resolved_seed_rate` flips 0→>0 once entities/edge-facts seed.
11767 let mut stats = GraphFrontierStats::default();
11768 {
11769 // C1 seeding — gather distinct candidate (logical_id, provenance source_id)
11770 // pairs from the graph's OWN query-matched FTS surfaces (NOT doc-node hits).
11771 // Order-preserving dedup (first provenance wins) so `seeds_considered` counts
11772 // each candidate once. `source_id` is the session the seed traces back to: the
11773 // matched edge's `source_id` (source A) or the entity node's own (source B).
11774 // F9: each seed carries the confidence of the edge that surfaced it
11775 // (`None` for entity-FTS seeds, which have no traversing edge).
11776 let mut candidate_seeds: Vec<(String, Option<String>, Option<f64>)> = Vec::new();
11777 let mut seen_candidates: std::collections::HashSet<String> =
11778 std::collections::HashSet::new();
11779 let push_candidate =
11780 |lid: String,
11781 source_id: Option<String>,
11782 confidence: Option<f64>,
11783 seen: &mut std::collections::HashSet<String>,
11784 out: &mut Vec<(String, Option<String>, Option<f64>)>| {
11785 if seen.insert(lid.clone()) {
11786 out.push((lid, source_id, confidence));
11787 }
11788 };
11789
11790 // Seed source A — edge-fact endpoints (primary). Both endpoints of each
11791 // matched, temporally-live, non-fallback edge are candidate seeds, tagged with
11792 // the edge's `source_id` provenance and (F9) `confidence`. `search_index_edges`
11793 // may be absent on very old DBs (< step-14) — degrade to no edge seeds rather
11794 // than error.
11795 // TC-33: `?1` MATCH, `?2` LIMIT ⇒ the edge `:now` binds at `?3`.
11796 if let Ok(mut edge_seed_stmt) = tx.prepare(&format!(
11797 "SELECT ce.from_id, ce.to_id, ce.source_id, ce.confidence \
11798 FROM search_index_edges sei \
11799 JOIN canonical_edges ce ON ce.write_cursor = sei.write_cursor \
11800 WHERE search_index_edges MATCH ?1 \
11801 AND ce.superseded_at IS NULL{} \
11802 AND (ce.temporal_fallback IS NULL OR ce.temporal_fallback = 0) \
11803 ORDER BY bm25(search_index_edges), sei.write_cursor \
11804 LIMIT ?2",
11805 edge_validity_sql("ce", 3)
11806 )) {
11807 let rows = edge_seed_stmt.query_map(
11808 rusqlite::params![match_expression, SEED_FTS_N as i64, view.edge_now()],
11809 |row| {
11810 Ok((
11811 row.get::<_, String>(0)?,
11812 row.get::<_, String>(1)?,
11813 row.get::<_, Option<String>>(2)?,
11814 row.get::<_, Option<f64>>(3)?,
11815 ))
11816 },
11817 )?;
11818 for quad in rows {
11819 let (from_id, to_id, source_id, confidence) = quad?;
11820 push_candidate(
11821 from_id,
11822 source_id.clone(),
11823 confidence,
11824 &mut seen_candidates,
11825 &mut candidate_seeds,
11826 );
11827 push_candidate(
11828 to_id,
11829 source_id,
11830 confidence,
11831 &mut seen_candidates,
11832 &mut candidate_seeds,
11833 );
11834 }
11835 }
11836
11837 // Seed source B — entity-node FTS (isolated / strongly-named entities).
11838 // `logical_id IS NOT NULL` structurally excludes doc nodes (the bug surface).
11839 // Provenance = the node's own `source_id` (the session it was extracted from).
11840 {
11841 // `?1` MATCH, `?2` LIMIT ⇒ `:now` binds at `?3`.
11842 let seed_validity = view.validity_sql("cn", 3);
11843 let mut node_seed_stmt = tx.prepare(&format!(
11844 "SELECT cn.logical_id, cn.source_id \
11845 FROM search_index si \
11846 JOIN canonical_nodes cn ON cn.write_cursor = si.write_cursor \
11847 WHERE search_index MATCH ?1 \
11848 AND cn.superseded_at IS NULL \
11849 AND cn.state = 'active' \
11850 AND cn.logical_id IS NOT NULL\
11851 {seed_validity} \
11852 ORDER BY bm25(search_index), si.write_cursor \
11853 LIMIT ?2"
11854 ))?;
11855 let mut seed_params: Vec<rusqlite::types::Value> = vec![
11856 rusqlite::types::Value::Text(match_expression.to_string()),
11857 rusqlite::types::Value::Integer(SEED_FTS_N as i64),
11858 ];
11859 if let Some(now) = now_param {
11860 seed_params.push(rusqlite::types::Value::Integer(now));
11861 }
11862 let rows = node_seed_stmt
11863 .query_map(rusqlite::params_from_iter(seed_params.iter()), |row| {
11864 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
11865 })?;
11866 for pair in rows {
11867 let (lid, source_id) = pair?;
11868 // Entity-FTS seed: no traversing edge ⇒ no edge confidence (neutral).
11869 push_candidate(lid, source_id, None, &mut seen_candidates, &mut candidate_seeds);
11870 }
11871 }
11872
11873 // Resolve + emit: a seed is `resolved` only if an ACTIVE canonical_node carries
11874 // that logical_id (dangling edge endpoints count considered-not-resolved). A
11875 // resolved seed is BOTH a BFS root AND emitted as a graph-arm candidate (depth
11876 // 0, hop_score 1.0) — so an edge-only query match surfaces the connected ENTITY
11877 // nodes, not just the fact body (codex §9 [P2]). Seeds whose body is already in
11878 // the two-arm result are skipped; the cap is respected.
11879 let active_validity = view.validity_sql("canonical_nodes", 2);
11880 let mut active_stmt = tx.prepare(&format!(
11881 "SELECT kind, body, write_cursor FROM canonical_nodes \
11882 WHERE logical_id = ?1 AND superseded_at IS NULL AND state = 'active'\
11883 {active_validity} LIMIT 1"
11884 ))?;
11885 for (lid, source_id, seed_confidence) in candidate_seeds {
11886 stats.seeds_considered += 1;
11887 let mut active_params: Vec<rusqlite::types::Value> =
11888 vec![rusqlite::types::Value::Text(lid.clone())];
11889 if let Some(now) = now_param {
11890 active_params.push(rusqlite::types::Value::Integer(now));
11891 }
11892 let row: Option<(String, String, i64)> = active_stmt
11893 .query_row(rusqlite::params_from_iter(active_params.iter()), |r| {
11894 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, i64>(2)?))
11895 })
11896 .optional()?;
11897 if let Some((kind, body, write_cursor)) = row {
11898 stats.seeds_resolved += 1;
11899 if visited.insert(lid.clone()) {
11900 // Cause-A: the seed's `logical_id` is in hand (`lid`) — derive the
11901 // stable id before `lid` is moved onto the frontier (zero extra query).
11902 let id = derive_stable_id(Some(&lid), &body);
11903 frontier.push_back((lid, 0));
11904 if !seed_bodies.contains(body.as_str()) && candidates.len() < cap {
11905 // depth-0 hop_score = 1.0/(1.0+0) = 1.0; synthesized penalty for
11906 // 'unknown' kind (mirrors the Phase-2 neighbor scoring).
11907 let score = if kind == "unknown" { SYNTHESIZED_PENALTY } else { 1.0 };
11908 // F9: an edge-seeded endpoint carries its seeding edge's
11909 // confidence (source A); entity-FTS seeds carry None.
11910 if let Some(c) = seed_confidence {
11911 edge_confidence_by_cursor.insert(write_cursor as u64, c);
11912 }
11913 candidates.push(SearchHit {
11914 id,
11915 write_cursor: write_cursor as u64,
11916 kind,
11917 body,
11918 score,
11919 branch: SoftFallbackBranch::GraphArm,
11920 source_id,
11921 ce_score: None,
11922 });
11923 }
11924 }
11925 }
11926 }
11927 }
11928 stats.frontier_nonempty = !frontier.is_empty();
11929
11930 // Phase 2: BFS over canonical_edges (temporal filter). `candidates` already
11931 // holds the depth-0 emitted seeds; BFS appends the reachable neighbors.
11932 // Both statements are prepared ONCE outside the loops — re-preparing inside
11933 // would issue O(frontier_size × neighbors) sqlite3_prepare_v2 calls.
11934 let mut edge_stmt = tx.prepare(
11935 // G0 Phase-2 (BLOCK-2): carry the traversed edge's `source_id` so a
11936 // graph-reached neighbor can resolve back to the session it was extracted
11937 // from. `ORDER BY e.write_cursor` makes the traversal deterministic: when
11938 // several active edges connect this node to the SAME neighbor with
11939 // different `source_id`s, the earliest-written edge wins the `visited`
11940 // dedup, so the carried provenance is stable (not SQLite-order-dependent).
11941 // (codex §9 [P2]; the design §B already rejected the memo's arbitrary
11942 // `LIMIT 1` lookup for the same reason.)
11943 // F9: also carry the traversed edge's `confidence` — the reweight input for
11944 // the reached node (keyed downstream by the node's `write_cursor`). Same
11945 // determinism as `source_id`: the earliest-written edge wins the `visited`
11946 // dedup, so the reached node's confidence is the winning-`bfs_rank` edge's.
11947 // TC-33: `?1` is the anchor logical_id ⇒ the edge `:now` binds at `?2`.
11948 &format!(
11949 "SELECT e.from_id, e.to_id, e.source_id, e.confidence \
11950 FROM canonical_edges e \
11951 WHERE (e.from_id = ?1 OR e.to_id = ?1) \
11952 AND e.superseded_at IS NULL{} \
11953 AND (e.temporal_fallback IS NULL OR e.temporal_fallback = 0) \
11954 ORDER BY e.write_cursor \
11955 LIMIT 64",
11956 edge_validity_sql("e", 2)
11957 ),
11958 )?;
11959 // Fetch write_cursor alongside kind+body so graph-arm hits carry a real id
11960 // for apply_recency_reweight (id=0 would force min_id=0 and distort span).
11961 let body_validity = view.validity_sql("canonical_nodes", 2);
11962 let mut body_stmt = tx.prepare(&format!(
11963 "SELECT kind, body, write_cursor FROM canonical_nodes \
11964 WHERE logical_id = ?1 AND superseded_at IS NULL AND state = 'active'\
11965 {body_validity} \
11966 LIMIT 1"
11967 ))?;
11968
11969 while let Some((lid, depth)) = frontier.pop_front() {
11970 if candidates.len() >= cap {
11971 break;
11972 }
11973 if depth >= max_depth {
11974 continue;
11975 }
11976
11977 // Fetch temporal-live neighbors via edges, each paired with the
11978 // traversing edge's `source_id` (BLOCK-2 provenance carry) and (F9)
11979 // `confidence` (the reweight input for the reached node).
11980 let neighbors: Vec<(String, Option<String>, Option<f64>)> = {
11981 let rows = edge_stmt.query_map(params![&lid, view.edge_now()], |row| {
11982 Ok((
11983 row.get::<_, String>(0)?,
11984 row.get::<_, String>(1)?,
11985 row.get::<_, Option<String>>(2)?,
11986 row.get::<_, Option<f64>>(3)?,
11987 ))
11988 })?;
11989 rows.flatten()
11990 .map(|(from_id, to_id, source_id, confidence)| {
11991 let neighbor = if from_id == lid { to_id } else { from_id };
11992 (neighbor, source_id, confidence)
11993 })
11994 .collect()
11995 };
11996
11997 for (neighbor, edge_source_id, edge_confidence) in neighbors {
11998 if visited.contains(&neighbor) {
11999 continue;
12000 }
12001 visited.insert(neighbor.clone());
12002
12003 // Fetch neighbor body + write_cursor from canonical_nodes.
12004 let mut body_params: Vec<rusqlite::types::Value> =
12005 vec![rusqlite::types::Value::Text(neighbor.clone())];
12006 if let Some(now) = now_param {
12007 body_params.push(rusqlite::types::Value::Integer(now));
12008 }
12009 let row: Option<(String, String, i64)> = body_stmt
12010 .query_row(rusqlite::params_from_iter(body_params.iter()), |row| {
12011 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?))
12012 })
12013 .optional()?;
12014
12015 if let Some((kind, body, write_cursor)) = row {
12016 // Skip bodies already covered by the two-arm result.
12017 if !seed_bodies.contains(body.as_str()) {
12018 let hop_score = 1.0 / (1.0 + (depth + 1) as f64);
12019 let score =
12020 if kind == "unknown" { hop_score * SYNTHESIZED_PENALTY } else { hop_score };
12021 // Cause-A: the neighbor's `logical_id` is `neighbor` (still in
12022 // scope here; only moved onto the frontier below) — derive the
12023 // stable id with no extra query.
12024 let id = derive_stable_id(Some(&neighbor), &body);
12025 // F9: record the traversing edge's confidence for this node
12026 // (first edge wins — this is the winning-`bfs_rank` edge).
12027 if let Some(c) = edge_confidence {
12028 edge_confidence_by_cursor.insert(write_cursor as u64, c);
12029 }
12030 candidates.push(SearchHit {
12031 id,
12032 write_cursor: write_cursor as u64,
12033 kind,
12034 body,
12035 score,
12036 branch: SoftFallbackBranch::GraphArm,
12037 // BLOCK-2: the session this fact-edge was extracted from.
12038 source_id: edge_source_id.clone(),
12039 ce_score: None,
12040 });
12041 if candidates.len() >= cap {
12042 break;
12043 }
12044 }
12045 // Always push neighbor to frontier for further BFS expansion.
12046 frontier.push_back((neighbor, depth + 1));
12047 }
12048 }
12049 }
12050
12051 drop(edge_stmt);
12052 drop(body_stmt);
12053 tx.commit()?;
12054 stats.graph_candidates_emitted = candidates.len() as u32;
12055 Ok((candidates, stats, edge_confidence_by_cursor))
12056}
12057
12058/// Slice 30 (G3) — the ~1M cap on a single op-store read-back page. The public
12059/// `read.collection` / `read.mutations` LIMIT is `min(caller_limit, this)`, so
12060/// no API path can issue an unbounded SELECT. Cursor/limit hardening under a
12061/// genuine ~1M-row append-only log is reserved-gap Slice 32.
12062const READ_COLLECTION_MAX_LIMIT: usize = 1_000_000;
12063
12064/// Slice 30 (G2) — active-only point lookup by `logical_id` on the DEFERRED
12065/// reader tx (mirrors `read_search_in_tx`'s snapshot-stable BEGIN DEFERRED). One
12066/// returned slot per requested id, in REQUEST ORDER; `None` where no ACTIVE row
12067/// (`superseded_at IS NULL`) carries that id. Mirrors the `:4170` canonical
12068/// projection columns + `logical_id`; superseded versions are never returned.
12069fn read_get_by_id_in_tx(
12070 reader: &mut Connection,
12071 logical_ids: &[String],
12072 view: &ReadView,
12073) -> rusqlite::Result<Vec<Option<NodeRecord>>> {
12074 if logical_ids.is_empty() {
12075 return Ok(Vec::new());
12076 }
12077 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12078 // De-duplicate the requested ids for the IN(...) probe, then re-expand into
12079 // request order (a repeated id echoes the same active row).
12080 let mut found: HashMap<String, NodeRecord> = HashMap::new();
12081 {
12082 let unique: Vec<&String> = {
12083 let mut seen = std::collections::HashSet::new();
12084 logical_ids.iter().filter(|id| seen.insert((*id).clone())).collect()
12085 };
12086 let placeholders = std::iter::repeat_n("?", unique.len()).collect::<Vec<_>>().join(", ");
12087 // The `?` placeholders above auto-number 1..=unique.len(), so the
12088 // validity instant takes the next positional slot.
12089 let now_idx = unique.len() + 1;
12090 let node_sql = view.node_sql("canonical_nodes", now_idx);
12091 // R-20-RV: with `include_superseded` a logical_id can match several
12092 // rows. `ORDER BY write_cursor` + last-write-wins into `found` resolves
12093 // the slot DETERMINISTICALLY to the most recent version, rather than
12094 // leaving it at the mercy of scan order.
12095 let sql = format!(
12096 "SELECT logical_id, kind, body, write_cursor
12097 FROM canonical_nodes
12098 WHERE logical_id IN ({placeholders}){node_sql}
12099 ORDER BY write_cursor"
12100 );
12101 let mut statement = tx.prepare(&sql)?;
12102 let mut binds: Vec<rusqlite::types::Value> =
12103 unique.iter().map(|s| rusqlite::types::Value::Text((*s).clone())).collect();
12104 if let Some(now) = view.now_param() {
12105 binds.push(rusqlite::types::Value::Integer(now));
12106 }
12107 let rows = statement.query_map(rusqlite::params_from_iter(binds.iter()), |row| {
12108 let logical_id: String = row.get(0)?;
12109 Ok(NodeRecord {
12110 logical_id,
12111 kind: row.get(1)?,
12112 body: row.get(2)?,
12113 write_cursor: row.get::<_, i64>(3)? as u64,
12114 })
12115 })?;
12116 for row in rows {
12117 let record = row?;
12118 found.insert(record.logical_id.clone(), record);
12119 }
12120 }
12121 // tx is read-only; dropping it rolls back the (empty) transaction.
12122 let out = logical_ids.iter().map(|id| found.get(id).cloned()).collect();
12123 Ok(out)
12124}
12125
12126/// Slice 30 (G3) — paginated op-store read-back over `operational_mutations` for
12127/// one `collection`, `ORDER BY id`, on the DEFERRED reader tx. The effective SQL
12128/// LIMIT is `min(limit, READ_COLLECTION_MAX_LIMIT)`; a caller `limit == 0`
12129/// returns an empty `Vec` without a SELECT. The after-id cursor (`id > ?`,
12130/// default 0) excludes the boundary row. The `_for_test` SELECTs
12131/// (`lib.rs` op-store probes) are a shape oracle only — this is a new statement.
12132///
12133/// Slice 33 (G3 / F4-READ) — hardened under a genuine large multi-collection log:
12134/// the SELECT rides the step-13 `operational_mutations(collection_name, id)`
12135/// index (`SEARCH … USING INDEX …(collection_name=? AND id>?)`), so the per-page
12136/// cost is O(page) — the leading `collection_name` equality fixes the prefix and
12137/// the trailing `id` serves both the cursor range and `ORDER BY id` with no temp
12138/// B-tree. The cursor is normalized with `.max(0)` so a negative `after_id` is
12139/// explicitly clamped to the start of the log (ids are ≥ 1) and is never confused
12140/// with a row id; `after_id` past the end and unknown collections yield empty
12141/// pages.
12142fn read_collection_in_tx(
12143 reader: &mut Connection,
12144 collection: &str,
12145 after_id: Option<i64>,
12146 limit: usize,
12147) -> rusqlite::Result<Vec<OpStoreRow>> {
12148 if limit == 0 {
12149 return Ok(Vec::new());
12150 }
12151 let clamped = limit.min(READ_COLLECTION_MAX_LIMIT) as i64;
12152 // Normalize the cursor: a negative after_id is clamped to the start of the
12153 // log. `operational_mutations.id` is autoincrement (≥ 1), so `id > 0` is the
12154 // full log; clamping removes the "is a negative cursor a sentinel or a row
12155 // id?" ambiguity without changing happy-path semantics.
12156 let after = after_id.unwrap_or(0).max(0);
12157 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12158 let mut statement = tx.prepare(
12159 "SELECT id, collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
12160 FROM operational_mutations
12161 WHERE collection_name = ?1 AND id > ?2
12162 ORDER BY id
12163 LIMIT ?3",
12164 )?;
12165 let rows = statement.query_map(params![collection, after, clamped], |row| {
12166 Ok(OpStoreRow {
12167 id: row.get(0)?,
12168 collection: row.get(1)?,
12169 record_key: row.get(2)?,
12170 op_kind: row.get(3)?,
12171 payload: row.get(4)?,
12172 schema_id: row.get(5)?,
12173 write_cursor: row.get::<_, i64>(6)? as u64,
12174 })
12175 })?;
12176 let mut out = Vec::new();
12177 for row in rows {
12178 out.push(row?);
12179 }
12180 Ok(out)
12181}
12182
12183/// Slice 35 (G4) — execute `read.list` inside a DEFERRED reader transaction.
12184///
12185/// Builds parameterized SQL: `kind = ?1 AND superseded_at IS NULL [AND
12186/// json_extract(body, '$.field') <op> ?N ...]` — injection-safe because:
12187/// (a) `kind` is `?1` (bound parameter);
12188/// (b) each predicate value is a bound `?N` parameter;
12189/// (c) the json_extract path is the ALLOWLIST ENTRY (a server-side constant
12190/// validated at `Predicate` construction time), never the raw caller string;
12191/// (d) `ComparisonOp` compiles to a server-side literal operator string from a
12192/// closed enum, not a caller-supplied string.
12193fn read_list_in_tx(
12194 reader: &mut Connection,
12195 kind: &str,
12196 predicates: &[Predicate],
12197 limit: usize,
12198 view: &ReadView,
12199) -> rusqlite::Result<Vec<NodeRecord>> {
12200 if limit == 0 {
12201 return Ok(Vec::new());
12202 }
12203 // Build the SQL WHERE clauses for each predicate.
12204 // Parameters: ?1 = kind; ?2..?N = predicate values; limit is inlined.
12205 // `logical_id IS NOT NULL` is a SQL-level predicate so that LIMIT counts
12206 // only rows that can be represented as NodeRecord (which requires a non-null
12207 // String logical_id). Anonymous nodes (PreparedWrite::Node { logical_id: None })
12208 // cannot be included in NodeRecord results and are excluded before LIMIT.
12209 // When predicates are present we add `json_valid(body)` so rows with
12210 // non-JSON bodies are skipped rather than causing a `malformed JSON` error.
12211 let json_valid_guard = if predicates.is_empty() { "" } else { " AND json_valid(body)" };
12212 // R-20-RV/R-20-NV: the view's predicates replace the previously hard-coded
12213 // existence pair. The validity instant takes the positional slot AFTER the
12214 // predicate binds (?1 = kind, ?2..=?(1+n) = predicate values), so it is
12215 // `?{predicates.len() + 2}`. Positional `?N` is order-independent in SQLite,
12216 // so emitting it here — textually before the predicate clauses appended
12217 // below — is safe and unambiguous.
12218 let now_idx = predicates.len() + 2;
12219 let node_sql = view.node_sql("canonical_nodes", now_idx);
12220 let mut sql = format!(
12221 "SELECT logical_id, kind, body, write_cursor \
12222 FROM canonical_nodes \
12223 WHERE kind = ?1{node_sql} \
12224 AND logical_id IS NOT NULL{json_valid_guard}"
12225 );
12226
12227 // Predicate params start at ?2.
12228 for (i, pred) in predicates.iter().enumerate() {
12229 let param_idx = i + 2; // ?1 is kind
12230 sql.push_str(" AND ");
12231 sql.push_str(&pred.to_sql_clause(param_idx));
12232 }
12233 sql.push_str(&format!(" LIMIT {limit}"));
12234
12235 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12236 let mut statement = tx.prepare(&sql)?;
12237
12238 // Bind all parameters: [kind, predicate_values...]
12239 let mut params: Vec<rusqlite::types::Value> = Vec::with_capacity(2 + predicates.len());
12240 params.push(rusqlite::types::Value::Text(kind.to_string()));
12241 for pred in predicates {
12242 params.push(pred.bind_value());
12243 }
12244 // Lands at index `now_idx` (= predicates.len() + 2), matching `?{now_idx}`
12245 // emitted by `ReadView::validity_sql`. Omitted entirely when the view
12246 // relaxes validity, in which case no `?{now_idx}` was emitted either.
12247 if let Some(now) = view.now_param() {
12248 params.push(rusqlite::types::Value::Integer(now));
12249 }
12250
12251 let rows = statement.query_map(rusqlite::params_from_iter(params.iter()), |row| {
12252 Ok(NodeRecord {
12253 logical_id: row.get(0)?,
12254 kind: row.get(1)?,
12255 body: row.get(2)?,
12256 write_cursor: row.get::<_, i64>(3)? as u64,
12257 })
12258 })?;
12259
12260 let mut out = Vec::new();
12261 for row in rows {
12262 out.push(row?);
12263 }
12264 Ok(out)
12265}
12266
12267// ---------------------------------------------------------------------------
12268// Slice 20 (G5/G6) — BFS graph-traversal helpers
12269// ---------------------------------------------------------------------------
12270
12271/// Hard cap on the number of nodes returned by a single `graph_neighbors` call.
12272/// Ported from v0.5.6 `MAX_TRAVERSAL_DEPTH` (applied as a LIMIT on the CTE and
12273/// the final SELECT). Defense-in-depth against unbounded traversal.
12274const GRAPH_NEIGHBORS_HARD_CAP: usize = 50;
12275
12276/// Build the BFS CTE SQL for the given `direction`, under `view`.
12277///
12278/// Parameters (positional):
12279/// `?1` — root `logical_id`
12280/// `?2` — max_depth (`u32`, SDK-facing depth ceiling ≤ 3)
12281/// `?3` — R-20-NV node-validity instant (`:now` seam), emitted at EVERY node
12282/// position; omitted entirely when the view relaxes validity.
12283///
12284/// `LIMIT {GRAPH_NEIGHBORS_HARD_CAP}` appears on both the CTE and the final SELECT.
12285///
12286/// # Why one template instead of three
12287///
12288/// The three directions previously carried three hand-maintained copies of the
12289/// CTE, each repeating the node predicate at THREE positions (anchor, recursive
12290/// join, final projection) — nine hand-written copies in total. R-20-RV requires
12291/// a relax flag to apply at every one of them, and nine copies is exactly the
12292/// shape in which "it works on `Outgoing` but silently not on `Both`" hides. The
12293/// directions are folded into ONE template parameterised by the two things that
12294/// actually differ (the edge join condition and the traversed-to expression), so
12295/// `view.node_sql(...)` is written once per position and applying to all three
12296/// directions is structural rather than a thing to remember.
12297///
12298/// **TC-33: the `canonical_edges` temporal filter is now parameterised too.** It
12299/// was `datetime(e.t_invalid) > datetime('now')` inline, deliberately left alone
12300/// while edge validity was ISO-8601 TEXT. Edge timestamps are INTEGER epoch
12301/// seconds now, so the predicate is generated by [`edge_validity_sql`] and binds
12302/// the frozen instant at `?4` — no inline clock remains in this template.
12303fn build_bfs_sql(direction: TraversalDirection, view: &ReadView) -> String {
12304 let cap = GRAPH_NEIGHBORS_HARD_CAP;
12305 // cte_cap: the SQLite CTE LIMIT counts path-rows, not distinct nodes. In a
12306 // multigraph (multiple parallel edges between the same pair of nodes), the CTE
12307 // can contain duplicate-target rows before the final SELECT DISTINCT. A cap of
12308 // cap+1 would be exhausted by ~50 parallel edges to the same node, preventing
12309 // other neighbors from being discovered. Use cap*cap as a generous safety
12310 // ceiling that still bounds CTE growth for any realistic graph while allowing
12311 // the final SELECT LIMIT cap to be the authoritative distinct-node cap.
12312 let cte_cap = cap * cap;
12313 // Cycle guard uses char(30) (ASCII Record Separator, 0x1E) as delimiter instead
12314 // of comma, so logical_ids containing commas are handled correctly. char(30) is
12315 // a non-printable control character that callers cannot place in logical_id values
12316 // via normal text input.
12317 //
12318 // `?3` is the node-validity instant. Positional (not named), so the repeated
12319 // occurrences across the three node positions all bind the SAME value once.
12320 const NOW_IDX: usize = 3;
12321 // TC-33: `?4` is the EDGE-validity instant, bound separately because the node
12322 // instant is `Option` (relaxed by `include_out_of_window`) while edge recency
12323 // is always applied.
12324 const EDGE_NOW_IDX: usize = 4;
12325
12326 // The ONLY two things that differ between directions.
12327 let (edge_join, target_expr) = match direction {
12328 TraversalDirection::Outgoing => ("e.from_id = t.logical_id", "e.to_id"),
12329 TraversalDirection::Incoming => ("e.to_id = t.logical_id", "e.from_id"),
12330 TraversalDirection::Both => (
12331 "(e.from_id = t.logical_id OR e.to_id = t.logical_id)",
12332 "CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END",
12333 ),
12334 };
12335
12336 // Position 1 (anchor), position 2 (recursive join), position 3 (final
12337 // projection) — the view is applied at all three, for every direction.
12338 let anchor_node = view.node_sql("n", NOW_IDX);
12339 let next_node = view.node_sql("next_n", NOW_IDX);
12340 let projection_node = view.node_sql("n", NOW_IDX);
12341 let edge_valid = edge_validity_sql("e", EDGE_NOW_IDX);
12342
12343 format!(
12344 "WITH RECURSIVE
12345 traversal(logical_id, depth, visited) AS (
12346 SELECT n.logical_id, 0, char(30) || n.logical_id || char(30)
12347 FROM canonical_nodes n
12348 WHERE n.logical_id = ?1{anchor_node}
12349 UNION ALL
12350 SELECT {target_expr}, t.depth + 1, t.visited || {target_expr} || char(30)
12351 FROM traversal t
12352 JOIN canonical_edges e ON {edge_join}
12353 JOIN canonical_nodes next_n ON next_n.logical_id = {target_expr}{next_node}
12354 WHERE t.depth < ?2
12355 AND e.superseded_at IS NULL{edge_valid}
12356 AND instr(t.visited, char(30) || {target_expr} || char(30)) = 0
12357 LIMIT {cte_cap}
12358 )
12359SELECT DISTINCT n.logical_id, n.kind, n.body, n.write_cursor
12360FROM traversal tr
12361JOIN canonical_nodes n ON n.logical_id = tr.logical_id
12362WHERE tr.logical_id != ?1{projection_node}
12363LIMIT {cap}"
12364 )
12365}
12366
12367/// Build the BFS CTE SQL for `search_expand` — identical to `build_bfs_sql`
12368/// but the final SELECT uses `GROUP BY` + `MIN(tr.depth)` so that each
12369/// expanded node carries its actual BFS distance from the root.
12370///
12371/// Returns 5 columns: logical_id, kind, body, write_cursor, min_depth.
12372fn build_bfs_with_depth_sql() -> String {
12373 let cap = GRAPH_NEIGHBORS_HARD_CAP;
12374 let cte_cap = cap * cap; // same multigraph-safe headroom as build_bfs_sql
12375 // TC-33: `?1` anchor, `?2` depth ⇒ the edge `:now` binds at `?3`. This is a
12376 // SECOND, separate BFS template — the edge-validity predicate has to be
12377 // re-grounded here too or `search_expand` silently keeps the old semantics.
12378 let edge_valid = edge_validity_sql("e", 3);
12379 format!(
12380 "WITH RECURSIVE
12381 traversal(logical_id, depth, visited) AS (
12382 SELECT n.logical_id, 0, char(30) || n.logical_id || char(30)
12383 FROM canonical_nodes n
12384 WHERE n.logical_id = ?1 AND n.superseded_at IS NULL AND n.state = 'active'
12385 UNION ALL
12386 SELECT
12387 CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END,
12388 t.depth + 1,
12389 t.visited || CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END || char(30)
12390 FROM traversal t
12391 JOIN canonical_edges e ON (e.from_id = t.logical_id OR e.to_id = t.logical_id)
12392 JOIN canonical_nodes next_n
12393 ON next_n.logical_id = CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END
12394 AND next_n.superseded_at IS NULL AND next_n.state = 'active'
12395 WHERE t.depth < ?2
12396 AND e.superseded_at IS NULL{edge_valid}
12397 AND instr(t.visited,
12398 char(30) || CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END || char(30)) = 0
12399 LIMIT {cte_cap}
12400 )
12401SELECT n.logical_id, n.kind, n.body, n.write_cursor, MIN(tr.depth) AS min_depth
12402FROM traversal tr
12403JOIN canonical_nodes n ON n.logical_id = tr.logical_id
12404WHERE n.superseded_at IS NULL AND n.state = 'active'
12405 AND tr.logical_id != ?1
12406GROUP BY n.logical_id
12407LIMIT {cap}"
12408 )
12409}
12410
12411/// 0.8.20 Slice 10b (R-20-NV) — the validity-boundary hook, on the DEFERRED
12412/// reader transaction.
12413///
12414/// Reports nodes whose `valid_from` and/or `valid_until` falls in the half-open
12415/// interval `(since, upper]`. Both bounds are BOUND parameters (`?1`, `?2`) —
12416/// the node-validity path never inlines `datetime('now')`.
12417///
12418/// The view's EXISTENCE conjunct applies (default: current + active rows only);
12419/// its VALIDITY conjunct deliberately does not, because the question is "did
12420/// this window cross a boundary", not "is this row valid now".
12421fn crossed_boundary_since_in_tx(
12422 reader: &mut Connection,
12423 since: i64,
12424 view: &ReadView,
12425) -> rusqlite::Result<Vec<BoundaryCrossing>> {
12426 // `now_param()` is None exactly when the view relaxes validity, which here
12427 // means "no upper bound on the interval".
12428 let upper = view.now_param().unwrap_or(i64::MAX);
12429 let existence = view.existence_sql("canonical_nodes");
12430 // `1 = 1` keeps the leading ` AND ` of `existence_sql` well-formed even when
12431 // every existence flag is relaxed and the conjunct is empty.
12432 let sql = format!(
12433 "SELECT logical_id, kind, body, write_cursor, valid_from, valid_until \
12434 FROM canonical_nodes \
12435 WHERE 1 = 1{existence} \
12436 AND logical_id IS NOT NULL \
12437 AND ( (valid_from IS NOT NULL AND valid_from > ?1 AND valid_from <= ?2) \
12438 OR (valid_until IS NOT NULL AND valid_until > ?1 AND valid_until <= ?2) ) \
12439 ORDER BY write_cursor"
12440 );
12441 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12442 let mut statement = tx.prepare(&sql)?;
12443 let rows = statement.query_map(params![since, upper], |row| {
12444 let valid_from: Option<i64> = row.get(4)?;
12445 let valid_until: Option<i64> = row.get(5)?;
12446 Ok(BoundaryCrossing {
12447 node: NodeRecord {
12448 logical_id: row.get(0)?,
12449 kind: row.get(1)?,
12450 body: row.get(2)?,
12451 write_cursor: row.get::<_, i64>(3)? as u64,
12452 },
12453 became_valid_at: valid_from.filter(|t| *t > since && *t <= upper),
12454 became_invalid_at: valid_until.filter(|t| *t > since && *t <= upper),
12455 })
12456 })?;
12457 let mut out = Vec::new();
12458 for row in rows {
12459 out.push(row?);
12460 }
12461 Ok(out)
12462}
12463
12464/// Slice 20 (G5) — execute a bounded BFS on the DEFERRED reader transaction.
12465/// Called inside the reader worker loop.
12466fn graph_neighbors_in_tx(
12467 reader: &mut Connection,
12468 root_logical_id: &str,
12469 depth: u32,
12470 direction: TraversalDirection,
12471 view: &ReadView,
12472) -> rusqlite::Result<Vec<NodeRecord>> {
12473 let sql = build_bfs_sql(direction, view);
12474 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12475 let depth_i64 = depth as i64;
12476 let mut statement = tx.prepare(&sql)?;
12477 // ?1 root, ?2 depth, ?3 = the NODE validity instant, ?4 = the EDGE validity
12478 // instant (TC-33).
12479 //
12480 // ?3 is bound UNCONDITIONALLY even when the view relaxes node validity and
12481 // `build_bfs_sql` emitted no `?3`: the template still references ?4, so
12482 // SQLite's parameter count is 4 and the positions must not shift. Binding an
12483 // index the SQL never reads is harmless; letting ?4's value slide into ?3
12484 // would silently compare edge times against a placeholder.
12485 let frozen = (*view).freeze();
12486 let binds: Vec<rusqlite::types::Value> = vec![
12487 rusqlite::types::Value::Text(root_logical_id.to_string()),
12488 rusqlite::types::Value::Integer(depth_i64),
12489 rusqlite::types::Value::Integer(frozen.now_param().unwrap_or_default()),
12490 rusqlite::types::Value::Integer(frozen.edge_now()),
12491 ];
12492 let rows = statement.query_map(rusqlite::params_from_iter(binds.iter()), |row| {
12493 Ok(NodeRecord {
12494 logical_id: row.get(0)?,
12495 kind: row.get(1)?,
12496 body: row.get(2)?,
12497 write_cursor: row.get::<_, i64>(3)? as u64,
12498 })
12499 })?;
12500 let mut out = Vec::new();
12501 for row in rows {
12502 out.push(row?);
12503 }
12504 Ok(out)
12505}
12506
12507/// Slice 20 (G6) — resolve search hit `write_cursor`s to `logical_id`s, run
12508/// BFS for each root, and merge into a [`SearchExpandResult`]. Called inside
12509/// the reader worker loop on the DEFERRED reader transaction.
12510fn search_expand_in_tx(
12511 reader: &mut Connection,
12512 search_hits: &[SearchHit],
12513 depth: u32,
12514) -> rusqlite::Result<SearchExpandResult> {
12515 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12516
12517 // Step 1: resolve write_cursor → logical_id for each search hit.
12518 // Possible outcomes per hit:
12519 // - None: no matching write_cursor in canonical_nodes (superseded) → drop.
12520 // - Some(""): row exists but logical_id IS NULL (anonymous node) or TextEdge hit
12521 // → keep as valid search result, skip BFS expansion (empty sentinel).
12522 // - Some(lid): active named node → keep; use as BFS root.
12523 let mut hit_logical_ids: Vec<Option<String>> = Vec::with_capacity(search_hits.len());
12524 {
12525 let mut node_stmt = tx.prepare(
12526 "SELECT logical_id FROM canonical_nodes
12527 WHERE write_cursor = ?1 AND superseded_at IS NULL AND state = 'active'
12528 LIMIT 1",
12529 )?;
12530 let mut edge_stmt = tx.prepare(
12531 "SELECT 1 FROM canonical_edges
12532 WHERE write_cursor = ?1 AND superseded_at IS NULL
12533 LIMIT 1",
12534 )?;
12535 for hit in search_hits {
12536 if hit.branch == SoftFallbackBranch::TextEdge {
12537 // Edge-body hit: verify the edge row is still active in THIS snapshot.
12538 // Stale edge hits (superseded between search and expansion) are dropped.
12539 let cursor_i64 = hit.write_cursor as i64;
12540 let active: Option<i32> =
12541 edge_stmt.query_row([cursor_i64], |row| row.get(0)).optional()?;
12542 if active.is_some() {
12543 hit_logical_ids.push(Some(String::new())); // sentinel: keep hit, skip BFS
12544 } else {
12545 hit_logical_ids.push(None); // superseded edge: drop
12546 }
12547 } else {
12548 let cursor_i64 = hit.write_cursor as i64;
12549 // Returns Option<Option<String>>:
12550 // None → no row → superseded
12551 // Some(None) → row with NULL logical_id → anonymous node
12552 // Some(Some(s)) → active named node
12553 let resolved = node_stmt
12554 .query_row([cursor_i64], |row| row.get::<_, Option<String>>(0))
12555 .optional()?;
12556 match resolved {
12557 None => hit_logical_ids.push(None), // superseded: drop
12558 Some(None) => hit_logical_ids.push(Some(String::new())), // anon: keep, skip BFS
12559 Some(Some(lid)) => hit_logical_ids.push(Some(lid)), // named: keep + BFS root
12560 }
12561 }
12562 }
12563 }
12564
12565 // Build a set of logical_ids present in the search hits (for deduplication).
12566 // Empty-string sentinels (TextEdge hits) are excluded — they are not real node ids.
12567 let hit_id_set: std::collections::HashSet<String> =
12568 hit_logical_ids.iter().filter_map(|id| id.clone()).filter(|s| !s.is_empty()).collect();
12569
12570 // Step 2: for each root logical_id, run the BFS and collect expanded nodes.
12571 // A node already in `hit_id_set` is NOT added to `expanded`.
12572 // Use the depth-aware variant so each node reports its actual BFS distance.
12573 let bfs_sql = build_bfs_with_depth_sql();
12574 let depth_i64 = depth as i64;
12575 // nearest_hop: for each expanded logical_id track the minimum hop count
12576 // seen across ALL search-hit roots. A node reachable from multiple roots
12577 // at different depths must report the shortest distance (nearest root).
12578 let mut nearest_hop: std::collections::HashMap<String, (NodeRecord, u32)> =
12579 std::collections::HashMap::new();
12580
12581 if depth > 0 {
12582 let mut bfs_stmt = tx.prepare(&bfs_sql)?;
12583 // TC-33: `?3` is the edge-validity instant. `search_expand` has no
12584 // `ReadView` in scope, so it uses the default (strict) semantics —
12585 // resolved ONCE here, not per root, so every root in one call agrees.
12586 let edge_now = current_epoch_seconds();
12587 for root_id in hit_logical_ids.iter().flatten().filter(|s| !s.is_empty()) {
12588 let neighbor_rows =
12589 bfs_stmt.query_map(params![root_id, depth_i64, edge_now], |row| {
12590 let node = NodeRecord {
12591 logical_id: row.get(0)?,
12592 kind: row.get(1)?,
12593 body: row.get(2)?,
12594 write_cursor: row.get::<_, i64>(3)? as u64,
12595 };
12596 let min_depth: i64 = row.get(4)?;
12597 Ok((node, min_depth as u32))
12598 })?;
12599 for row_result in neighbor_rows {
12600 let (node, hop_count) = row_result?;
12601 if hit_id_set.contains(&node.logical_id) {
12602 // Already a search hit — skip (search score takes priority).
12603 continue;
12604 }
12605 nearest_hop
12606 .entry(node.logical_id.clone())
12607 .and_modify(|(_, prev_hop)| {
12608 if hop_count < *prev_hop {
12609 *prev_hop = hop_count;
12610 }
12611 })
12612 .or_insert((node, hop_count));
12613 }
12614 }
12615 }
12616
12617 // Materialize expanded in insertion order (deterministic for tests).
12618 let mut expanded: Vec<(NodeRecord, u32)> = nearest_hop.into_values().collect();
12619 expanded.sort_by(|(a, _), (b, _)| a.logical_id.cmp(&b.logical_id));
12620
12621 // Filter search_hits to only include those whose write_cursor resolved to an
12622 // active logical_id in THIS snapshot. Hits that were superseded between the
12623 // search phase and the expansion phase (the two-snapshot window) are dropped
12624 // rather than returned with stale data.
12625 let resolved_hits: Vec<SearchHit> = search_hits
12626 .iter()
12627 .zip(hit_logical_ids.iter())
12628 .filter_map(|(hit, lid)| lid.as_ref().map(|_| hit.clone()))
12629 .collect();
12630
12631 // Build `all_logical_ids` = resolved search-hit logical_ids + expanded node ids.
12632 // Empty-string sentinels (TextEdge hits) are excluded — they are not real node ids.
12633 let mut all_logical_ids: Vec<String> =
12634 hit_logical_ids.into_iter().flatten().filter(|s| !s.is_empty()).collect();
12635 for (node, _) in &expanded {
12636 if !all_logical_ids.contains(&node.logical_id) {
12637 all_logical_ids.push(node.logical_id.clone());
12638 }
12639 }
12640
12641 Ok(SearchExpandResult { search_hits: resolved_hits, expanded, all_logical_ids })
12642}
12643
12644/// Slice 20 test seam — run `EXPLAIN QUERY PLAN` on the BFS CTE SQL and return
12645/// the plan `detail` column (column index 3) for each row. Used by
12646/// `explain_plan_uses_indexes` to assert index usage.
12647fn explain_graph_neighbors_in_tx(
12648 reader: &mut Connection,
12649 root_logical_id: &str,
12650 depth: u32,
12651 direction: TraversalDirection,
12652) -> rusqlite::Result<Vec<String>> {
12653 // The EXPLAIN index-usage gate measures the DEFAULT (strict) read path.
12654 let view = ReadView::default();
12655 let bfs_sql = build_bfs_sql(direction, &view);
12656 let explain_sql = format!("EXPLAIN QUERY PLAN {bfs_sql}");
12657 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12658 let depth_i64 = depth as i64;
12659 let mut statement = tx.prepare(&explain_sql)?;
12660 // EXPLAIN QUERY PLAN returns rows: (id, parent, notused, detail).
12661 // We collect the `detail` column (index 3).
12662 // The strict view emits `?3` (the node-validity instant) at every node
12663 // position; TC-33 adds `?4`, the edge-validity instant.
12664 let frozen = view.freeze();
12665 let now = frozen.now_param().expect("the strict view always binds a validity instant");
12666 let rows = statement
12667 .query_map(params![root_logical_id, depth_i64, now, frozen.edge_now()], |row| {
12668 row.get::<_, String>(3)
12669 })?;
12670 let mut out = Vec::new();
12671 for row in rows {
12672 out.push(row?);
12673 }
12674 Ok(out)
12675}
12676
12677fn projection_dispatcher_loop(shared: Arc<ProjectionRuntimeShared>) {
12678 let connection = match open_runtime_connection(&shared.path) {
12679 Ok(connection) => connection,
12680 Err(_) => return,
12681 };
12682 // 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — read ONCE:
12683 // `ProjectionRuntimeShared::embedder` is fixed for the session's lifetime.
12684 let dense_arm_live = shared.embedder.is_some();
12685 loop {
12686 let in_flight = {
12687 let mut state = match shared.state.lock() {
12688 Ok(state) => state,
12689 Err(_) => return,
12690 };
12691 while !state.stopping
12692 && (!state.pending_scan
12693 || state.frozen
12694 || state.active_jobs + state.queued_jobs >= PROJECTION_INFLIGHT_LIMIT)
12695 {
12696 state = match shared.state_cvar.wait(state) {
12697 Ok(state) => state,
12698 Err(_) => return,
12699 };
12700 }
12701 if state.stopping {
12702 return;
12703 }
12704 state.pending_scan = false;
12705 state.in_flight.clone()
12706 };
12707
12708 // Fetch up to the in-flight budget in one SQL roundtrip and
12709 // enqueue them as a batch — previously this loop fetched ONE job
12710 // per cycle, which capped projection throughput at one row per
12711 // scanner/worker handshake regardless of how much work was queued
12712 // in canonical_nodes.
12713 let budget = {
12714 let state = match shared.state.lock() {
12715 Ok(state) => state,
12716 Err(_) => return,
12717 };
12718 PROJECTION_INFLIGHT_LIMIT.saturating_sub(state.active_jobs + state.queued_jobs)
12719 };
12720 let fetch_cap = budget.clamp(1, PROJECTION_SCAN_FETCH);
12721 // 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — with no live embedder a
12722 // NODE job can only come back DEFERRED (`ProjectionOutcome::Deferred`),
12723 // which by design records no terminal, so dispatching one would re-fetch
12724 // the SAME cursor forever. fix-5 (codex §9 round 4 [P1]) moved that
12725 // exclusion INSIDE the scan, so the `LIMIT` applies to the already-filtered
12726 // set and a pending EDGE body behind a full window of node rows is still
12727 // reachable. See `next_pending_projection_jobs`.
12728 let fetched =
12729 next_pending_projection_jobs(&connection, &in_flight, fetch_cap, dense_arm_live);
12730 // Cheap assertion only — it can never DROP a job, which is precisely what
12731 // the fix-4 shape did.
12732 debug_assert!(
12733 fetched
12734 .as_ref()
12735 .map(|jobs| dense_arm_live || jobs.iter().all(|job| job.kind == EDGE_FACT_KIND))
12736 .unwrap_or(true),
12737 "no-embedder scan returned a NODE job: the exclusion must be in the scan's SQL"
12738 );
12739 match fetched {
12740 Ok(jobs) if !jobs.is_empty() => {
12741 if let Ok(mut state) = shared.state.lock() {
12742 state.queued_jobs = state.queued_jobs.saturating_add(jobs.len());
12743 for job in &jobs {
12744 state.in_flight.insert(job.cursor);
12745 }
12746 state.pending_scan = true;
12747 shared.state_cvar.notify_all();
12748 }
12749 if let Ok(mut queue) = shared.queue.lock() {
12750 for job in jobs {
12751 queue.push_back(job);
12752 }
12753 shared.queue_cvar.notify_all();
12754 }
12755 }
12756 Ok(_) => {}
12757 Err(_) => {
12758 if let Ok(mut state) = shared.state.lock() {
12759 state.pending_scan = false;
12760 shared.state_cvar.notify_all();
12761 }
12762 }
12763 }
12764 }
12765}
12766
12767fn projection_worker_loop(shared: Arc<ProjectionRuntimeShared>) {
12768 let mut connection = match open_runtime_connection(&shared.path) {
12769 Ok(connection) => connection,
12770 Err(_) => return,
12771 };
12772 if ensure_vector_partition(&mut connection, shared.embedder_identity.dimension).is_err() {
12773 return;
12774 }
12775 loop {
12776 let jobs = {
12777 let mut queue = match shared.queue.lock() {
12778 Ok(queue) => queue,
12779 Err(_) => return,
12780 };
12781 loop {
12782 let stopping = shared.state.lock().map(|state| state.stopping).unwrap_or(true);
12783 if stopping && queue.is_empty() {
12784 return;
12785 }
12786 if let Some(job) = queue.pop_front() {
12787 let mut jobs = vec![job];
12788 while jobs.len() < PROJECTION_COMMIT_BATCH {
12789 let Some(job) = queue.pop_front() else {
12790 break;
12791 };
12792 jobs.push(job);
12793 }
12794 if let Ok(mut state) = shared.state.lock() {
12795 state.queued_jobs = state.queued_jobs.saturating_sub(jobs.len());
12796 state.active_jobs = state.active_jobs.saturating_add(jobs.len());
12797 shared.state_cvar.notify_all();
12798 }
12799 break jobs;
12800 }
12801 queue = match shared.queue_cvar.wait(queue) {
12802 Ok(queue) => queue,
12803 Err(_) => return,
12804 };
12805 }
12806 };
12807
12808 // EU-5f — isolate worker faults. A panic inside `embed()` (or the
12809 // commit) must not skip the state cleanup below, or `active_jobs`
12810 // would stay elevated forever and `wait_for_idle` / `drain` would
12811 // wedge into `EngineError::Scheduler` (Finding A). Mirrors the
12812 // reader pool's `LiveGuard` panic-safety. The local commit tx rolls
12813 // back on unwind, leaving the connection clean for reuse.
12814 let commit_result = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
12815 run_projection_jobs(&shared, &mut connection, &jobs)
12816 })) {
12817 Ok(result) => result,
12818 Err(_) => commit_projection_panic_failures(&shared, &mut connection, &jobs),
12819 };
12820 if let Err(err) = commit_result {
12821 // Host subscribers are arbitrary application code. Their panic must
12822 // not bypass the mandatory state cleanup below, or the durable
12823 // pending row would stay stranded in `in_flight` forever.
12824 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
12825 report_projection_commit_failure(&shared, &err);
12826 }));
12827 #[cfg(debug_assertions)]
12828 if let Some((reported, release)) = shared
12829 .projection_commit_failure_pause
12830 .lock()
12831 .unwrap_or_else(|poisoned| poisoned.into_inner())
12832 .take()
12833 {
12834 reported.wait();
12835 release.wait();
12836 }
12837 }
12838
12839 if let Ok(mut state) = shared.state.lock() {
12840 state.active_jobs = state.active_jobs.saturating_sub(jobs.len());
12841 for job in &jobs {
12842 state.in_flight.remove(&job.cursor);
12843 }
12844 if !state.stopping {
12845 state.pending_scan = true;
12846 }
12847 shared.state_cvar.notify_all();
12848 }
12849 }
12850}
12851
12852enum ProjectionOutcome {
12853 /// `blob` is the un-centered f32 BLOB persisted to
12854 /// `vector_default.embedding`. `bin_blob` is the (possibly centered)
12855 /// f32 BLOB fed to `vec_quantize_binary` for the sign-bit column.
12856 /// EU-5a2: `bin_blob == blob` unless the identity is MC-required
12857 /// AND a mean_vec is pinned.
12858 Success {
12859 cursor: u64,
12860 kind: String,
12861 blob: Vec<u8>,
12862 bin_blob: Vec<u8>,
12863 },
12864 Failure {
12865 cursor: u64,
12866 failure_code: &'static str,
12867 },
12868 /// 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — the ENVIRONMENT could
12869 /// not serve this row, as distinct from the embed FAILING. Records nothing
12870 /// at all: no `projection_failures` audit row and, decisively, **no
12871 /// terminal**. The row stays `terminal IS NULL`, i.e. PENDING, so the next
12872 /// session that DOES have an embedder picks it up through the ordinary
12873 /// scheduler — no graft path and no recovery machinery.
12874 ///
12875 /// The only producer is the absent-embedder check at the top of
12876 /// [`run_projection_job`]. That condition cannot change within a session, so
12877 /// this can never become a retry loop for a genuinely-failing row.
12878 ///
12879 /// It carries NO cursor, deliberately: the other two variants carry one
12880 /// because they identify the row they are about to WRITE, and this variant
12881 /// writes nothing at all. A row's deferral is represented on disk by the
12882 /// continued ABSENCE of its `_fathomdb_projection_terminal` row, which is
12883 /// exactly the state it was already in.
12884 Deferred,
12885}
12886
12887fn run_projection_jobs(
12888 shared: &ProjectionRuntimeShared,
12889 connection: &mut Connection,
12890 jobs: &[ProjectionJob],
12891) -> rusqlite::Result<()> {
12892 let outcomes = embed_projection_batch(shared, jobs);
12893 commit_projection_outcomes(connection, &outcomes, shared)
12894}
12895
12896/// Embed a whole commit-batch in ONE `embed_batch` call (amortizes per-call
12897/// overhead; saturates the GPU — minutes -> seconds on a full-corpus embed). The
12898/// batched path is the fast HAPPY path only; on ANY anomaly — no embedder, breaker
12899/// open, single job, batch timeout/failure, row-count or per-row dimension mismatch
12900/// — it falls back to the proven per-job [`run_projection_job`], which carries the
12901/// full retry + circuit-breaker + failure-isolation semantics. So batching can only
12902/// make the common case faster, never change correctness. A panic inside the batch
12903/// embed resume-unwinds exactly like the per-embed watchdog, so the worker's
12904/// batch-level `catch_unwind` records `ProjectionPanic` as before.
12905///
12906/// Batching is **opt-in** via `FATHOMDB_PROJECTION_BATCH=1` (`true`/`on` accepted).
12907/// It reshapes the PR-9 per-embed watchdog/breaker accounting into per-batch, so the
12908/// conservative DEFAULT keeps the proven per-job path — leaving every PR-9 safety
12909/// test (watchdog, serialization, circuit breaker) behaving exactly as before. The
12910/// eval GPU-embed run sets the env to get the batched-forward speedup (minutes ->
12911/// seconds), where the per-job fallback below still backs every error case.
12912fn projection_batch_enabled() -> bool {
12913 matches!(
12914 std::env::var("FATHOMDB_PROJECTION_BATCH").ok().as_deref(),
12915 Some("1") | Some("true") | Some("on")
12916 )
12917}
12918
12919fn embed_projection_batch(
12920 shared: &ProjectionRuntimeShared,
12921 jobs: &[ProjectionJob],
12922) -> Vec<ProjectionOutcome> {
12923 let per_job = || jobs.iter().map(|job| run_projection_job(shared, job)).collect();
12924
12925 let Some(embedder) = shared.embedder.as_ref() else {
12926 return per_job();
12927 };
12928 if jobs.len() < 2
12929 || shared.embed_circuit_open.load(Ordering::Relaxed)
12930 || !projection_batch_enabled()
12931 {
12932 return per_job();
12933 }
12934
12935 let bodies: Vec<String> = jobs.iter().map(|job| job.body.clone()).collect();
12936 let embed_timeout = Duration::from_millis(shared.embed_timeout_ms.load(Ordering::Relaxed));
12937 // Each row keeps its single-embed budget worst-case (batch <= COMMIT_BATCH=16).
12938 let batch_timeout = embed_timeout.saturating_mul(jobs.len() as u32);
12939
12940 let vectors = {
12941 // PR-9 — serialize the embedder call (ONE batched call at a time) and make
12942 // the breaker decision with the guard held (race-free vs other workers),
12943 // mirroring `run_projection_job`. The batch thread counts as one live embed.
12944 let _embed_permit =
12945 shared.embed_serialize.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12946 let threshold = shared.embed_circuit_threshold.load(Ordering::Relaxed);
12947 if shared.embed_circuit_open.load(Ordering::Relaxed)
12948 || (threshold != 0 && shared.live_embed_threads.load(Ordering::Relaxed) >= threshold)
12949 {
12950 shared.embed_circuit_open.store(true, Ordering::Relaxed);
12951 return per_job();
12952 }
12953 match embed_batch_with_watchdog(
12954 embedder,
12955 &bodies,
12956 batch_timeout,
12957 &shared.live_embed_threads,
12958 ) {
12959 Ok(vectors) => vectors,
12960 // Timeout / failed / disconnected -> the per-job path retries each row
12961 // and engages the breaker exactly as before.
12962 Err(_) => return per_job(),
12963 }
12964 };
12965
12966 if vectors.len() != jobs.len() {
12967 return per_job();
12968 }
12969 let mut outcomes = Vec::with_capacity(jobs.len());
12970 for (job, vector) in jobs.iter().zip(vectors) {
12971 if u32::try_from(vector.len()).unwrap_or(u32::MAX) != shared.embedder_identity.dimension {
12972 // A row came back wrong-dim: fall back per-job for the whole batch
12973 // (rare; keeps the dimension-mismatch failure path identical).
12974 return per_job();
12975 }
12976 // Mirror run_projection_job's post-embed step exactly: persisted f32 BLOB is
12977 // un-centered; centering for the binary column is finalized in
12978 // commit_projection_outcomes (so bin_blob == blob here).
12979 let blob = encode_vector_blob(&vector);
12980 let bin_blob = blob.clone();
12981 outcomes.push(ProjectionOutcome::Success {
12982 cursor: job.cursor,
12983 kind: job.kind.clone(),
12984 blob,
12985 bin_blob,
12986 });
12987 }
12988 outcomes
12989}
12990
12991/// EU-5f — record every job in a panicked batch as a terminal projection
12992/// failure so the scheduler does not re-enqueue and re-panic on the same
12993/// cursors. Best-effort; runs after the worker caught a panic.
12994fn commit_projection_panic_failures(
12995 shared: &ProjectionRuntimeShared,
12996 connection: &mut Connection,
12997 jobs: &[ProjectionJob],
12998) -> rusqlite::Result<()> {
12999 let outcomes: Vec<ProjectionOutcome> = jobs
13000 .iter()
13001 .map(|job| ProjectionOutcome::Failure {
13002 cursor: job.cursor,
13003 failure_code: "ProjectionPanic",
13004 })
13005 .collect();
13006 commit_projection_outcomes(connection, &outcomes, shared)
13007}
13008
13009/// Route a background projection-commit failure through the engine's existing
13010/// host subscriber path. A SQLite error retains its stable SQLite code; a
13011/// rusqlite-layer error is an engine storage failure rather than a fabricated
13012/// SQLite diagnostic.
13013fn report_projection_commit_failure(shared: &ProjectionRuntimeShared, err: &rusqlite::Error) {
13014 let event = if let Some(code) = sqlite_extended_code_name(err) {
13015 lifecycle::Event {
13016 phase: lifecycle::Phase::Failed,
13017 source: lifecycle::EventSource::SqliteInternal,
13018 category: lifecycle::EventCategory::Error,
13019 code: Some(code),
13020 }
13021 } else {
13022 lifecycle::Event {
13023 phase: lifecycle::Phase::Failed,
13024 source: lifecycle::EventSource::Engine,
13025 category: lifecycle::EventCategory::Error,
13026 code: Some("StorageError"),
13027 }
13028 };
13029 shared.subscribers.dispatch(&event);
13030}
13031
13032/// PR-9 — ADR-0.6.0-embedder-protocol **Invariant 5**: run one `embed()`
13033/// under a per-call deadline. A hung (non-panicking) embed would otherwise
13034/// park a projection worker forever — the EU-5f `catch_unwind` only catches
13035/// *panics*. On timeout we return `RuntimeEmbedderError::Timeout`, which the
13036/// caller's existing retry/failure path already handles.
13037///
13038/// Cancellation follows Invariant 5 exactly: the embed runs on a detached
13039/// thread that is allowed to *finish + discard* its result — never aborted
13040/// mid-call (there is no safe thread-cancel API). The caller (the projection
13041/// worker) holds `embed_serialize` across this call, but DROPS it the moment
13042/// this returns — including on timeout — so the abandoned detached thread
13043/// runs lock-free and a hung embed can neither hold the serialization guard
13044/// forever nor deadlock the pool. (The commit happens later, outside this
13045/// call, under the separate `commit_gate`.)
13046///
13047/// Panic-transparent: if `embed()` panics, the panic payload is captured on
13048/// the watchdog thread and resumed on the worker thread, so the existing
13049/// batch-level `catch_unwind` records `ProjectionPanic` exactly as before.
13050///
13051/// `live` counts embed threads currently alive: incremented before the spawn
13052/// and decremented by the thread when it finishes (even if its result was
13053/// abandoned on timeout). The caller reads it to bound the abandoned-thread
13054/// leak via the circuit breaker.
13055fn embed_with_watchdog(
13056 embedder: &Arc<dyn Embedder>,
13057 body: &str,
13058 timeout: Duration,
13059 live: &Arc<AtomicU64>,
13060) -> Result<Vec<f32>, RuntimeEmbedderError> {
13061 let (tx, rx) = mpsc::channel();
13062 let embedder = Arc::clone(embedder);
13063 let body = body.to_string();
13064 // Count this embed thread as live before spawning; the thread decrements
13065 // when it finishes, whether or not its result is still wanted.
13066 live.fetch_add(1, Ordering::Relaxed);
13067 let live_thread = Arc::clone(live);
13068 thread::spawn(move || {
13069 let outcome =
13070 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| embedder.embed(&body)));
13071 // The receiver may already be gone (this call timed out): an async
13072 // channel send never blocks, and a send to a dropped receiver is a
13073 // no-op error we deliberately ignore — the result is discarded.
13074 let _ = tx.send(outcome);
13075 live_thread.fetch_sub(1, Ordering::Relaxed);
13076 });
13077 match rx.recv_timeout(timeout) {
13078 Ok(Ok(result)) => result,
13079 Ok(Err(panic_payload)) => std::panic::resume_unwind(panic_payload),
13080 Err(mpsc::RecvTimeoutError::Timeout) => Err(RuntimeEmbedderError::Timeout),
13081 // The watchdog thread dropped its sender without sending — should not
13082 // happen (panics are captured above), but treat as a failed embed so
13083 // the retry/failure path engages rather than silently succeeding.
13084 Err(mpsc::RecvTimeoutError::Disconnected) => Err(RuntimeEmbedderError::Failed {
13085 message: "embed watchdog thread dropped its result channel".to_string(),
13086 }),
13087 }
13088}
13089
13090/// Batch sibling of [`embed_with_watchdog`]: run ONE `embed_batch` on a detached,
13091/// timeout-bounded thread. Same Invariant-5 cancellation contract (the thread is
13092/// allowed to finish + discard on timeout, never aborted mid-call), same
13093/// panic-transparency (a panic is resumed on the caller so the worker's batch-level
13094/// `catch_unwind` records `ProjectionPanic`), same `live` accounting (one batch
13095/// thread = one live embed, bounding the abandoned-thread leak via the breaker).
13096fn embed_batch_with_watchdog(
13097 embedder: &Arc<dyn Embedder>,
13098 bodies: &[String],
13099 timeout: Duration,
13100 live: &Arc<AtomicU64>,
13101) -> Result<Vec<Vec<f32>>, RuntimeEmbedderError> {
13102 let (tx, rx) = mpsc::channel();
13103 let embedder = Arc::clone(embedder);
13104 let bodies = bodies.to_vec();
13105 live.fetch_add(1, Ordering::Relaxed);
13106 let live_thread = Arc::clone(live);
13107 thread::spawn(move || {
13108 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
13109 let refs: Vec<&str> = bodies.iter().map(String::as_str).collect();
13110 embedder.embed_batch(&refs)
13111 }));
13112 let _ = tx.send(outcome);
13113 live_thread.fetch_sub(1, Ordering::Relaxed);
13114 });
13115 match rx.recv_timeout(timeout) {
13116 Ok(Ok(result)) => result,
13117 Ok(Err(panic_payload)) => std::panic::resume_unwind(panic_payload),
13118 Err(mpsc::RecvTimeoutError::Timeout) => Err(RuntimeEmbedderError::Timeout),
13119 Err(mpsc::RecvTimeoutError::Disconnected) => Err(RuntimeEmbedderError::Failed {
13120 message: "embed batch watchdog thread dropped its result channel".to_string(),
13121 }),
13122 }
13123}
13124
13125fn run_projection_job(shared: &ProjectionRuntimeShared, job: &ProjectionJob) -> ProjectionOutcome {
13126 // 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — an ABSENT embedder is an
13127 // ENVIRONMENT fact, not an embed failure, and it CANNOT appear mid-job:
13128 // `ProjectionRuntimeShared::embedder` is fixed for the whole session. So for a
13129 // NODE row the whole retry ladder (0 + 1 + 4 + 16 s) can only reach a
13130 // conclusion that was already knowable at entry — answer it NOW, with the
13131 // NON-TERMINAL `Deferred`: no audit row, no terminal, and therefore a write
13132 // the next live-embedder session can still recover.
13133 //
13134 // That is codex's finding. With the kind already ENROLLED, the shipped
13135 // `'failed'` terminal was PERMANENT (nothing reopens one, and nothing should:
13136 // re-enqueueing one would loop a genuinely-failing row forever), so the write
13137 // was lost while `dense_readiness` read `ready`.
13138 //
13139 // `projection_dispatcher_loop` already declines to dispatch node jobs in a
13140 // no-embedder session — it must, or the still-pending row would be re-scanned
13141 // in a hot loop. This check is the LOCAL backstop for the same invariant:
13142 // whatever reaches a worker with no embedder must not be TERMINATED. Keeping
13143 // the invariant beside the code that would otherwise write the terminal is
13144 // what makes it hold if that dispatcher-side filter is ever loosened.
13145 //
13146 // EDGE rows deliberately fall THROUGH to the shipped path, ladder and all.
13147 // `'edge_fact'` is auto-registered by the edge write itself, UN-gated on the
13148 // embedder (`project_canonical_edge_row`, G11 — see the note in
13149 // `enrol_batch_vector_kinds`), so an edge body written with no embedder is
13150 // outstanding the moment it lands. Deferring it would leave `drain` and
13151 // `excise_source` returning `EngineError::Scheduler` on paths with nothing to
13152 // do with the dense arm (MEASURED: 4 shipped tests across
13153 // `tc31_source_id_on_every_hit`, `provenance_mandatory` and
13154 // `multidoc_extractor_provenance`). Making edges recoverable needs their
13155 // enrolment gated the way fix-2 gated node kinds — reported as OOS-13, and
13156 // outside codex's finding, which is the node path.
13157 //
13158 // Their LADDER is left alone for a second, separately MEASURED reason:
13159 // shortening it makes the worker's terminal-commit land while a caller's own
13160 // write is still open, which used to trip the governed write-race. Measured on
13161 // `consolidate_provider` under 6-way concurrency: 0/48 failures with the
13162 // ladder, 8/48 without. Left byte-for-byte as shipped; the ladder length is
13163 // reported as OOS-17 rather than newly exposed by a fix round.
13164 //
13165 // 0.8.20 Slice 21 (TC-57) — this note used to name that race
13166 // `SQLITE_BUSY_SNAPSHOT` and call it PRE-EXISTING. Both are corrected: the
13167 // characterized mechanism is plain `SQLITE_BUSY` (5) on a read→write lock
13168 // PROMOTION, with the busy handler invoked ZERO times (SQLite skips it for
13169 // deadlock avoidance), so no `busy_timeout` could absorb it;
13170 // `SQLITE_BUSY_SNAPSHOT` (517) is only a second, narrower exit of the same
13171 // shape. And the race is FIXED — `commit_batch` now takes `BEGIN IMMEDIATE`
13172 // (see the note there), so the governed path never promotes. The 0/48-vs-8/48
13173 // measurement above stands as the reason not to shorten the ladder, but it is
13174 // no longer load-bearing for correctness of the governed write path.
13175 if shared.embedder.is_none() && job.kind != EDGE_FACT_KIND {
13176 return ProjectionOutcome::Deferred;
13177 }
13178 // PR-9 — embed circuit breaker (see `embed_circuit_open`). Once abandoned
13179 // (timed-out) embed threads have piled up to the threshold the embedder is
13180 // treated as broken; fail subsequent jobs fast WITHOUT attempting an embed,
13181 // so a wedged embedder cannot keep leaking abandoned watchdog threads. This
13182 // entry check is the fast path; the latch decision itself is made under the
13183 // embed guard below (race-free against other workers).
13184 if shared.embed_circuit_open.load(Ordering::Relaxed) {
13185 return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: "EmbedderError" };
13186 }
13187 let delays = shared.retry_delays_ms.lock().map(|delays| delays.clone()).unwrap_or_default();
13188 let mut last_code = "EmbedderError";
13189 for (attempt, delay_ms) in std::iter::once(0_u64).chain(delays.iter().copied()).enumerate() {
13190 if attempt > 0 {
13191 if shared.state.lock().map(|state| state.stopping).unwrap_or(true) {
13192 return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code };
13193 }
13194 thread::sleep(Duration::from_millis(delay_ms));
13195 }
13196 // PR-9 — re-check the breaker on every attempt, not just at entry:
13197 // another worker (or an earlier attempt of this job) may have latched
13198 // it while we were sleeping between retries. Bail before spawning yet
13199 // another timeout-bound watchdog thread, so the abandoned-thread leak
13200 // stays bounded even on the multi-retry path.
13201 if shared.embed_circuit_open.load(Ordering::Relaxed) {
13202 return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code };
13203 }
13204 // PR-9 / ADR-0.6.0 Invariant 5 — every embed runs under the per-call
13205 // watchdog deadline so a hung embed surfaces Timeout instead of
13206 // parking this worker forever.
13207 let embed_timeout = Duration::from_millis(shared.embed_timeout_ms.load(Ordering::Relaxed));
13208 let vector = match shared.embedder.as_ref() {
13209 Some(embedder) => {
13210 // PR-9 — serialize the embed call engine-side (see
13211 // `embed_serialize`): the shared embedder is invoked one call
13212 // at a time, for SAFETY with arbitrary caller-supplied
13213 // embedders (throughput is ~neutral on the candle default).
13214 // The guard is held across the watchdog call and released
13215 // here, so commit/IO below stays parallel and a timed-out
13216 // embed frees it. The guard owns no data; a panic-resumed
13217 // embed poisons it, so we recover the inner guard rather than
13218 // wedge the whole pool.
13219 let _embed_permit =
13220 shared.embed_serialize.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13221 // PR-9 — breaker decision, made WITH the guard held so it is
13222 // race-free against other workers: if abandoned embed threads
13223 // from earlier timeouts have piled up to the threshold, latch
13224 // the breaker and fail fast WITHOUT spawning another one. The
13225 // live count is checked here (also covers a breaker latched by
13226 // another worker while we were queued on the lock), bounding
13227 // the abandoned-thread leak to ~threshold regardless of whether
13228 // the embedder hangs always or only intermittently.
13229 let threshold = shared.embed_circuit_threshold.load(Ordering::Relaxed);
13230 if shared.embed_circuit_open.load(Ordering::Relaxed)
13231 || (threshold != 0
13232 && shared.live_embed_threads.load(Ordering::Relaxed) >= threshold)
13233 {
13234 shared.embed_circuit_open.store(true, Ordering::Relaxed);
13235 return ProjectionOutcome::Failure {
13236 cursor: job.cursor,
13237 failure_code: last_code,
13238 };
13239 }
13240 match embed_with_watchdog(
13241 embedder,
13242 &job.body,
13243 embed_timeout,
13244 &shared.live_embed_threads,
13245 ) {
13246 Ok(vector) => vector,
13247 Err(RuntimeEmbedderError::Timeout) => {
13248 // The embed thread is now abandoned (still counted in
13249 // live_embed_threads until it returns); the breaker
13250 // check above caps how many can accumulate.
13251 last_code = "EmbedderError";
13252 continue;
13253 }
13254 Err(RuntimeEmbedderError::Failed { .. }) => {
13255 last_code = "EmbedderError";
13256 continue;
13257 }
13258 }
13259 }
13260 None => {
13261 last_code = "EmbedderNotConfiguredError";
13262 continue;
13263 }
13264 };
13265
13266 if u32::try_from(vector.len()).unwrap_or(u32::MAX) != shared.embedder_identity.dimension {
13267 last_code = "EmbedderDimensionMismatchError";
13268 continue;
13269 }
13270
13271 let blob = encode_vector_blob(&vector);
13272 // EU-5a2 mean-centering apply path (projection write side). The
13273 // f32 BLOB persisted is ALWAYS un-centered; `bin_blob` carries
13274 // the (possibly centered) f32 fed to `vec_quantize_binary`. The
13275 // centering decision is finalized in `commit_projection_outcomes`
13276 // where the writer connection is in-hand and the read of
13277 // `_fathomdb_embedder_profiles.mean_vec` is in the same tx as
13278 // the INSERT. NoopEmbedder (EU-5a2's only live identity) is not
13279 // MC-required, so `bin_blob == blob` throughout EU-5a2.
13280 let bin_blob = blob.clone();
13281 return ProjectionOutcome::Success {
13282 cursor: job.cursor,
13283 kind: job.kind.clone(),
13284 blob,
13285 bin_blob,
13286 };
13287 }
13288
13289 ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code }
13290}
13291
13292/// 0.8.20 Slice 20 fix-1 (codex §9 [P2]) — the ONE definition of "a canonical
13293/// EDGE row the vector pipeline still owes an embed for".
13294///
13295/// Two call sites must agree on this predicate and had drifted:
13296///
13297/// - [`next_pending_projection_jobs`] — the SCHEDULER, and therefore the
13298/// authority on what will actually be embedded. It joins
13299/// `_fathomdb_vector_kinds` on `'edge_fact'`, so an edge body is only ever
13300/// scheduled when that kind is registered.
13301/// - [`connection_has_pending_projection_work`] — the PROBE behind
13302/// `drain`/`wait_for_idle` and, since this slice, `dense_readiness`. It
13303/// omitted that join.
13304///
13305/// The consequence of the drift: a live edge body written while `edge_fact` was
13306/// not a registered vector kind (e.g. edges carried forward from before the G11
13307/// edge-vector pipeline, which is what auto-registers the kind) counted as
13308/// outstanding work the scheduler would NEVER take. `dense_readiness` reported
13309/// `embedding` forever and `drain` could never report idle — both the mirror
13310/// image of R-20-DR's property. Building both edge arms from this one fragment
13311/// makes a repeat drift unrepresentable.
13312///
13313/// Emits the `FROM`/`JOIN` clauses plus the shared `WHERE` predicates, with the
13314/// edge table aliased `ce` and the projection terminal aliased `pt`; `now_idx`
13315/// is the 1-based bind index of the `:now` seam that [`edge_validity_sql`]
13316/// consumes. Callers may append further `AND` predicates.
13317///
13318/// **The one predicate deliberately NOT shared** is the scheduler's
13319/// `write_cursor > :cursor` watermark filter, which the scheduler appends and
13320/// the probe must not: per the G11 (Slice 15) fix-1 note on the probe, the
13321/// probe has to see edge bodies left un-projected BELOW the watermark when the
13322/// engine closed mid-flight, or `drain` would report idle with edge vectors
13323/// still missing on reopen. That asymmetry is intentional and load-bearing; the
13324/// row-eligibility predicates above are not, and are shared.
13325fn pending_edge_projection_from_where(now_idx: usize) -> String {
13326 format!(
13327 "FROM canonical_edges ce
13328 JOIN _fathomdb_vector_kinds
13329 ON _fathomdb_vector_kinds.kind = 'edge_fact'
13330 LEFT JOIN _fathomdb_projection_terminal pt
13331 ON pt.write_cursor = ce.write_cursor
13332 WHERE ce.body IS NOT NULL
13333 AND ce.superseded_at IS NULL{}
13334 AND pt.write_cursor IS NULL",
13335 edge_validity_sql("ce", now_idx)
13336 )
13337}
13338
13339/// The SCHEDULER's scan: the next `max_jobs` pending projection jobs in
13340/// `write_cursor` order.
13341///
13342/// `dense_arm_live` is `ProjectionRuntimeShared::embedder.is_some()`, read once
13343/// per dispatcher because it is fixed for the session's lifetime.
13344///
13345/// # fix-5 (codex §9 round 4 [P1]) — why the node exclusion is IN the SQL
13346///
13347/// With no live embedder a NODE job can only come back
13348/// [`ProjectionOutcome::Deferred`], which by design records no terminal (fix-4),
13349/// so dispatching one would re-fetch the SAME cursor forever: a hot loop for the
13350/// whole life of the session. fix-4 suppressed that by filtering the vector this
13351/// function RETURNS — i.e. after the `ORDER BY … LIMIT`, so the `LIMIT` still
13352/// applied to the UNFILTERED set. More than `PROJECTION_SCAN_FETCH` pending node
13353/// rows ordered before a pending EDGE body therefore filled the entire window
13354/// with jobs that were then all dropped, the dispatcher went back to sleep with
13355/// `pending_scan` already consumed, and the edge body was never scheduled at all
13356/// — permanently, since those node rows stay pending for the session's life. The
13357/// exclusion belongs here, where the `LIMIT` applies to the ALREADY-FILTERED set
13358/// and a later edge job is always reachable.
13359///
13360/// Edges are NOT excluded: `'edge_fact'` is auto-registered by the edge write
13361/// itself, un-gated on the embedder (`project_canonical_edge_row`, G11, and the
13362/// note in [`Engine::enrol_batch_vector_kinds`]), so an edge body still
13363/// TERMINATES on an absent embedder exactly as it has shipped since G11. Making
13364/// edges recoverable too needs that enrolment gated first (OOS-13).
13365fn next_pending_projection_jobs(
13366 connection: &Connection,
13367 in_flight: &BTreeSet<u64>,
13368 max_jobs: usize,
13369 dense_arm_live: bool,
13370) -> rusqlite::Result<Vec<ProjectionJob>> {
13371 if max_jobs == 0 {
13372 return Ok(Vec::new());
13373 }
13374 let cursor = load_projection_cursor(connection)?;
13375 // Over-fetch by `in_flight.len()` so the post-filter still returns
13376 // up to `max_jobs` after skipping cursors already in-flight.
13377 let sql_limit = max_jobs.saturating_add(in_flight.len()).min(256);
13378 // G11 (Slice 15) — UNION extends the projection queue to include edge bodies.
13379 // Edge bodies use kind `'edge_fact'` so `resolve_source_type` maps them to
13380 // `source_type = 'edge_fact'` in `vector_default` (partition correctness).
13381 // The UNION is ordered by write_cursor so projection proceeds in
13382 // insertion order across nodes and edges.
13383 //
13384 // fix-5 [P1]: with no dense arm the NODE arm is omitted outright rather than
13385 // predicated false, so the planner never walks it. The edge arm keeps both
13386 // binds (`?1` the cursor, `?2` the `:now` seam), so the bound parameter set
13387 // is identical either way.
13388 let node_arm = if dense_arm_live {
13389 "SELECT canonical_nodes.write_cursor AS write_cursor,
13390 canonical_nodes.kind AS kind,
13391 canonical_nodes.body AS body
13392 FROM canonical_nodes
13393 JOIN _fathomdb_vector_kinds
13394 ON _fathomdb_vector_kinds.kind = canonical_nodes.kind
13395 LEFT JOIN _fathomdb_projection_terminal
13396 ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
13397 WHERE canonical_nodes.write_cursor > ?1
13398 AND _fathomdb_projection_terminal.write_cursor IS NULL
13399
13400 UNION ALL
13401
13402 "
13403 } else {
13404 ""
13405 };
13406 let sql = format!(
13407 "SELECT write_cursor, kind, body FROM (
13408 {node_arm}SELECT ce.write_cursor AS write_cursor,
13409 'edge_fact' AS kind,
13410 ce.body AS body
13411 {edge_arm}
13412 AND ce.write_cursor > ?1
13413 ) ORDER BY write_cursor
13414 LIMIT {sql_limit}",
13415 // fix-1 [P2]: the edge arm's row-eligibility predicates come from the
13416 // shared fragment so this and `connection_has_pending_projection_work`
13417 // cannot disagree about what is outstanding. The `write_cursor > ?1`
13418 // watermark is appended here and ONLY here — see the fragment's doc.
13419 // TC-33: `?1` is the projection cursor ⇒ the edge `:now` binds at `?2`.
13420 edge_arm = pending_edge_projection_from_where(2)
13421 );
13422 let mut statement = connection.prepare_cached(&sql)?;
13423 let rows = statement.query_map(params![cursor, current_epoch_seconds()], |row| {
13424 Ok(ProjectionJob { cursor: row.get(0)?, kind: row.get(1)?, body: row.get(2)? })
13425 })?;
13426 let mut jobs = Vec::with_capacity(max_jobs);
13427 for row in rows {
13428 let job = row?;
13429 if in_flight.contains(&job.cursor) {
13430 continue;
13431 }
13432 jobs.push(job);
13433 if jobs.len() >= max_jobs {
13434 break;
13435 }
13436 }
13437 Ok(jobs)
13438}
13439
13440fn database_has_pending_projection_work(path: &Path) -> rusqlite::Result<bool> {
13441 let connection = open_runtime_connection(path)?;
13442 connection_has_pending_projection_work(&connection)
13443}
13444
13445/// 0.8.20 Slice 20 (R-20-DR) — the body of
13446/// [`database_has_pending_projection_work`], lifted so it can also run on a
13447/// connection the caller ALREADY holds (the engine's own connection, inside
13448/// [`Engine::read_projections`]) instead of opening a runtime connection from a
13449/// path. Both callers run the same two arms and the same predicates — which is
13450/// the point. Readiness and `drain`/`wait_for_idle` must key off ONE definition
13451/// of "outstanding embed", or readiness could report `ready` for work `drain`
13452/// still waits on.
13453///
13454/// fix-1 (codex §9 [P2]) — the edge arm is no longer a hand-copied mirror of
13455/// the scheduler's: both are built from
13456/// [`pending_edge_projection_from_where`]. The copy had lost the
13457/// `_fathomdb_vector_kinds` join, so this probe reported permanent pending work
13458/// for edge bodies the scheduler would never schedule. That was PRE-EXISTING —
13459/// it reached `Engine::drain` through `wait_for_idle` before readiness existed.
13460fn connection_has_pending_projection_work(connection: &Connection) -> rusqlite::Result<bool> {
13461 let cursor = load_projection_cursor(connection)?;
13462 // Check canonical_nodes for un-projected work.
13463 let has_node_work: bool = connection
13464 .query_row(
13465 "SELECT 1
13466 FROM canonical_nodes
13467 JOIN _fathomdb_vector_kinds ON _fathomdb_vector_kinds.kind = canonical_nodes.kind
13468 LEFT JOIN _fathomdb_projection_terminal
13469 ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
13470 WHERE canonical_nodes.write_cursor > ?1
13471 AND _fathomdb_projection_terminal.write_cursor IS NULL
13472 LIMIT 1",
13473 [cursor],
13474 |_row| Ok(true),
13475 )
13476 .or_else(|err| match err {
13477 rusqlite::Error::QueryReturnedNoRows => Ok(false),
13478 _ => Err(err),
13479 })?;
13480 if has_node_work {
13481 return Ok(true);
13482 }
13483 // G11 (Slice 15) fix-1 [P2] — also check canonical_edges for edge bodies
13484 // that were not projected before the engine closed. Without this check,
13485 // drain() returns idle while edge vectors remain unembedded on reopen.
13486 // fix-31 [P2]: exclude superseded edges from the pending check so the
13487 // scheduler does not pick up stale tombstoned rows as projection work.
13488 // 0.8.12 Slice A (R-CON-2 named default-ON blocker; Slice-20 codex §9
13489 // [P2]) — also exclude t_invalid-excluded (recency-consolidated) edges,
13490 // mirroring `next_pending_projection_jobs`'s edge arm. Required: without
13491 // this mirror, a rebuild-truncated t_invalid edge that
13492 // `next_pending_projection_jobs` now correctly skips would never gain a
13493 // `_fathomdb_projection_terminal` row, so this probe would flag it as
13494 // phantom-pending forever and `drain()`/`wait_for_idle` would hang.
13495 // Slice-20 fix-1 [P2]: the mirror is now STRUCTURAL — the arm is built from
13496 // `pending_edge_projection_from_where`, the same fragment the scheduler
13497 // uses — because the hand-copied mirror had already lost the
13498 // `_fathomdb_vector_kinds` join and produced exactly the phantom-pending
13499 // hang described above for edge bodies under an unregistered `edge_fact`.
13500 connection
13501 .query_row(
13502 // TC-33: no other parameter here ⇒ the edge `:now` binds at `?1`.
13503 // No `write_cursor > cursor` filter — see the fragment's doc for
13504 // why the probe deliberately looks BELOW the watermark too.
13505 &format!("SELECT 1 {} LIMIT 1", pending_edge_projection_from_where(1)),
13506 params![current_epoch_seconds()],
13507 |_row| Ok(true),
13508 )
13509 .or_else(|err| match err {
13510 rusqlite::Error::QueryReturnedNoRows => Ok(false),
13511 _ => Err(err),
13512 })
13513}
13514
13515/// 0.8.20 Slice 20 (R-20-DR) — the `dense_readiness` of the `searchable→vector`
13516/// projection, DERIVED. There is no stored flag, no schema step
13517/// (`SCHEMA_VERSION` stays 24) and no `MIGRATIONS` change.
13518///
13519/// **Why derived is the design, not a shortcut.** §4.1 invariant 1 requires
13520/// `{ vector-insert ∧ dense_readiness := ready }` to be ONE transaction, with a
13521/// torn `ready`-without-vector FORBIDDEN. A stored flag is precisely the thing
13522/// that can tear. Deriving it makes the invariant true **by construction**:
13523/// readiness is a pure function of state that
13524/// [`commit_projection_outcomes`] already writes inside a single transaction —
13525/// the `vector_default` / `_fathomdb_vector_rows` INSERTs, the
13526/// `_fathomdb_projection_terminal` row ([`record_projection_terminal`]) and the
13527/// readiness watermark ([`advance_projection_cursor`], which only ever steps
13528/// over cursors that ALREADY hold a terminal) all commit together or not at all.
13529/// So `ready` cannot be observed before the vector is durable, and the only
13530/// reachable torn state is the tolerated one (`embedding` with the vector
13531/// absent — the dense arm simply reads as partial).
13532///
13533/// It reuses the EXACT predicate `drain`/`wait_for_idle` use
13534/// ([`connection_has_pending_projection_work`]), so "readiness is `ready`" and
13535/// "`drain` reports idle" cannot disagree.
13536///
13537/// **Scope note (honest boundary).** The predicate is corpus-wide, not
13538/// per-attribute, because Slice 15d persists the `searchable→vector` sub-object
13539/// but DEFERS building any per-attribute embedding (`ProjectionDelta::deferred`)
13540/// — every declared vector projection is served by the one engine vector
13541/// pipeline, so per-projection scoping has no distinct meaning yet. A stored
13542/// column would not have been more specific; it would only have been tearable.
13543/// When per-attribute embedding lands, this function is where the scoping goes.
13544///
13545/// **Failure boundary.** A row whose embed FAILED terminally records a `failed`
13546/// terminal (no vector row), so it stops being outstanding and readiness returns
13547/// to `ready`. That is the correct reading of a two-member vocabulary — the row
13548/// will never embed, so reporting `embedding` forever would be a lie — and
13549/// failures stay separately observable through the `projection_failures`
13550/// collection. It is the one case where a `ready` corpus can lack a vector row,
13551/// and it is NOT a torn write: no `up_to_date` terminal exists for it.
13552fn derive_dense_readiness(connection: &Connection) -> Result<DenseReadiness, EngineError> {
13553 if connection_has_pending_projection_work(connection).map_err(|_| EngineError::Storage)? {
13554 Ok(DenseReadiness::Embedding)
13555 } else {
13556 Ok(DenseReadiness::Ready)
13557 }
13558}
13559
13560struct CanonicalNodeRow {
13561 cursor: u64,
13562 kind: String,
13563 body: String,
13564 row_kind: RowKind,
13565 /// fix-2 [P2] — whether this row is in the attribute projection's row set
13566 /// (`state = 'active' AND superseded_at IS NULL`, the exact `backfill_attribute`
13567 /// predicate). A projector-replay rebuild uses this to gate the attribute
13568 /// projection so it does not re-surface a pending / superseded node's values.
13569 /// Node-FTS / vector shadows are rebuilt for every row (their stale versions
13570 /// are excluded by the read-side lifecycle join, unchanged from before).
13571 attr_projected: bool,
13572}
13573
13574/// 0.8.0 Slice 5 (G1) — re-tokenize `search_index` from the canonical source
13575/// rows after the step-11 tokenizer-default upgrade drops + recreates the FTS5
13576/// virtual table. Projection-only: it reads `canonical_nodes` (the source of
13577/// truth, untouched) and rewrites the FTS shadow; it performs **no**
13578/// source-record migration. Every canonical node already carries an FTS row at
13579/// write time (the projection-time INSERT is unconditional), so reinserting
13580/// every node exactly reproduces the prior index content under the new
13581/// tokenizer. Runs in a single transaction on the writer connection before
13582/// readers spawn.
13583///
13584/// Crash-retryable (fix-1): the reindex and its durable completion marker
13585/// (`SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY` in `_fathomdb_open_state`)
13586/// commit together in ONE `BEGIN IMMEDIATE…COMMIT`. A crash before the commit
13587/// rolls both back, leaving no marker; the next open re-runs. A crash after
13588/// the commit finds the marker present and skips. Idempotent.
13589fn reproject_search_index_after_tokenizer_upgrade(connection: &Connection) -> rusqlite::Result<()> {
13590 let rows = canonical_node_rows(connection)?;
13591 connection.execute_batch("BEGIN IMMEDIATE")?;
13592 let result = (|| {
13593 // 0.8.20 Slice 5a (R-20-E1) — registry-driven: re-tokenize EVERY
13594 // node-FTS projection, not just `search_index`. `search_index_v2` uses
13595 // the SAME tokenizer (`porter unicode61 remove_diacritics 2`), so it is
13596 // equally invalidated by a tokenizer-default upgrade; before this slice
13597 // it was neither cleared nor re-tokenized here. Edge FTS is out of scope
13598 // for this open-path repair (it postdates the step-11 upgrade and is
13599 // rebuilt by `rebuild_projections`).
13600 truncate_row_projections_in(connection, &[ProjectionClass::NodeFts])?;
13601 for row in &rows {
13602 project_canonical_node_row(
13603 connection,
13604 row.cursor,
13605 &row.kind,
13606 &row.body,
13607 row.row_kind,
13608 ProjectionPass::FtsOnly,
13609 // FtsOnly never touches the attribute store (predates step 24), so
13610 // `node_active` is inert here; forward the row's flag anyway (it is
13611 // the backfill's active-and-non-superseded predicate) so the field
13612 // has a reader in every build configuration.
13613 row.attr_projected,
13614 )?;
13615 }
13616 connection.execute(
13617 "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
13618 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
13619 params![SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY, "1"],
13620 )?;
13621 Ok(())
13622 })();
13623 match result {
13624 Ok(()) => connection.execute_batch("COMMIT"),
13625 Err(err) => {
13626 let _ = connection.execute_batch("ROLLBACK");
13627 Err(err)
13628 }
13629 }
13630}
13631
13632/// 0.8.0 Slice 5 (G1) fix-1 — has the post-tokenizer-upgrade re-tokenization
13633/// committed durably on this DB? Keys off the
13634/// `SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY` row written inside the reindex
13635/// transaction; its absence on a v11 DB means the reindex never committed
13636/// (fresh-after-step-11 or crash-in-window) and must (re-)run.
13637///
13638/// A MISSING `_fathomdb_open_state` table is reported as "complete" (skip the
13639/// reproject): that table is created by migration step 1, so its absence means
13640/// the DB never ran our migrations (e.g. a synthetic DB whose `user_version`
13641/// was stamped to 11 by hand, or a legacy/foreign shape). Such DBs are
13642/// rejected by the downstream embedder-identity/integrity probes; the reproject
13643/// must not run — and must not mask those errors — on them. On a genuinely
13644/// migrated DB the table always exists, so the crash-repair path is unaffected.
13645fn search_index_tokenizer_reproject_complete(connection: &Connection) -> rusqlite::Result<bool> {
13646 match connection.query_row(
13647 "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
13648 [SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY],
13649 |row| row.get::<_, String>(0),
13650 ) {
13651 Ok(value) => Ok(value == "1"),
13652 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
13653 Err(rusqlite::Error::SqliteFailure(_, Some(ref message)))
13654 if message.contains("no such table") =>
13655 {
13656 Ok(true)
13657 }
13658 Err(err) => Err(err),
13659 }
13660}
13661
13662/// 0.8.20 Slice 15c (TC-33) fix-6 — has the one-time edge-vector prune committed
13663/// durably on this DB? Keys off the [`EDGE_VECTOR_PRUNE_MARKER_KEY`] row written
13664/// inside the prune transaction; its absence means the prune never ran (a DB
13665/// upgraded before this fix shipped, or a crash between the step-23 commit and
13666/// the prune commit) and must (re-)run.
13667///
13668/// A MISSING `_fathomdb_open_state` table is reported as "complete" (skip the
13669/// prune) — that table is created by migration step 1, so its absence means the
13670/// DB never ran our migrations (a synthetic/foreign shape rejected downstream);
13671/// the prune must not run, and must not mask those errors, on it. Mirrors
13672/// [`search_index_tokenizer_reproject_complete`].
13673fn edge_vector_prune_complete(connection: &Connection) -> rusqlite::Result<bool> {
13674 match connection.query_row(
13675 "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
13676 [EDGE_VECTOR_PRUNE_MARKER_KEY],
13677 |row| row.get::<_, String>(0),
13678 ) {
13679 Ok(value) => Ok(value == "1"),
13680 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
13681 Err(rusqlite::Error::SqliteFailure(_, Some(ref message)))
13682 if message.contains("no such table") =>
13683 {
13684 Ok(true)
13685 }
13686 Err(err) => Err(err),
13687 }
13688}
13689
13690/// 0.8.20 Slice 15c (TC-33) fix-6 — delete every `vector_default` (vec0) row that
13691/// has NO `_fathomdb_vector_rows` sidecar entry, then record the durable
13692/// completion marker, all in one `BEGIN IMMEDIATE` transaction (crash-retryable:
13693/// a crash before COMMIT leaves no marker and the next open re-runs).
13694///
13695/// A vec0 row and its sidecar row are written and deleted TOGETHER (same
13696/// transaction) on every steady-state path, so a sidecar-less vec0 row is ONLY
13697/// ever produced by the step-23 recreate, which drops the edge rows and their
13698/// sidecar entries but cannot reach the engine-created vec0 table. So this
13699/// targets exactly the dropped edges' orphans and touches NOTHING on a healthy
13700/// corpus. Node vec0 rows keep their sidecar entry, so they are never pruned —
13701/// node recall is unaffected.
13702///
13703/// The orphans are gathered with plain scans (both proven vec0 forms — a full
13704/// `SELECT rowid FROM vector_default` and per-`rowid` `DELETE`) and diffed in
13705/// Rust, rather than relying on a compound `DELETE ... WHERE rowid NOT IN (...)`
13706/// over the virtual table.
13707fn prune_orphaned_edge_vectors(connection: &Connection) -> rusqlite::Result<()> {
13708 connection.execute_batch("BEGIN IMMEDIATE")?;
13709 let result = (|| {
13710 let sidecar: std::collections::HashSet<i64> = {
13711 let mut statement =
13712 connection.prepare("SELECT write_cursor FROM _fathomdb_vector_rows")?;
13713 let rows = statement.query_map([], |row| row.get::<_, i64>(0))?;
13714 let mut set = std::collections::HashSet::new();
13715 for r in rows {
13716 set.insert(r?);
13717 }
13718 set
13719 };
13720 let vec_rowids: Vec<i64> = {
13721 let mut statement = connection.prepare("SELECT rowid FROM vector_default")?;
13722 let rows = statement.query_map([], |row| row.get::<_, i64>(0))?;
13723 let mut out = Vec::new();
13724 for r in rows {
13725 out.push(r?);
13726 }
13727 out
13728 };
13729 for rowid in vec_rowids {
13730 if !sidecar.contains(&rowid) {
13731 // vec0 rowid IS the canonical write_cursor; delete by rowid (the
13732 // proven vec0 delete form, as `prune_edge_projection_shadows`),
13733 // through the one TC-76-safe vec0-delete primitive.
13734 delete_vector_partition_row(connection, rowid)?;
13735 }
13736 }
13737 connection.execute(
13738 "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
13739 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
13740 params![EDGE_VECTOR_PRUNE_MARKER_KEY, "1"],
13741 )?;
13742 Ok(())
13743 })();
13744 match result {
13745 Ok(()) => connection.execute_batch("COMMIT"),
13746 Err(err) => {
13747 let _ = connection.execute_batch("ROLLBACK");
13748 Err(err)
13749 }
13750 }
13751}
13752
13753fn canonical_node_rows(connection: &Connection) -> rusqlite::Result<Vec<CanonicalNodeRow>> {
13754 // fix-2 [P2] — also read `state` + `superseded_at` so a replay rebuild can gate
13755 // the attribute projection to the backfill's row set. `attr_projected` mirrors
13756 // the exact `backfill_attribute` predicate (`state = 'active' AND
13757 // superseded_at IS NULL`): a NULL/foreign state is NOT 'active' and so is
13758 // excluded, identical to the SQL equality.
13759 let mut statement = connection.prepare(
13760 "SELECT write_cursor, kind, body, row_kind, state, superseded_at \
13761 FROM canonical_nodes ORDER BY write_cursor",
13762 )?;
13763 let rows = statement.query_map([], |row| {
13764 let state: Option<String> = row.get::<_, Option<String>>(4)?;
13765 let superseded_at: Option<i64> = row.get::<_, Option<i64>>(5)?;
13766 Ok(CanonicalNodeRow {
13767 cursor: row.get::<_, u64>(0)?,
13768 kind: row.get::<_, String>(1)?,
13769 body: row.get::<_, String>(2)?,
13770 row_kind: row_kind_from_column(&row.get::<_, String>(3)?),
13771 attr_projected: state.as_deref() == Some("active") && superseded_at.is_none(),
13772 })
13773 })?;
13774 rows.collect()
13775}
13776
13777/// 0.8.20 Slice 5a — inverse of [`RowKind::as_str`] for the stored
13778/// `canonical_nodes.row_kind` column. An unrecognized spelling degrades to
13779/// `Leaf`, the column DEFAULT and the shape every pre-EXP-S row carries; that
13780/// keeps a projector replay behavior-identical to the pre-registry rebuild,
13781/// which ignored `row_kind` entirely.
13782fn row_kind_from_column(value: &str) -> RowKind {
13783 match value {
13784 "coverage" => RowKind::Coverage,
13785 "graph" => RowKind::Graph,
13786 _ => RowKind::Leaf,
13787 }
13788}
13789
13790#[cfg(feature = "operator")]
13791fn hex_encode(bytes: &[u8]) -> String {
13792 let mut out = String::with_capacity(bytes.len() * 2);
13793 for byte in bytes {
13794 out.push(hex_nibble(byte >> 4));
13795 out.push(hex_nibble(byte & 0x0f));
13796 }
13797 out
13798}
13799
13800#[cfg(feature = "operator")]
13801fn hex_nibble(value: u8) -> char {
13802 match value {
13803 0..=9 => (b'0' + value) as char,
13804 10..=15 => (b'a' + value - 10) as char,
13805 _ => unreachable!(),
13806 }
13807}
13808
13809#[cfg(feature = "operator")]
13810fn physical_section(connection: &Connection, full: bool) -> Section {
13811 let mut findings = Vec::new();
13812 if let Err(err) = connection.query_row("PRAGMA page_count", [], |row| row.get::<_, i64>(0)) {
13813 findings.push(Finding {
13814 code: "E_CORRUPT_HEADER",
13815 stage: "PhysicalProbe",
13816 locator: locator_from_rusqlite_error(&err),
13817 doc_anchor: "design/recovery.md#header-malformed",
13818 detail: format!("page_count probe failed: {err}"),
13819 });
13820 }
13821 if full {
13822 match collect_integrity_check_findings(connection) {
13823 Ok(rows) => findings.extend(rows),
13824 Err(err) => findings.push(Finding {
13825 code: "E_CORRUPT_INTEGRITY_CHECK",
13826 stage: "IntegrityCheck",
13827 locator: locator_from_rusqlite_error(&err),
13828 doc_anchor: "design/recovery.md#integrity-check-full-findings",
13829 detail: format!("PRAGMA integrity_check failed: {err}"),
13830 }),
13831 }
13832 }
13833 if findings.is_empty() {
13834 Section::Clean
13835 } else {
13836 Section::Findings(findings)
13837 }
13838}
13839
13840#[cfg(feature = "operator")]
13841fn logical_section(connection: &Connection) -> Section {
13842 let mut findings = Vec::new();
13843 if let Err(err) = connection.query_row("PRAGMA schema_version", [], |row| row.get::<_, i64>(0))
13844 {
13845 findings.push(Finding {
13846 code: "E_CORRUPT_SCHEMA",
13847 stage: "SchemaProbe",
13848 locator: locator_from_rusqlite_error(&err),
13849 doc_anchor: "design/recovery.md#schema-inconsistent",
13850 detail: format!("schema_version probe failed: {err}"),
13851 });
13852 }
13853 match connection.query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)) {
13854 Ok(0) => findings.push(Finding {
13855 code: "E_CORRUPT_SCHEMA",
13856 stage: "SchemaProbe",
13857 locator: CorruptionLocator::MigrationStep { from: 0, to: 0 },
13858 doc_anchor: "design/recovery.md#schema-inconsistent",
13859 detail: "user_version is zero".to_string(),
13860 }),
13861 Ok(_) => {}
13862 Err(err) => findings.push(Finding {
13863 code: "E_CORRUPT_SCHEMA",
13864 stage: "SchemaProbe",
13865 locator: locator_from_rusqlite_error(&err),
13866 doc_anchor: "design/recovery.md#schema-inconsistent",
13867 detail: format!("user_version probe failed: {err}"),
13868 }),
13869 }
13870 if findings.is_empty() {
13871 Section::Clean
13872 } else {
13873 Section::Findings(findings)
13874 }
13875}
13876
13877#[cfg(feature = "operator")]
13878fn semantic_section(connection: &Connection) -> Section {
13879 match load_default_profile(connection) {
13880 Ok(_) => Section::Clean,
13881 Err(rusqlite::Error::QueryReturnedNoRows) => Section::Findings(vec![Finding {
13882 code: "E_CORRUPT_EMBEDDER_IDENTITY",
13883 stage: "EmbedderIdentity",
13884 locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
13885 doc_anchor: "design/recovery.md#embedder-identity-drift",
13886 detail: "default embedder profile row is missing".to_string(),
13887 }]),
13888 Err(err) => Section::Findings(vec![Finding {
13889 code: "E_CORRUPT_EMBEDDER_IDENTITY",
13890 stage: "EmbedderIdentity",
13891 locator: locator_from_rusqlite_error(&err),
13892 doc_anchor: "design/recovery.md#embedder-identity-drift",
13893 detail: format!("default embedder profile probe failed: {err}"),
13894 }]),
13895 }
13896}
13897
13898#[cfg(feature = "operator")]
13899fn collect_integrity_check_findings(connection: &Connection) -> rusqlite::Result<Vec<Finding>> {
13900 let mut statement = connection.prepare("PRAGMA integrity_check")?;
13901 let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
13902 let mut findings = Vec::new();
13903 for row in rows {
13904 let message = row?;
13905 if message == "ok" {
13906 continue;
13907 }
13908 findings.push(Finding {
13909 code: "E_CORRUPT_INTEGRITY_CHECK",
13910 stage: "IntegrityCheck",
13911 locator: CorruptionLocator::OpaqueSqliteError {
13912 sqlite_extended_code: rusqlite::ffi::SQLITE_CORRUPT,
13913 },
13914 doc_anchor: "design/recovery.md#integrity-check-full-findings",
13915 detail: message,
13916 });
13917 }
13918 Ok(findings)
13919}
13920
13921#[cfg(feature = "operator")]
13922fn locator_from_rusqlite_error(err: &rusqlite::Error) -> CorruptionLocator {
13923 let extended = err.sqlite_error().map(|inner| inner.extended_code).unwrap_or(0);
13924 CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: extended }
13925}
13926
13927fn open_runtime_connection(path: &Path) -> rusqlite::Result<Connection> {
13928 let connection = Connection::open(path)?;
13929 connection.pragma_update(None, "journal_mode", "WAL")?;
13930 // OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — `secure_delete=ON` at
13931 // EVERY open. The projection/vector-rewrite runtime connection performs
13932 // DELETEs (shadow-table rewrites), so its freed pages must be scrubbed too;
13933 // setting the pragma only on the writer left a GDPR-erasure leak here.
13934 connection.pragma_update(None, "secure_delete", "ON")?;
13935 Ok(connection)
13936}
13937
13938fn load_projection_cursor(connection: &Connection) -> rusqlite::Result<u64> {
13939 connection
13940 .query_row(
13941 "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
13942 [PROJECTION_CURSOR_KEY],
13943 |row| row.get::<_, String>(0),
13944 )
13945 .map(|value| value.parse::<u64>().unwrap_or(0))
13946 .or_else(|err| match err {
13947 rusqlite::Error::QueryReturnedNoRows => Ok(0),
13948 _ => Err(err),
13949 })
13950}
13951
13952fn store_projection_cursor(connection: &Connection, cursor: u64) -> rusqlite::Result<()> {
13953 connection.execute(
13954 "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
13955 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
13956 params![PROJECTION_CURSOR_KEY, cursor.to_string()],
13957 )?;
13958 Ok(())
13959}
13960
13961fn record_projection_terminal(
13962 connection: &Connection,
13963 cursor: u64,
13964 state: &str,
13965) -> rusqlite::Result<()> {
13966 connection.execute(
13967 "INSERT OR IGNORE INTO _fathomdb_projection_terminal(write_cursor, state) VALUES(?1, ?2)",
13968 params![cursor, state],
13969 )?;
13970 Ok(())
13971}
13972
13973fn terminal_state_for_cursor(
13974 connection: &Connection,
13975 cursor: u64,
13976) -> rusqlite::Result<Option<String>> {
13977 connection
13978 .query_row(
13979 "SELECT state FROM _fathomdb_projection_terminal WHERE write_cursor = ?1",
13980 [cursor],
13981 |row| row.get::<_, String>(0),
13982 )
13983 .map(Some)
13984 .or_else(|err| match err {
13985 rusqlite::Error::QueryReturnedNoRows => Ok(None),
13986 _ => Err(err),
13987 })
13988}
13989
13990fn advance_projection_cursor(connection: &Connection) -> rusqlite::Result<u64> {
13991 let mut cursor = load_projection_cursor(connection)?;
13992 loop {
13993 let next = cursor.saturating_add(1);
13994 if terminal_state_for_cursor(connection, next)?.is_some() {
13995 cursor = next;
13996 } else {
13997 break;
13998 }
13999 }
14000 store_projection_cursor(connection, cursor)?;
14001 Ok(cursor)
14002}
14003
14004fn commit_projection_outcomes(
14005 connection: &mut Connection,
14006 outcomes: &[ProjectionOutcome],
14007 shared: &ProjectionRuntimeShared,
14008) -> rusqlite::Result<()> {
14009 let embedder_identity = &shared.embedder_identity;
14010 let mc = identity_requires_mean_centering(embedder_identity);
14011 // EU-5f — serialize the whole commit across workers so the at-pin
14012 // re-quantize sees a totally-ordered history (see `commit_gate`).
14013 let _gate = shared.commit_gate.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
14014 // Take the WAL write lock before the reads below. A deferred transaction
14015 // would need a read-to-write promotion while a concurrent Engine::write
14016 // holds its own immediate transaction, which SQLite rejects without
14017 // invoking the busy handler and forces the worker to recompute the batch.
14018 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
14019 // The accumulator is mutable process state coupled to this transaction.
14020 // Keep the shared value untouched while building a candidate so rollback
14021 // cannot count a vector or consume the pin threshold prematurely.
14022 let mut shared_accumulator =
14023 shared.mean_accumulator.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
14024 let mut candidate_accumulator = shared_accumulator.clone();
14025 // EU-5a2/EU-5f — the live pinned mean. Read once at the top; may pin
14026 // mid-batch (set to `Some` after a threshold-crossing row below).
14027 let mut current_mean: Option<Vec<f32>> = if mc {
14028 tx.query_row(
14029 "SELECT mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
14030 [],
14031 |row| row.get::<_, Option<Vec<u8>>>(0),
14032 )
14033 .ok()
14034 .flatten()
14035 .map(|bytes| decode_vector_blob(&bytes))
14036 } else {
14037 None
14038 };
14039 let mut staged_events: Vec<EmbedderEvent> = Vec::new();
14040 for outcome in outcomes {
14041 match outcome {
14042 ProjectionOutcome::Success { cursor, kind, blob, bin_blob } => {
14043 if terminal_state_for_cursor(&tx, *cursor)?.is_some() {
14044 continue;
14045 }
14046 // Build the threshold decision in the transaction-local
14047 // candidate. The shared accumulator changes only after commit.
14048 let pin_mean: Option<Vec<f32>> = if mc && current_mean.is_none() {
14049 match candidate_accumulator.as_mut() {
14050 Some(a) => {
14051 a.add(&decode_vector_blob(bin_blob));
14052 if a.count() >= MEAN_VEC_PIN_THRESHOLD {
14053 let mean = a.materialize();
14054 candidate_accumulator = None;
14055 Some(mean)
14056 } else {
14057 None
14058 }
14059 }
14060 None => None,
14061 }
14062 } else {
14063 None
14064 };
14065
14066 let source_type = resolve_source_type(kind).map_err(|_| {
14067 rusqlite::Error::SqliteFailure(
14068 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
14069 Some(format!("unknown kind for source_type mapping: {kind}")),
14070 )
14071 })?;
14072 let now_unix =
14073 SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
14074 as i64;
14075 tx.execute(
14076 "INSERT OR IGNORE INTO _fathomdb_vector_rows(rowid, kind, write_cursor) VALUES(?1, ?2, ?3)",
14077 params![cursor, kind, cursor],
14078 )?;
14079 // EU-5a2/EU-5f — sign-quant input is the mean-subtracted
14080 // vector iff a mean is live (`current_mean`); otherwise the
14081 // un-centered `bin_blob`. A row inserted just before the
14082 // crossing is centered retroactively by the re-quantize
14083 // pass below.
14084 let centered_blob: Vec<u8> = match ¤t_mean {
14085 Some(mean) if mean.len() * 4 == bin_blob.len() => {
14086 encode_vector_blob(&subtract_mean(&decode_vector_blob(bin_blob), mean))
14087 }
14088 _ => bin_blob.clone(),
14089 };
14090 // Slice 10 / G10 — `status` ships the empty-string sentinel (vec0
14091 // TEXT metadata is NOT NULL-able); no real population source yet
14092 // (reserved-gap candidate 13).
14093 //
14094 // 0.8.20 Slice 15e — when the live `vector_default` carries
14095 // `filterable` `attr_<hex>` columns, EVERY column must be bound
14096 // (vec0 rejects a partial-column INSERT). Bind each from the node
14097 // body's scalar extraction (or the `''` sentinel). The body is not
14098 // carried on the embed job, so it is read from `canonical_nodes` by
14099 // `write_cursor` (== rowid) — but ONLY when attr columns exist, so
14100 // the common no-filterable hot path stays byte-identical and does
14101 // NO extra lookup.
14102 if actual_vector_attr_columns(&tx)?.is_empty() {
14103 tx.execute(
14104 "INSERT OR IGNORE INTO vector_default(
14105 rowid, embedding, embedding_bin, source_type, kind, created_at, status
14106 ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, '')",
14107 params![cursor, blob, centered_blob, source_type, kind, now_unix],
14108 )?;
14109 } else {
14110 let body: String = tx
14111 .query_row(
14112 "SELECT body FROM canonical_nodes WHERE write_cursor = ?1 LIMIT 1",
14113 [*cursor as i64],
14114 |row| row.get(0),
14115 )
14116 .optional()?
14117 .unwrap_or_default();
14118 let (cols_sql, ph_sql, attr_vals) =
14119 vector_attr_insert_fragments(&tx, &body, 7)?;
14120 let sql = format!(
14121 "INSERT OR IGNORE INTO vector_default(
14122 rowid, embedding, embedding_bin, source_type, kind, created_at, status{cols_sql}
14123 ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, ''{ph_sql})"
14124 );
14125 let mut pv: Vec<rusqlite::types::Value> = vec![
14126 rusqlite::types::Value::Integer(*cursor as i64),
14127 rusqlite::types::Value::Blob(blob.clone()),
14128 rusqlite::types::Value::Blob(centered_blob.clone()),
14129 rusqlite::types::Value::Text(source_type.to_string()),
14130 rusqlite::types::Value::Text(kind.to_string()),
14131 rusqlite::types::Value::Integer(now_unix),
14132 ];
14133 pv.extend(attr_vals);
14134 tx.execute(&sql, rusqlite::params_from_iter(pv.iter()))?;
14135 }
14136 record_projection_terminal(&tx, *cursor, "up_to_date")?;
14137
14138 // EU-5f — this row crossed the threshold: pin the mean and
14139 // re-quantize every row written so far (incl. earlier rows
14140 // in this same tx, which are visible to the SELECT) within
14141 // the same transaction so the pin is atomic.
14142 if let Some(mean) = pin_mean {
14143 tx.execute(
14144 "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
14145 params![encode_vector_blob(&mean)],
14146 )?;
14147 let rows: Vec<(i64, Vec<u8>)> = {
14148 let mut statement = tx.prepare(
14149 "SELECT rowid, embedding FROM vector_default ORDER BY rowid",
14150 )?;
14151 let mapped = statement.query_map([], |row| {
14152 Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?))
14153 })?;
14154 let mut out = Vec::new();
14155 for r in mapped {
14156 out.push(r?);
14157 }
14158 out
14159 };
14160 let (doc_count, _) =
14161 run_pin_and_requantize_pass(&tx, &rows, &mean).map_err(|_| {
14162 rusqlite::Error::SqliteFailure(
14163 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
14164 Some("mean-centering re-quantize pass failed".to_string()),
14165 )
14166 })?;
14167 staged_events.push(EmbedderEvent::MeanVecPinned {
14168 dim: u32::try_from(mean.len()).unwrap_or(u32::MAX),
14169 doc_count,
14170 });
14171 current_mean = Some(mean);
14172 }
14173 }
14174 ProjectionOutcome::Failure { cursor, failure_code } => {
14175 if terminal_state_for_cursor(&tx, *cursor)?.is_some() {
14176 continue;
14177 }
14178 let existing: u64 = tx.query_row(
14179 "SELECT COUNT(*) FROM operational_mutations
14180 WHERE collection_name = 'projection_failures'
14181 AND json_extract(payload_json, '$.write_cursor') = ?1",
14182 [cursor],
14183 |row| row.get(0),
14184 )?;
14185 if existing == 0 {
14186 let payload = format!(
14187 r#"{{"write_cursor":{cursor},"failure_code":"{failure_code}","recorded_at":0}}"#
14188 );
14189 tx.execute(
14190 "INSERT INTO operational_mutations(
14191 collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
14192 ) VALUES('projection_failures', ?1, 'append', ?2, NULL, ?3)",
14193 params![cursor.to_string(), payload, cursor],
14194 )?;
14195 }
14196 record_projection_terminal(&tx, *cursor, "failed")?;
14197 }
14198 // 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — record NOTHING.
14199 //
14200 // No `projection_failures` audit row (an ABSENT embedder is an
14201 // environment fact, not an embed failure) and, decisively, no
14202 // terminal: the row keeps `terminal IS NULL`, so
14203 // `advance_projection_cursor` below cannot step over it, the shared
14204 // `connection_has_pending_projection_work` predicate still reports it
14205 // outstanding, and `derive_dense_readiness` therefore reads
14206 // `embedding`. That is the ONLY torn state
14207 // `dev/design/record-lifecycle-protocol/projection-registry-and-async-embed.md`
14208 // §4.1 invariant 1 tolerates; the alternative — the enqueue-side gate
14209 // — puts an `'up_to_date'` terminal on an ENROLLED row with no
14210 // vector, which is the torn `ready` that invariant calls FORBIDDEN.
14211 //
14212 // Q6a graceful-absent governs ROLE DECLARATION ("you declared a
14213 // projection I cannot build yet" -> defer + graft), i.e. the
14214 // NOT-yet-enrolled case fix-1/fix-2 handle. Once a kind IS enrolled,
14215 // §4.1 invariant 1 governs. (HITL ruling, 0.8.20 Slice 20c fix-4.)
14216 //
14217 // Consumer-visible consequence, accepted deliberately and pinned by
14218 // `slice20c_flush_barrier`: for the REST of that no-embedder session
14219 // `dense_readiness` stays `embedding` and `drain` burns its timeout
14220 // into `EngineError::Scheduler`. Loud and recoverable, rather than
14221 // silent and lost.
14222 ProjectionOutcome::Deferred => {}
14223 }
14224 }
14225 // 0.7.2 PR-2bc S2 — the AUTOMATIC in-ingest drift detector (EWMA recent
14226 // mean + cos-threshold + debounce + 200k cap + `MeanRecomputeDeferred`)
14227 // was CARVED OUT and DEFERRED to 0.8.x; its recall premise was refuted
14228 // (the mean is a non-lever) and the benefit is unmeasured. The mean is
14229 // refreshed only on demand via `Engine::recompute_mean` (the
14230 // `doctor recompute-mean` verb). See `dev/design/embedder.md` §0.3 and
14231 // `dev/plans/prompts/0.8.x-auto-mean-drift-DEFERRED.md`. Nothing here
14232 // mutates `mean_vec` after the initial pin.
14233
14234 advance_projection_cursor(&tx)?;
14235 #[cfg(debug_assertions)]
14236 match shared.force_projection_commit_failure.swap(0, Ordering::SeqCst) {
14237 1 => {
14238 return Err(rusqlite::Error::SqliteFailure(
14239 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
14240 Some("forced projection commit failure".to_string()),
14241 ));
14242 }
14243 2 => return Err(rusqlite::Error::InvalidQuery),
14244 _ => {}
14245 }
14246 tx.commit()?;
14247 // Commit and the accumulator transition become visible together. On every
14248 // earlier error the transaction and the local candidate drop, leaving the
14249 // runtime state exactly as it was before this attempt.
14250 *shared_accumulator = candidate_accumulator;
14251 // EU-5f — publish MeanVecPinned only after the pin tx is durable, so a
14252 // rolled-back pin never emits a spurious event.
14253 if !staged_events.is_empty() {
14254 if let Ok(mut events) = shared.pending_events.lock() {
14255 events.extend(staged_events);
14256 }
14257 }
14258 Ok(())
14259}
14260
14261/// EU-5f — open-time recovery pin (`dev/design/embedder.md` §0.3, Hazard 4).
14262/// Derives the corpus mean from the existing un-centered `vector_default`
14263/// rows, pins it, and re-quantizes every row, all in one transaction on the
14264/// single-threaded open connection (no workers running yet, so no gate is
14265/// needed). Called only when MC is required, no mean is pinned, and the row
14266/// count already meets the threshold.
14267fn recover_mean_vec_pin(
14268 connection: &mut Connection,
14269 identity: &EmbedderIdentity,
14270) -> Result<(), EngineError> {
14271 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
14272 recompute_mean_in_tx(&tx, identity)?;
14273 tx.commit().map_err(|_| EngineError::Storage)?;
14274 Ok(())
14275}
14276
14277/// 0.7.2 PR-2b — shared mean (re)compute core, run INSIDE the caller's
14278/// transaction. Derives the FULL-corpus mean from the un-centered
14279/// `vector_default.embedding` BLOBs, writes `mean_vec`, and re-quantizes
14280/// EVERY row via the existing [`run_pin_and_requantize_pass`] so no row is
14281/// left under a stale centering.
14282///
14283/// This generalizes the EU-5f open-time recovery pin: it has NO "no mean
14284/// pinned yet" guard, so it equally serves the FIRST pin (recovery) and a
14285/// REFRESH of an already-pinned mean (PR-2b drift / `doctor recompute-mean`).
14286/// The caller owns the transaction boundary, which is what makes a fault
14287/// between the `mean_vec` UPDATE and re-quantize completion roll back
14288/// wholesale (`dev/design/embedder.md` §0.5 atomicity). It does NOT publish
14289/// any event — that is the caller's job, strictly post-durable-commit.
14290fn recompute_mean_in_tx(
14291 tx: &rusqlite::Transaction<'_>,
14292 identity: &EmbedderIdentity,
14293) -> Result<MeanRecomputeReport, EngineError> {
14294 recompute_mean_in_tx_inner(tx, identity, false)
14295}
14296
14297/// 0.7.2 PR-2b — recompute core with an optional fault-injection point. The
14298/// `fail_after_mean_update` flag (debug builds only, set via a test seam)
14299/// errors AFTER the `mean_vec` UPDATE but BEFORE the re-quantize completes,
14300/// so the caller's tx rolls back the partial recentering.
14301fn recompute_mean_in_tx_inner(
14302 tx: &rusqlite::Transaction<'_>,
14303 identity: &EmbedderIdentity,
14304 fail_after_mean_update: bool,
14305) -> Result<MeanRecomputeReport, EngineError> {
14306 let started = Instant::now();
14307 let dim = identity.dimension as usize;
14308 // The previously-pinned mean (if any) is read first so we can report
14309 // the pre-recompute drift cosine.
14310 let old_mean = read_pinned_mean_vec(tx, identity.dimension)?;
14311 let rows: Vec<(i64, Vec<u8>)> = {
14312 let mut statement = tx
14313 .prepare("SELECT rowid, embedding FROM vector_default ORDER BY rowid")
14314 .map_err(|_| EngineError::Storage)?;
14315 let mapped = statement
14316 .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)))
14317 .map_err(|_| EngineError::Storage)?;
14318 let mut out = Vec::new();
14319 for r in mapped {
14320 out.push(r.map_err(|_| EngineError::Storage)?);
14321 }
14322 out
14323 };
14324 let mut accumulator = MeanAccumulator::new(dim);
14325 for (_rowid, blob) in &rows {
14326 if blob.len() != dim * 4 {
14327 return Err(EngineError::Storage);
14328 }
14329 accumulator.add(&decode_vector_blob(blob));
14330 }
14331 let old_doc_count = accumulator.count();
14332 let mean = accumulator.materialize();
14333 let drift_cos_before = match &old_mean {
14334 Some(old) => cosine_similarity(&mean, old),
14335 None => 1.0,
14336 };
14337 tx.execute(
14338 "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
14339 params![encode_vector_blob(&mean)],
14340 )
14341 .map_err(|_| EngineError::Storage)?;
14342 if fail_after_mean_update {
14343 // Injected fault: bail before re-quantizing so the caller's tx
14344 // rolls back the `mean_vec` UPDATE too (crash-atomicity proof).
14345 return Err(EngineError::Storage);
14346 }
14347 let (doc_count, _) = run_pin_and_requantize_pass(tx, &rows, &mean)?;
14348 Ok(MeanRecomputeReport {
14349 dim: u32::try_from(dim).unwrap_or(u32::MAX),
14350 old_doc_count,
14351 doc_count_requantized: doc_count,
14352 drift_cos_before,
14353 mean_was_pinned: old_mean.is_some(),
14354 elapsed_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
14355 })
14356}
14357
14358/// Cap sweep over the op-store mutation log: keeps the newest `cap` SWEEPABLE
14359/// rows, dropping the oldest by `id`.
14360///
14361/// **Pending-redaction exemption (0.8.20 Slice 5 fix-1).**
14362/// [`ERASURE_PENDING_REDACTION_COLLECTION`] is exempt on the same principle for a
14363/// stronger reason: that row is not a record of a discharged obligation but an
14364/// UNDISCHARGED one. Sweeping it would silently drop an erasure the engine still
14365/// owes, and the next retry would then report success with the leaked stable ids
14366/// still in the telemetry sink — exactly the R-20-E5 violation this mechanism
14367/// exists to prevent.
14368///
14369/// **Erasure-audit exemption (0.8.20 Slice 5b, design v5 §2 defect D-A;
14370/// HITL-ruled 2026-07-19: *"there must be an auditable record of deletion
14371/// event."*).** Rows in [`ERASURE_AUDIT_COLLECTIONS`] are excluded from BOTH the
14372/// count and the DELETE, and are therefore **never removed by retention
14373/// pressure**. Previously this swept `operational_mutations` cap-first,
14374/// oldest-`id`-first, with no collection filter — so the `excise_source_audit`
14375/// row proving an erasure occurred shared one retention pool with the very
14376/// payloads it must prove erased, and (being written before whatever workload
14377/// followed) was among the first evicted. Accountability is a distinct
14378/// obligation from erasure; a sweep must not silently discharge it.
14379///
14380/// Consequence of excluding audit rows from the count: `cap` is a cap on
14381/// SWEEPABLE rows, not on the physical table size. That is deliberate — the
14382/// alternative (counting exempt rows toward the cap) would let a growing audit
14383/// trail evict ordinary provenance ever more aggressively, and in the limit
14384/// leave nothing sweepable while the sweep churned every write.
14385fn enforce_provenance_retention(connection: &Connection, cap: u64) -> rusqlite::Result<()> {
14386 if cap == 0 {
14387 return Ok(());
14388 }
14389 // Static, engine-internal identifiers — no caller input reaches this SQL.
14390 let exempt = ERASURE_AUDIT_COLLECTIONS
14391 .iter()
14392 .copied()
14393 .chain(std::iter::once(ERASURE_PENDING_REDACTION_COLLECTION))
14394 .map(|name| format!("'{name}'"))
14395 .collect::<Vec<_>>()
14396 .join(", ");
14397 let slack = cap.max(20) / 20;
14398 let upper = cap.saturating_add(slack.max(1));
14399 let count: u64 = connection.query_row(
14400 &format!(
14401 "SELECT COUNT(*) FROM operational_mutations
14402 WHERE collection_name NOT IN ({exempt})"
14403 ),
14404 [],
14405 |row| row.get(0),
14406 )?;
14407 if count <= upper {
14408 return Ok(());
14409 }
14410 let to_delete = count.saturating_sub(cap);
14411 connection.execute(
14412 &format!(
14413 "DELETE FROM operational_mutations
14414 WHERE id IN (
14415 SELECT id FROM operational_mutations
14416 WHERE collection_name NOT IN ({exempt})
14417 ORDER BY id
14418 LIMIT ?1
14419 )"
14420 ),
14421 [to_delete],
14422 )?;
14423 Ok(())
14424}
14425
14426/// 0.8.20 Slice 5b (R-20-E6) — the prefixed stable ids
14427/// ([`IdSpace::to_prefixed`]) of the canonical rows an erasure verb is about to
14428/// delete, so they can be redacted from the telemetry sink.
14429///
14430/// Must be called INSIDE the erasing transaction and BEFORE the DELETEs — after
14431/// them the rows, and with them the `logical_id`/`body` the ids derive from, are
14432/// gone. Both queries take one bound parameter (`?1`), applied to nodes and
14433/// edges respectively; `derive_stable_id` reproduces exactly what
14434/// `capture_telemetry` wrote into `result_stable_ids`.
14435fn collect_erased_stable_ids(
14436 tx: &Connection,
14437 node_sql: &str,
14438 edge_sql: &str,
14439 bind: &str,
14440) -> Result<Vec<String>, EngineError> {
14441 let mut ids = Vec::new();
14442 for sql in [node_sql, edge_sql] {
14443 let mut stmt = tx.prepare(sql).map_err(|_| EngineError::Storage)?;
14444 let rows = stmt
14445 .query_map(params![bind], |row| {
14446 Ok((row.get::<_, Option<String>>(0)?, row.get::<_, Option<String>>(1)?))
14447 })
14448 .map_err(|_| EngineError::Storage)?;
14449 for row in rows {
14450 let (logical_id, body) = row.map_err(|_| EngineError::Storage)?;
14451 ids.push(
14452 derive_stable_id(logical_id.as_deref(), body.as_deref().unwrap_or(""))
14453 .to_prefixed(),
14454 );
14455 }
14456 }
14457 ids.sort_unstable();
14458 ids.dedup();
14459 Ok(ids)
14460}
14461
14462/// 0.8.20 Slice 5 fix-1 (codex §9 P2) — record, INSIDE the erasing transaction,
14463/// that a telemetry redaction is owed for `erased_stable_ids`.
14464///
14465/// Must be called in the same transaction as the DELETEs. That is the whole
14466/// point: "the rows are gone" and "a redaction is owed for them" then commit
14467/// atomically, so no crash or failure can leave the first true and the second
14468/// unrecorded. [`Engine::discharge_pending_redactions`] drains the queue and
14469/// deletes the entry only once the sink has actually been rewritten.
14470///
14471/// `record_key` is the VERB, never a stable id — the ids live in the payload,
14472/// which is deleted on discharge.
14473fn enqueue_pending_redaction(
14474 tx: &Connection,
14475 verb: &str,
14476 erased_stable_ids: &[String],
14477 write_cursor: u64,
14478) -> Result<(), EngineError> {
14479 if erased_stable_ids.is_empty() {
14480 return Ok(());
14481 }
14482 let payload =
14483 serde_json::json!({ "verb": verb, "erased_stable_ids": erased_stable_ids }).to_string();
14484 tx.execute(
14485 "INSERT INTO operational_mutations(
14486 collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
14487 ) VALUES(?1, ?2, 'append', ?3, NULL, ?4)",
14488 params![ERASURE_PENDING_REDACTION_COLLECTION, verb, payload, write_cursor],
14489 )
14490 .map_err(|_| EngineError::Storage)?;
14491 Ok(())
14492}
14493
14494/// 0.8.20 Slice 5b (R-20-E7) — the audit handle for an erased op-store record:
14495/// `SHA-256(collection + 0x1F + record_key)`, lowercase hex.
14496///
14497/// A record key is arbitrary caller-supplied text and may itself be the
14498/// identifier being erased, so a durable audit row must not echo it. `0x1F`
14499/// (ASCII unit separator) is the delimiter because it cannot appear in a
14500/// well-formed collection name, keeping the pairing unambiguous.
14501#[cfg(feature = "operator")]
14502fn digest_record_identity(collection: &str, record_key: &str) -> String {
14503 let mut hasher = Sha256::new();
14504 hasher.update(collection.as_bytes());
14505 hasher.update([0x1f_u8]);
14506 hasher.update(record_key.as_bytes());
14507 hasher.finalize().iter().map(|b| format!("{b:02x}")).collect()
14508}
14509
14510fn projection_status(
14511 connection: &Connection,
14512 kind: &str,
14513) -> Result<lifecycle::ProjectionStatus, EngineError> {
14514 let latest = connection
14515 .query_row(
14516 "SELECT COALESCE(MAX(write_cursor), 0) FROM canonical_nodes WHERE kind = ?1",
14517 [kind],
14518 |row| row.get::<_, u64>(0),
14519 )
14520 .map_err(|_| EngineError::Storage)?;
14521 if latest == 0 {
14522 return Ok(lifecycle::ProjectionStatus::UpToDate);
14523 }
14524 let pending: u64 = connection
14525 .query_row(
14526 "SELECT COUNT(*)
14527 FROM canonical_nodes
14528 LEFT JOIN _fathomdb_projection_terminal
14529 ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
14530 WHERE canonical_nodes.kind = ?1
14531 AND _fathomdb_projection_terminal.write_cursor IS NULL",
14532 [kind],
14533 |row| row.get(0),
14534 )
14535 .map_err(|_| EngineError::Storage)?;
14536 if pending > 0 {
14537 return Ok(lifecycle::ProjectionStatus::Pending);
14538 }
14539 match terminal_state_for_cursor(connection, latest).map_err(|_| EngineError::Storage)? {
14540 Some(state) if state == "failed" => Ok(lifecycle::ProjectionStatus::Failed),
14541 _ => Ok(lifecycle::ProjectionStatus::UpToDate),
14542 }
14543}
14544
14545fn canonical_database_path(path: &Path) -> Result<PathBuf, EngineOpenError> {
14546 let parent = path
14547 .parent()
14548 .filter(|parent| !parent.as_os_str().is_empty())
14549 .unwrap_or_else(|| Path::new("."));
14550 let canonical_parent = parent.canonicalize().map_err(|_| EngineOpenError::Io {
14551 message: "database parent directory is not accessible".to_string(),
14552 })?;
14553 let file_name = path.file_name().ok_or_else(|| EngineOpenError::Io {
14554 message: "database path has no file name".to_string(),
14555 })?;
14556
14557 Ok(canonical_parent.join(file_name))
14558}
14559
14560fn acquire_lock(path: &Path) -> Result<File, EngineOpenError> {
14561 let lock_path = lock_path(path);
14562 let mut options = OpenOptions::new();
14563 options.read(true).write(true).create(true);
14564 #[cfg(unix)]
14565 options.mode(0o600);
14566
14567 let mut file = options.open(&lock_path).map_err(|_| EngineOpenError::Io {
14568 message: "could not open database lock file".to_string(),
14569 })?;
14570
14571 match file.try_lock() {
14572 Ok(()) => {
14573 let pid = std::process::id().to_string();
14574 let _ = file.set_len(0);
14575 let _ = file.seek(SeekFrom::Start(0));
14576 let _ = file.write_all(pid.as_bytes());
14577 Ok(file)
14578 }
14579 Err(std::fs::TryLockError::WouldBlock) => {
14580 Err(EngineOpenError::DatabaseLocked { holder_pid: read_holder_pid(&lock_path) })
14581 }
14582 Err(_) => {
14583 Err(EngineOpenError::Io { message: "could not acquire database lock".to_string() })
14584 }
14585 }
14586}
14587
14588fn lock_path(path: &Path) -> PathBuf {
14589 let mut lock_path = path.as_os_str().to_os_string();
14590 lock_path.push(LOCK_SUFFIX);
14591 PathBuf::from(lock_path)
14592}
14593
14594fn read_holder_pid(path: &Path) -> Option<u32> {
14595 std::fs::read_to_string(path).ok()?.trim().parse().ok()
14596}
14597
14598fn map_migration_error(err: SchemaMigrationError) -> EngineOpenError {
14599 match err {
14600 SchemaMigrationError::IncompatibleSchemaVersion { seen, supported } => {
14601 EngineOpenError::IncompatibleSchemaVersion { seen, supported }
14602 }
14603 SchemaMigrationError::MigrationError(report) => EngineOpenError::MigrationError {
14604 schema_version_before: report.schema_version_before,
14605 schema_version_current: report.schema_version_current,
14606 step_id: report.migration_steps.last().map_or(0, |step| step.step_id),
14607 },
14608 SchemaMigrationError::Storage { message } => {
14609 EngineOpenError::Io { message: message.to_string() }
14610 }
14611 }
14612}
14613
14614/// 0.7.0 perf-experiments hook: process-start `sqlite3_config` calls.
14615/// Runs exactly once per process; must precede any `Connection::open`.
14616/// Gated on `FATHOMDB_PERF_EXPERIMENTS=1`. Each individual config
14617/// option is opt-in via its own env var so unrelated experiments do
14618/// not implicitly co-fire.
14619///
14620/// Currently supports:
14621/// - `FATHOMDB_PERF_SQLITE_MEMSTATUS_OFF=1`:
14622/// `sqlite3_config(SQLITE_CONFIG_MEMSTATUS, 0)` — drops the
14623/// allocator stats locking surface (whitepaper § 7.4). Composes
14624/// with other levers; small payoff alone.
14625///
14626/// Pattern: shutdown → config → initialize, mirroring B.1 attempt #2
14627/// (`d448263`, reverted). The captured rc for each config call is
14628/// logged to stderr so experiments can verify the call took effect.
14629fn init_perf_experiments_runtime() {
14630 static INIT: Once = Once::new();
14631 INIT.call_once(|| {
14632 if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
14633 return;
14634 }
14635 let memstatus_off =
14636 std::env::var_os("FATHOMDB_PERF_SQLITE_MEMSTATUS_OFF").is_some_and(|v| v == "1");
14637 // FATHOMDB_PERF_SQLITE_PAGECACHE=<page_size_bytes>:<page_count>
14638 // E.g. "4096:5000" => pre-allocate 4096 B × 5000 pages = 20 MB
14639 // global page-cache backing. SQLite distributes this across
14640 // connections; reduces global allocator pressure for page
14641 // cache fills.
14642 let pagecache = std::env::var("FATHOMDB_PERF_SQLITE_PAGECACHE").ok();
14643 // FATHOMDB_PERF_SQLITE_PCACHE2=1 installs the per-instance
14644 // custom page-cache allocator (pcache2.rs). Targets AC-020
14645 // residual contention on the default pcache1 mutex.
14646 let pcache2_on =
14647 std::env::var_os("FATHOMDB_PERF_SQLITE_PCACHE2").is_some_and(|v| v == "1");
14648 if !memstatus_off && pagecache.is_none() && !pcache2_on {
14649 return;
14650 }
14651 // SAFETY: sqlite3_shutdown / sqlite3_initialize are documented
14652 // as safe to call before any other SQLite API; sqlite3_config
14653 // must be called between shutdown and initialize. We pre-empt
14654 // rusqlite's lazy first-call sqlite3_initialize via this
14655 // explicit shutdown-then-config-then-initialize sequence,
14656 // identical to B.1 attempt #2's plumbing.
14657 unsafe {
14658 let rc_shutdown = rusqlite::ffi::sqlite3_shutdown();
14659 let rc_memstatus = if memstatus_off {
14660 rusqlite::ffi::sqlite3_config(rusqlite::ffi::SQLITE_CONFIG_MEMSTATUS, 0_i32)
14661 } else {
14662 -1
14663 };
14664 // SQLITE_CONFIG_PAGECACHE = 7 per sqlite3.h. With buffer=NULL,
14665 // SQLite allocates the backing memory itself but still
14666 // partitions it for use as the page-cache pool.
14667 let rc_pagecache = if let Some(spec) = pagecache.as_ref() {
14668 let mut parts = spec.split(':');
14669 let sz = parts.next().and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
14670 let n = parts.next().and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
14671 if sz > 0 && n > 0 {
14672 rusqlite::ffi::sqlite3_config(
14673 7, // SQLITE_CONFIG_PAGECACHE
14674 std::ptr::null_mut::<std::ffi::c_void>(),
14675 sz,
14676 n,
14677 )
14678 } else {
14679 eprintln!(
14680 "perf-experiment: bad FATHOMDB_PERF_SQLITE_PAGECACHE spec '{spec}' (expect '<bytes>:<count>')"
14681 );
14682 -1
14683 }
14684 } else {
14685 -1
14686 };
14687 let rc_pcache2 = if pcache2_on {
14688 // SQLITE_CONFIG_PCACHE2 = 18 per sqlite3.h. The methods
14689 // table must outlive the SQLite engine; we pass a
14690 // pointer to our static.
14691 rusqlite::ffi::sqlite3_config(
14692 rusqlite::ffi::SQLITE_CONFIG_PCACHE2,
14693 &raw const pcache2::PCACHE2_METHODS.0,
14694 )
14695 } else {
14696 -1
14697 };
14698 let rc_init = rusqlite::ffi::sqlite3_initialize();
14699 eprintln!(
14700 "perf-experiment: runtime-config rcs shutdown={rc_shutdown} \
14701 memstatus={rc_memstatus} pagecache={rc_pagecache} pcache2={rc_pcache2} \
14702 initialize={rc_init} (0=SQLITE_OK; 21=SQLITE_MISUSE; -1=not configured)"
14703 );
14704 }
14705 });
14706}
14707
14708fn register_sqlite_vec_extension() {
14709 static REGISTER: Once = Once::new();
14710 REGISTER.call_once(|| unsafe {
14711 let entrypoint: unsafe extern "C" fn(
14712 *mut rusqlite::ffi::sqlite3,
14713 *mut *const std::os::raw::c_char,
14714 *const rusqlite::ffi::sqlite3_api_routines,
14715 ) -> std::os::raw::c_int = std::mem::transmute(sqlite3_vec_init as *const ());
14716 rusqlite::ffi::sqlite3_auto_extension(Some(entrypoint));
14717 });
14718}
14719
14720fn probe_open_integrity(connection: &Connection) -> Result<(), EngineOpenError> {
14721 // `SELECT COUNT(*) FROM sqlite_schema` forces a full traversal of the
14722 // sqlite_schema b-tree; this surfaces page-1 b-tree corruption that a
14723 // bare `PRAGMA schema_version` (which only reads the schema cookie
14724 // out of the file header) would miss.
14725 connection
14726 .query_row("SELECT COUNT(*) FROM sqlite_schema", [], |row| row.get::<_, i64>(0))
14727 .map(|_| ())
14728 .map_err(|err| map_open_sqlite_error(err, OpenStage::SchemaProbe))
14729}
14730
14731fn probe_database_header(connection: &Connection) -> Result<(), EngineOpenError> {
14732 connection
14733 .query_row("PRAGMA application_id", [], |row| row.get::<_, i64>(0))
14734 .map(|_| ())
14735 .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))
14736}
14737
14738/// Pre-`pragma WAL` sidecar validation. SQLite silently discards a WAL
14739/// file whose header magic is wrong or whose advertised page size is
14740/// outside `[512, SQLITE_MAX_PAGE_SIZE]`, which would cause us to lose
14741/// committed frames at open time. AC-035a requires that we instead
14742/// refuse to open with `Corruption(WalReplayFailure)` rather than
14743/// silently rebuild from a truncated WAL.
14744fn probe_wal_sidecar(db_path: &Path) -> Result<(), EngineOpenError> {
14745 let mut wal_path = db_path.as_os_str().to_owned();
14746 wal_path.push("-wal");
14747 let wal_path = PathBuf::from(wal_path);
14748 // Bounded read: the WAL header is fixed-layout in the first 32
14749 // bytes (magic + format + page-size + checkpoint-seq + salts +
14750 // checksums); frame data starts at offset 32 and is irrelevant to
14751 // the magic + page-size pre-check. A `std::fs::read` of the whole
14752 // sidecar would force an unclean-shutdown open path to allocate
14753 // and copy the entire WAL into memory before SQLite touches
14754 // recovery — a real latency + RSS regression on AC-035.
14755 use std::io::Read;
14756 let mut file = match std::fs::File::open(&wal_path) {
14757 Ok(file) => file,
14758 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
14759 Err(_) => return Ok(()),
14760 };
14761 let mut bytes = [0u8; 32];
14762 if file.read_exact(&mut bytes).is_err() {
14763 // A short (< 32-byte) sidecar carries no committed frames;
14764 // SQLite treats it as empty and re-initializes WAL state.
14765 return Ok(());
14766 }
14767 let magic = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
14768 let page_size = u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
14769 // WAL_MAGIC mask per SQLite `walIndexRecover`: low bit distinguishes
14770 // big-endian vs little-endian checksum encoding; the rest of the
14771 // magic is fixed.
14772 const WAL_MAGIC_MASK: u32 = 0xFFFF_FFFE;
14773 const WAL_MAGIC: u32 = 0x377F_0682;
14774 const SQLITE_MAX_PAGE_SIZE: u32 = 65536;
14775 let magic_ok = (magic & WAL_MAGIC_MASK) == WAL_MAGIC;
14776 let page_size_ok =
14777 page_size.is_power_of_two() && (512..=SQLITE_MAX_PAGE_SIZE).contains(&page_size);
14778 if magic_ok && page_size_ok {
14779 return Ok(());
14780 }
14781 Err(EngineOpenError::Corruption(CorruptionDetail {
14782 kind: CorruptionKind::WalReplayFailure,
14783 stage: OpenStage::WalReplay,
14784 locator: CorruptionLocator::FileOffset { offset: if !magic_ok { 0 } else { 8 } },
14785 recovery_hint: RecoveryHint {
14786 code: "E_CORRUPT_WAL_REPLAY",
14787 doc_anchor: "design/recovery.md#wal-replay-failures",
14788 },
14789 }))
14790}
14791
14792fn reject_legacy_shape(connection: &Connection) -> Result<(), EngineOpenError> {
14793 let has_legacy_table = table_exists(connection, "fathom_nodes")
14794 || table_exists(connection, "fathom_edges")
14795 || table_exists(connection, "fathom_chunks");
14796 if !has_legacy_table {
14797 return Ok(());
14798 }
14799
14800 let seen =
14801 connection.query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)).unwrap_or(0);
14802 Err(EngineOpenError::IncompatibleSchemaVersion { seen, supported: SCHEMA_VERSION })
14803}
14804
14805fn table_exists(connection: &Connection, table: &str) -> bool {
14806 connection
14807 .query_row(
14808 "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?1",
14809 [table],
14810 |_row| Ok(()),
14811 )
14812 .is_ok()
14813}
14814
14815#[cfg(feature = "operator")]
14816fn read_schema_objects(
14817 connection: &Connection,
14818 obj_type: &str,
14819) -> Result<Vec<SchemaObject>, EngineError> {
14820 let mut stmt = connection
14821 .prepare(
14822 "SELECT name, sql FROM sqlite_schema
14823 WHERE type = ?1 AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL
14824 ORDER BY name",
14825 )
14826 .map_err(|_| EngineError::Storage)?;
14827 let rows = stmt
14828 .query_map([obj_type], |row| {
14829 Ok(SchemaObject { name: row.get::<_, String>(0)?, sql: row.get::<_, String>(1)? })
14830 })
14831 .map_err(|_| EngineError::Storage)?;
14832 let mut out = Vec::new();
14833 for row in rows {
14834 out.push(row.map_err(|_| EngineError::Storage)?);
14835 }
14836 Ok(out)
14837}
14838
14839#[cfg(feature = "operator")]
14840fn order_canonical_first(mut objects: Vec<SchemaObject>) -> Vec<SchemaObject> {
14841 let mut canonical: Vec<SchemaObject> = Vec::new();
14842 for name in CANONICAL_TABLES {
14843 if let Some(pos) = objects.iter().position(|o| o.name == *name) {
14844 canonical.push(objects.remove(pos));
14845 }
14846 }
14847 canonical.extend(objects);
14848 canonical
14849}
14850
14851fn load_default_profile(connection: &Connection) -> rusqlite::Result<EmbedderIdentity> {
14852 connection.query_row(
14853 "SELECT name, revision, dimension FROM _fathomdb_embedder_profiles WHERE profile = ?1",
14854 [DEFAULT_VECTOR_PROFILE],
14855 |row| {
14856 Ok(EmbedderIdentity::new(
14857 row.get::<_, String>(0)?,
14858 row.get::<_, String>(1)?,
14859 row.get::<_, u32>(2)?,
14860 ))
14861 },
14862 )
14863}
14864
14865fn default_profile_dimension(connection: &Connection) -> Result<u32, EngineError> {
14866 load_default_profile(connection)
14867 .map(|identity| identity.dimension)
14868 .map_err(|_| EngineError::Storage)
14869}
14870
14871fn kind_is_vector_indexed(connection: &Connection, kind: &str) -> Result<bool, EngineError> {
14872 connection
14873 .query_row("SELECT 1 FROM _fathomdb_vector_kinds WHERE kind = ?1", [kind], |_row| Ok(()))
14874 .map(|_| true)
14875 .or_else(|err| match err {
14876 rusqlite::Error::QueryReturnedNoRows => Ok(false),
14877 _ => Err(EngineError::Storage),
14878 })
14879}
14880
14881fn ensure_vector_partition(connection: &mut Connection, dimension: u32) -> rusqlite::Result<()> {
14882 // 0.7.0 Pack 1 schema per dev/design/0.7.0-vector-quant-pack1.md D1/D2:
14883 // f32 `embedding` + binary-quant sibling `embedding_bin` + `source_type`
14884 // partition key + `kind` + `created_at`. The vec0 column type is
14885 // dim-parameterized, so the reshape lives here rather than in the
14886 // SQL-only migration framework — see fathomdb-schema migration step 9
14887 // and dev/plans/runs/0.7.0-PVQ-P1-IMPL-output.json for the deviation
14888 // from the design memo's "Choose (a)" guidance.
14889 //
14890 // Three paths:
14891 // (1) no vector_default -> CREATE at new shape.
14892 // (2) old single-column shape -> stage + drop + recreate at new shape
14893 // + repopulate with vec_quantize_binary.
14894 // (3) already new shape -> no-op.
14895 let existing_sql: Option<String> = connection
14896 .query_row(
14897 "SELECT sql FROM sqlite_master WHERE type='table' AND name=?1",
14898 [DEFAULT_VECTOR_PARTITION],
14899 |row| row.get::<_, String>(0),
14900 )
14901 .optional()?;
14902
14903 // Slice 10 / G10 — 3-way shape-sentinel (fixes the prior
14904 // `contains("embedding_bin")` no-op that hid the `status` column from
14905 // existing Pack-1 DBs):
14906 // `status` present -> Pack-2 (current) shape, no-op.
14907 // `embedding_bin` present -> Pack-1 -> stage + recreate + back-fill status.
14908 // neither -> legacy single-column -> migrate to current.
14909 match existing_sql {
14910 None => create_vector_partition(connection, dimension),
14911 Some(sql) if sql.contains("status") => Ok(()),
14912 Some(sql) if sql.contains("embedding_bin") => {
14913 migrate_vector_partition_pack1_to_pack2(connection, dimension)
14914 }
14915 Some(_) => migrate_vector_partition_to_pack1(connection, dimension),
14916 }
14917}
14918
14919/// The current (Pack-2) `vector_default` vec0 shape. Slice 10 / G10 adds a plain
14920/// `status TEXT` metadata column — **not** aux (`+status`): aux columns
14921/// hard-error under a KNN `WHERE`, and the G10 filter constrains `status` in the
14922/// phase-1 KNN statement. `status` ships NULL plumbing only (no population source
14923/// yet).
14924///
14925/// 0.8.20 Slice 15e — `attr_cols` are the declared-`filterable` attribute columns
14926/// (byte-safe `attr_<hex>` identifiers, see [`attr_vec0_column`]), each a PLAIN
14927/// `TEXT` metadata column (never aux `+`), appended after `status`. **When
14928/// `attr_cols` is empty the produced SQL is byte-identical to the shipped shape**
14929/// — every existing caller passes `&[]`, so no shipped behaviour changes.
14930fn vector_partition_create_sql(
14931 dimension: u32,
14932 if_not_exists: bool,
14933 attr_cols: &[String],
14934) -> String {
14935 let guard = if if_not_exists { "IF NOT EXISTS " } else { "" };
14936 let mut attrs = String::new();
14937 for col in attr_cols {
14938 attrs.push_str(&format!(",{col} TEXT"));
14939 }
14940 format!(
14941 "CREATE VIRTUAL TABLE {guard}{DEFAULT_VECTOR_PARTITION} USING vec0(\
14942 embedding float[{dimension}],\
14943 embedding_bin bit[{dimension}],\
14944 source_type TEXT partition key,\
14945 kind TEXT,\
14946 created_at INTEGER,\
14947 status TEXT{attrs}\
14948 )"
14949 )
14950}
14951
14952fn create_vector_partition(connection: &Connection, dimension: u32) -> rusqlite::Result<()> {
14953 connection.execute_batch(&vector_partition_create_sql(dimension, true, &[]))
14954}
14955
14956/// 0.8.20 Slice 15e — encode an arbitrary registry attribute NAME into a vec0-safe
14957/// column identifier: `attr_` + lowercase hex of the name's UTF-8 bytes.
14958///
14959/// vec0 rejects quoted column identifiers, and a Slice-15d-validated attribute
14960/// name may contain spaces / unicode / `-`, so the raw name cannot be a column
14961/// identifier. Hex is injective (so the map is reversible by
14962/// [`decode_attr_vec0_column`]), matches `^attr_[0-9a-f]+$`, and can never collide
14963/// with a built-in metadata column (`embedding`, `embedding_bin`, `source_type`,
14964/// `kind`, `created_at`, `status` — none carry the `attr_` prefix followed by an
14965/// even-length hex string of the name).
14966fn attr_vec0_column(name: &str) -> String {
14967 let mut s = String::from("attr_");
14968 for b in name.as_bytes() {
14969 s.push_str(&format!("{b:02x}"));
14970 }
14971 s
14972}
14973
14974/// 0.8.20 Slice 15e — inverse of [`attr_vec0_column`]. Returns the original
14975/// attribute name for an `attr_<hex>` column, or `None` if `col` is not a
14976/// well-formed encoded attribute column (so the built-in metadata columns and any
14977/// vec0 shadow columns are skipped when enumerating a live table's attribute set).
14978fn decode_attr_vec0_column(col: &str) -> Option<String> {
14979 let hex = col.strip_prefix("attr_")?;
14980 if hex.is_empty() || hex.len() % 2 != 0 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
14981 return None;
14982 }
14983 let mut bytes = Vec::with_capacity(hex.len() / 2);
14984 let raw = hex.as_bytes();
14985 let mut i = 0;
14986 while i < raw.len() {
14987 let hi = (raw[i] as char).to_digit(16)?;
14988 let lo = (raw[i + 1] as char).to_digit(16)?;
14989 bytes.push((hi * 16 + lo) as u8);
14990 i += 2;
14991 }
14992 String::from_utf8(bytes).ok()
14993}
14994
14995/// 0.8.20 Slice 15e — the DESIRED `attr_<hex>` columns implied by the durable
14996/// projection registry: one per `filterable` projection, sorted by attribute name
14997/// (⇒ sorted by column, since hex encoding preserves byte order). This is the
14998/// derived-cache source the reshape reconciles the live vec0 shape against.
14999fn desired_vector_attr_columns(conn: &Connection) -> rusqlite::Result<Vec<String>> {
15000 let registry = load_projection_registry(conn)?;
15001 let mut cols: Vec<String> = registry
15002 .iter()
15003 .filter(|(_, stored)| stored.roles.contains(&ProjectionRole::Filterable))
15004 .map(|(name, _)| attr_vec0_column(name))
15005 .collect();
15006 cols.sort();
15007 Ok(cols)
15008}
15009
15010/// 0.8.20 Slice 15e — the `attr_<hex>` columns actually present on the live
15011/// `vector_default` vec0 table, parsed from its `CREATE VIRTUAL TABLE` SQL and
15012/// sorted. Empty when the table is absent. Parsing the SQL (rather than a PRAGMA)
15013/// keeps this robust across vec0 versions and shadow-table layouts.
15014fn actual_vector_attr_columns(conn: &Connection) -> rusqlite::Result<Vec<String>> {
15015 let sql: Option<String> = conn
15016 .query_row(
15017 "SELECT sql FROM sqlite_master WHERE type='table' AND name=?1",
15018 [DEFAULT_VECTOR_PARTITION],
15019 |row| row.get::<_, String>(0),
15020 )
15021 .optional()?;
15022 let Some(sql) = sql else {
15023 return Ok(Vec::new());
15024 };
15025 let mut cols: Vec<String> = Vec::new();
15026 // Tokenize on any non-identifier byte; a token is an attribute column iff it
15027 // decodes as a well-formed `attr_<hex>` identifier.
15028 let mut token = String::new();
15029 let flush = |token: &mut String, cols: &mut Vec<String>| {
15030 if !token.is_empty() {
15031 if decode_attr_vec0_column(token).is_some() && !cols.contains(token) {
15032 cols.push(token.clone());
15033 }
15034 token.clear();
15035 }
15036 };
15037 for ch in sql.chars() {
15038 if ch.is_ascii_alphanumeric() || ch == '_' {
15039 token.push(ch);
15040 } else {
15041 flush(&mut token, &mut cols);
15042 }
15043 }
15044 flush(&mut token, &mut cols);
15045 cols.sort();
15046 Ok(cols)
15047}
15048
15049/// TC-76 (sqlite-vec issue **#99**) — **the** way to delete ONE `vector_default`
15050/// row by rowid. Every by-rowid vec0 delete in this crate goes through here.
15051///
15052/// # The upstream defect this works around
15053///
15054/// A vec0 plain-TEXT metadata column stores a 16-byte inline view per row: a
15055/// 4-byte length plus the first 12 bytes of the value
15056/// (`VEC0_METADATA_TEXT_VIEW_DATA_LENGTH`). A value longer than those 12 bytes
15057/// ALSO gets a row in the `<tbl>_metadatatext<NN>` shadow table.
15058///
15059/// In `sqlite-vec` `=0.1.7`, `vec0Update_Delete_ClearMetadata`
15060/// (`sqlite-vec.c:8888`) deletes that shadow row (`sqlite-vec.c:8934-8952`) and
15061/// then returns `sqlite3_step`'s `SQLITE_DONE` (101) verbatim — it never resets
15062/// `rc` to `SQLITE_OK`. `vec0Update_Delete` (`sqlite-vec.c:9027`) reads
15063/// `101 != SQLITE_OK` as a failure and aborts the entire `DELETE`. The
15064/// INSERT/UPDATE twin of that code (`sqlite-vec.c:8258-8320`) is saved by an
15065/// unconditional `rc = sqlite3_blob_close(...)` after its switch, which is why
15066/// only DELETE carries the defect — and why the neutralizing `UPDATE` below is a
15067/// sound workaround rather than the same bug by another name.
15068///
15069/// # Why FathomDB is exposed
15070///
15071/// `vector_default` carries three plain-TEXT metadata columns
15072/// ([`vector_partition_create_sql`]): `kind`, `status` and one `attr_<hex>` per
15073/// declared `filterable` projection. Only the `attr_*` VALUES are caller-supplied
15074/// and unbounded, and Slice 15e stores them marker-encoded (`\x01 || V`), so a
15075/// raw attribute value of **12 or more UTF-8 bytes** trips #99 and makes
15076/// `erase_source` / `purge` / edge supersession / the open-path orphan sweep fail
15077/// with [`EngineError::Storage`], leaving the row AND its shadowed value at rest.
15078/// `kind` is bounded to `resolve_source_type`'s locked vocabulary (max 9 bytes)
15079/// at every enrolment door via [`kind_is_vector_committable`], and `status` ships
15080/// the `''` sentinel — so neither needs neutralizing, and neither is touched.
15081///
15082/// # The workaround
15083///
15084/// Blank the `attr_*` columns FIRST. vec0's UPDATE path takes the `n <= 12 &&
15085/// prev_n > 12` branch (`sqlite-vec.c:8300-8319`), which deletes the shadow row
15086/// AND returns `SQLITE_OK`, then the DELETE no longer has an over-length value to
15087/// clear. Measured: the `embedding` bytes and the other metadata columns survive
15088/// the metadata-only UPDATE verbatim, and the `_metadatatext<NN>` row is gone —
15089/// so this is erasure-COMPLETE, not merely error-suppressing.
15090///
15091/// Pinned by `tests/tc76_vec0_long_metadata_delete.rs`, whose bare-vec0 test is
15092/// the tripwire: it fails once `sqlite-vec` ships a fix for #99, which is the
15093/// signal to delete the neutralize step.
15094///
15095/// A no-op UPDATE (no `attr_*` columns declared — every corpus before a
15096/// `filterable` projection exists) is skipped entirely, so the shipped
15097/// no-projection path issues the same single `DELETE` it always did.
15098fn delete_vector_partition_row(conn: &Connection, rowid: i64) -> rusqlite::Result<usize> {
15099 neutralize_vector_partition_attr_values(conn, Some(rowid))?;
15100 conn.execute(&format!("DELETE FROM {DEFAULT_VECTOR_PARTITION} WHERE rowid = ?1"), [rowid])
15101}
15102
15103/// TC-76 (sqlite-vec **#99**) — blank every `attr_<hex>` value on `vector_default`
15104/// (one row when `rowid` is `Some`, the whole table when `None`) so that a
15105/// following `DELETE` never has an over-length TEXT metadata value to clear. See
15106/// [`delete_vector_partition_row`] for the mechanism and the evidence.
15107///
15108/// A pure no-op when no `filterable` projection is declared — which is every
15109/// corpus that predates Slice 15e — so the shipped delete paths issue exactly the
15110/// statements they always did.
15111fn neutralize_vector_partition_attr_values(
15112 conn: &Connection,
15113 rowid: Option<i64>,
15114) -> rusqlite::Result<()> {
15115 let attr_cols = actual_vector_attr_columns(conn)?;
15116 if attr_cols.is_empty() {
15117 return Ok(());
15118 }
15119 // `attr_cols` are `^attr_[0-9a-f]+$` identifiers derived by
15120 // `attr_vec0_column`, never caller text: safe to interpolate.
15121 let sets = attr_cols.iter().map(|c| format!("{c}=''")).collect::<Vec<_>>().join(", ");
15122 match rowid {
15123 Some(rowid) => {
15124 conn.execute(
15125 &format!("UPDATE {DEFAULT_VECTOR_PARTITION} SET {sets} WHERE rowid = ?1"),
15126 [rowid],
15127 )?;
15128 }
15129 None => {
15130 conn.execute(&format!("UPDATE {DEFAULT_VECTOR_PARTITION} SET {sets}"), [])?;
15131 }
15132 }
15133 Ok(())
15134}
15135
15136/// 0.8.20 Slice 15e — reconcile the live `vector_default` attribute columns with
15137/// the registry's `filterable` set (TC-46: HITL-ratified NON-DESTRUCTIVE reshape,
15138/// following the shipped `migrate_vector_partition_pack1_to_pack2` precedent).
15139///
15140/// Diffs the DESIRED columns (from the registry) against the ACTUAL columns (on
15141/// the live table). When they already match — which is EVERY idempotent
15142/// re-registration and every boot re-derive that replays the same set — this is a
15143/// pure no-op: no reshape, no re-insert, vec0 untouched (so boot never silently
15144/// wipes a corpus). When they differ, performs ONE non-destructive reshape.
15145///
15146/// Returns `true` iff a reshape was performed. Runs the DDL directly on the passed
15147/// connection/transaction (no nested transaction), so a caller already inside a
15148/// write transaction (`configure_projections`) gets the reshape atomically with
15149/// its registry mutation. A no-op (and returns `false`) when `vector_default` does
15150/// not exist (a DB opened without an embedder).
15151fn reconcile_vector_attr_columns(conn: &Connection, dimension: u32) -> rusqlite::Result<bool> {
15152 let table_exists: bool = conn
15153 .query_row(
15154 "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1",
15155 [DEFAULT_VECTOR_PARTITION],
15156 |_| Ok(true),
15157 )
15158 .optional()?
15159 .unwrap_or(false);
15160 if !table_exists {
15161 return Ok(false);
15162 }
15163 let desired = desired_vector_attr_columns(conn)?;
15164 let actual = actual_vector_attr_columns(conn)?;
15165 if desired == actual {
15166 return Ok(false);
15167 }
15168 reshape_vector_partition_nondestructive(conn, dimension, &desired, &actual)?;
15169 Ok(true)
15170}
15171
15172/// 0.8.20 Slice 15e — the NON-DESTRUCTIVE reshape itself. Stages every live row
15173/// (base columns + all ACTUAL attribute columns), drops + recreates
15174/// `vector_default` at the DESIRED shape, then re-inserts each row.
15175///
15176/// THE FOUR LOAD-BEARING CONDITIONS (any one broken ⇒ silently wrong results):
15177/// 1. `rowid` is listed EXPLICITLY in the re-insert (a vec0 row maps to its node
15178/// by `rowid == write_cursor`; auto-assigned rowids would decouple every
15179/// embedding from its node);
15180/// 2. each attribute column is PLAIN `TEXT` metadata (via
15181/// [`vector_partition_create_sql`]), never a vec0 `aux`/`+` column (aux
15182/// hard-errors a filtered KNN);
15183/// 3. a DESIRED column with no ACTUAL predecessor back-fills each row from the
15184/// already-populated `canonical_attributes` (fix-1 finding 2) so a
15185/// pre-existing row whose body carries the attribute is immediately
15186/// filterable; the `''` sentinel (vec0 TEXT metadata is NOT-NULL-able) is
15187/// used ONLY where the attribute is genuinely absent, so an absent row
15188/// cleanly fails-to-match instead of erroring;
15189/// 4. `embedding_bin` is copied VERBATIM via `vec_bit(...)` — NOT re-quantized —
15190/// so old rows keep their (possibly mean-centered) bits and stay Hamming-
15191/// comparable to new rows.
15192///
15193/// Runs on `conn` directly (the caller owns the transaction). Transactional
15194/// atomicity + reader isolation are the caller's responsibility, exactly as for
15195/// `migrate_vector_partition_pack1_to_pack2`.
15196fn reshape_vector_partition_nondestructive(
15197 conn: &Connection,
15198 dimension: u32,
15199 desired_cols: &[String],
15200 actual_cols: &[String],
15201) -> rusqlite::Result<()> {
15202 // Stage: base columns + every ACTUAL attribute column (so no at-rest value is
15203 // lost, even for a column being dropped).
15204 let mut stage_defs = String::new();
15205 let mut stage_names =
15206 String::from("rowid, embedding, embedding_bin, source_type, kind, created_at, status");
15207 for col in actual_cols {
15208 stage_defs.push_str(&format!(",\n {col} TEXT"));
15209 stage_names.push_str(&format!(", {col}"));
15210 }
15211 conn.execute_batch(&format!(
15212 "CREATE TABLE _fathomdb_vector_reshape_stage (
15213 rowid INTEGER PRIMARY KEY,
15214 embedding BLOB NOT NULL,
15215 embedding_bin BLOB NOT NULL,
15216 source_type TEXT,
15217 kind TEXT,
15218 created_at INTEGER,
15219 status TEXT{stage_defs}
15220 );
15221 INSERT INTO _fathomdb_vector_reshape_stage({stage_names})
15222 SELECT {stage_names} FROM {DEFAULT_VECTOR_PARTITION};
15223 DROP TABLE {DEFAULT_VECTOR_PARTITION};"
15224 ))?;
15225
15226 // Recreate at the DESIRED shape (plain TEXT attr columns — condition #2).
15227 conn.execute_batch(&vector_partition_create_sql(dimension, false, desired_cols))?;
15228
15229 // Re-insert. `rowid` explicit (condition #1); `vec_bit(embedding_bin)`
15230 // verbatim, never re-quantized (condition #4); `status` and each surviving
15231 // attribute column carried forward; each NEW desired column back-fills from
15232 // `canonical_attributes` (the `''` sentinel only where genuinely absent —
15233 // condition #3).
15234 let mut insert_cols =
15235 String::from("rowid, embedding, embedding_bin, source_type, kind, created_at, status");
15236 let mut select_exprs = String::from(
15237 "rowid, embedding, vec_bit(embedding_bin), source_type, kind, created_at, status",
15238 );
15239 for col in desired_cols {
15240 insert_cols.push_str(&format!(", {col}"));
15241 if actual_cols.iter().any(|a| a == col) {
15242 // Surviving column — carry its value forward (never NULL: vec0 TEXT
15243 // metadata is `''`-sentinelled, but COALESCE defends the stage table).
15244 select_exprs.push_str(&format!(", COALESCE({col}, '')"));
15245 } else {
15246 // New column — back-fill from the ALREADY-populated `canonical_attributes`
15247 // (fix-1 finding 2 [P2]). `configure_projections` runs `backfill_attribute`
15248 // (which fills `canonical_attributes` from each active row's body) BEFORE
15249 // this reshape, so a pre-existing row whose body carries the attribute is
15250 // immediately filterable — no false negative until a re-embed. The `''`
15251 // sentinel (condition #3) is used ONLY where the attribute is genuinely
15252 // ABSENT for that row (no `canonical_attributes` row ⇒ COALESCE → '').
15253 // The EAV value equals the vec0 write-time value by construction (both go
15254 // through `extract_scalar_attribute`), so pre-existing and freshly-written
15255 // rows share one filter semantics.
15256 match decode_attr_vec0_column(col) {
15257 Some(name) => {
15258 // vec0/execute_batch takes no bind params; embed the decoded name
15259 // as a SQL string literal, escaping single quotes.
15260 //
15261 // fix-3 [P2] — a PRESENT row (a canonical_attributes row exists)
15262 // encodes its RAW `attr_value` as `\x01 || attr_value` (`char(1) ||
15263 // ca.attr_value`), matching the write-time vec0 encoding so a
15264 // pre-existing present-empty row (attr_value='') becomes the bare
15265 // marker, NOT `''`. An ABSENT row (no canonical_attributes row) is
15266 // the COALESCE default `''` (condition #3). `canonical_attributes`
15267 // itself stays RAW — only this vec0 column is encoded.
15268 let escaped = name.replace('\'', "''");
15269 select_exprs.push_str(&format!(
15270 ", COALESCE((SELECT char(1) || ca.attr_value FROM canonical_attributes ca \
15271 WHERE ca.write_cursor = _fathomdb_vector_reshape_stage.rowid \
15272 AND ca.attr_name = '{escaped}' LIMIT 1), '')"
15273 ));
15274 }
15275 // A desired column always decodes (built by `attr_vec0_column`); if it
15276 // somehow does not, fall back to the sentinel rather than panic.
15277 None => select_exprs.push_str(", ''"),
15278 }
15279 }
15280 }
15281 conn.execute_batch(&format!(
15282 "INSERT INTO {DEFAULT_VECTOR_PARTITION}({insert_cols})
15283 SELECT {select_exprs} FROM _fathomdb_vector_reshape_stage;
15284 DROP TABLE _fathomdb_vector_reshape_stage;"
15285 ))?;
15286 Ok(())
15287}
15288
15289/// Slice 10 / G10 — stage + recreate + back-fill upgrade of an existing
15290/// **Pack-1** `vector_default` (has `embedding_bin`, lacks `status`) to the
15291/// Pack-2 shape. The existing `embedding_bin` blob is preserved verbatim (it may
15292/// be mean-centered; re-quantizing from `embedding` would drop the centering),
15293/// and `status` back-fills NULL. Same transactional discipline as
15294/// `migrate_vector_partition_to_pack1`: a single `Connection::transaction()`;
15295/// reader handles are not opened until `ensure_vector_partition` returns, and
15296/// cross-process access is serialized by the sidecar lock, so readers never see
15297/// a partial reshape.
15298fn migrate_vector_partition_pack1_to_pack2(
15299 connection: &mut Connection,
15300 dimension: u32,
15301) -> rusqlite::Result<()> {
15302 let tx = connection.transaction()?;
15303 tx.execute_batch(
15304 "CREATE TABLE _fathomdb_vector_pack2_stage (
15305 rowid INTEGER PRIMARY KEY,
15306 embedding BLOB NOT NULL,
15307 embedding_bin BLOB NOT NULL,
15308 source_type TEXT,
15309 kind TEXT,
15310 created_at INTEGER
15311 );
15312 INSERT INTO _fathomdb_vector_pack2_stage(
15313 rowid, embedding, embedding_bin, source_type, kind, created_at
15314 )
15315 SELECT rowid, embedding, embedding_bin, source_type, kind, created_at
15316 FROM vector_default;
15317 DROP TABLE vector_default;",
15318 )?;
15319 tx.execute_batch(&vector_partition_create_sql(dimension, false, &[]))?;
15320 // `vec_bit(...)` re-tags the staged blob with the BIT subtype vec0's bit
15321 // column requires (a raw blob loses the subtype and fails the type check).
15322 // This preserves the existing (possibly mean-centered) bits verbatim — no
15323 // re-quantize, so centering survives the upgrade. `status` back-fills the
15324 // empty-string sentinel (vec0 TEXT metadata is NOT NULL-able; reserved-gap
15325 // candidate 13).
15326 tx.execute_batch(
15327 "INSERT INTO vector_default(
15328 rowid, embedding, embedding_bin, source_type, kind, created_at, status
15329 )
15330 SELECT rowid, embedding, vec_bit(embedding_bin), source_type, kind, created_at, ''
15331 FROM _fathomdb_vector_pack2_stage;
15332 DROP TABLE _fathomdb_vector_pack2_stage;",
15333 )?;
15334 tx.commit()
15335}
15336
15337/// SQL fragment implementing the D3 `kind -> source_type` map.
15338/// Used both by the Pack 1 reshape migration and by the drift-detection
15339/// unit test that pins it to [`resolve_source_type`].
15340const KIND_TO_SOURCE_TYPE_CASE_SQL: &str = "CASE s.kind
15341 WHEN 'email' THEN 'email'
15342 WHEN 'article' THEN 'article'
15343 WHEN 'paper' THEN 'paper'
15344 WHEN 'meeting' THEN 'meeting'
15345 WHEN 'note' THEN 'note'
15346 WHEN 'todo' THEN 'todo'
15347 WHEN 'doc' THEN 'article'
15348 ELSE 'article'
15349END";
15350
15351/// Pack 1 in-place reshape of `vector_default`. Stages the existing
15352/// f32 corpus + each row's `kind`, drops the old single-column vec0
15353/// table, recreates at the runtime `dimension` with the Pack 1
15354/// columns, then repopulates with SQL-side `vec_quantize_binary` +
15355/// the D3 `kind -> source_type` mapping. The preflight CHECK on
15356/// unknown kinds has already run as migration step 9 by the time we
15357/// get here.
15358///
15359/// Atomicity: the DROP+CREATE+repopulate sequence runs inside a
15360/// rusqlite `Connection::transaction()` (DEFERRED begin per rusqlite
15361/// `transaction.rs:417`). Cross-process serialization is provided by
15362/// the engine's sidecar `acquire_lock` at `open_with_migrations`
15363/// (`lib.rs:1127` area); reader handles are not opened until
15364/// `ensure_vector_partition` returns (`lib.rs:1241` area), so readers
15365/// never observe a partial reshape.
15366fn migrate_vector_partition_to_pack1(
15367 connection: &mut Connection,
15368 dimension: u32,
15369) -> rusqlite::Result<()> {
15370 let tx = connection.transaction()?;
15371 tx.execute_batch(
15372 "CREATE TABLE _fathomdb_vector_migration_v0_7_0 (
15373 rowid INTEGER PRIMARY KEY,
15374 embedding BLOB NOT NULL,
15375 kind TEXT NOT NULL
15376 );
15377 INSERT INTO _fathomdb_vector_migration_v0_7_0(rowid, embedding, kind)
15378 SELECT v.rowid, v.embedding, r.kind
15379 FROM vector_default v
15380 JOIN _fathomdb_vector_rows r ON r.rowid = v.rowid;
15381 DROP TABLE vector_default;",
15382 )?;
15383 // Slice 10 / G10 — recreate directly at the Pack-2 shape (adds `status`), so
15384 // a legacy single-column DB lands the current shape in one reshape.
15385 tx.execute_batch(&vector_partition_create_sql(dimension, false, &[]))?;
15386 // `status` back-fills the empty-string sentinel (vec0 TEXT metadata is NOT
15387 // NULL-able; reserved-gap candidate 13). Legacy single-column DBs predate
15388 // mean-centering, so re-quantizing from the un-centered `embedding` is
15389 // correct here.
15390 let repopulate_sql = format!(
15391 "INSERT INTO vector_default(
15392 rowid, embedding, embedding_bin, source_type, kind, created_at, status
15393 )
15394 SELECT
15395 s.rowid,
15396 s.embedding,
15397 vec_quantize_binary(s.embedding),
15398 {KIND_TO_SOURCE_TYPE_CASE_SQL},
15399 s.kind,
15400 strftime('%s', 'now'),
15401 ''
15402 FROM _fathomdb_vector_migration_v0_7_0 s;
15403 DROP TABLE _fathomdb_vector_migration_v0_7_0;"
15404 );
15405 tx.execute_batch(&repopulate_sql)?;
15406 tx.commit()
15407}
15408
15409fn encode_vector_blob(vector: &[f32]) -> Vec<u8> {
15410 vector.iter().flat_map(|value| value.to_le_bytes()).collect()
15411}
15412
15413fn decode_vector_blob(bytes: &[u8]) -> Vec<f32> {
15414 debug_assert_eq!(bytes.len() % 4, 0, "f32 BLOB length must be multiple of 4");
15415 bytes.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()
15416}
15417
15418/// EU-5a2 — does the live embedder identity request mean-centering?
15419/// Identity-name compare per EU-5a1's BGE_SMALL_EMBEDDER_NAME constant
15420/// (`dev/design/embedder.md` §0.6). NoopEmbedder returns `false`.
15421fn identity_requires_mean_centering(identity: &EmbedderIdentity) -> bool {
15422 identity.name == BGE_SMALL_EMBEDDER_NAME
15423}
15424
15425/// EU-5a2 — read the pinned mean vector from
15426/// `_fathomdb_embedder_profiles.mean_vec` for the default profile.
15427/// Returns `Ok(None)` when the column is NULL or the row is missing;
15428/// returns `Err(EngineError::Storage)` on dimension drift (the open-time
15429/// `check_embedder_profile` already fails closed for this, so a runtime
15430/// drift here would be an internal-inconsistency signal).
15431fn read_pinned_mean_vec(
15432 connection: &Connection,
15433 dimension: u32,
15434) -> Result<Option<Vec<f32>>, EngineError> {
15435 let bytes: Option<Vec<u8>> = connection
15436 .query_row(
15437 "SELECT mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
15438 [],
15439 |row| row.get::<_, Option<Vec<u8>>>(0),
15440 )
15441 .or_else(|err| match err {
15442 rusqlite::Error::QueryReturnedNoRows => Ok(None),
15443 other => Err(other),
15444 })
15445 .map_err(|_| EngineError::Storage)?;
15446 let Some(bytes) = bytes else { return Ok(None) };
15447 let expected_len = (dimension as usize).saturating_mul(4);
15448 if bytes.len() != expected_len {
15449 return Err(EngineError::Storage);
15450 }
15451 let mut out = Vec::with_capacity(dimension as usize);
15452 for chunk in bytes.chunks_exact(4) {
15453 let arr = [chunk[0], chunk[1], chunk[2], chunk[3]];
15454 out.push(f32::from_le_bytes(arr));
15455 }
15456 Ok(Some(out))
15457}
15458
15459/// EU-5a2 — pointwise `v - mean`. Length-checked debug-assert; caller
15460/// guarantees equal length via `read_pinned_mean_vec` + dimension check.
15461fn subtract_mean(v: &[f32], mean: &[f32]) -> Vec<f32> {
15462 debug_assert_eq!(v.len(), mean.len(), "subtract_mean dim mismatch");
15463 v.iter().zip(mean.iter()).map(|(a, b)| *a - *b).collect()
15464}
15465
15466/// 0.8.18 Slice 5 (#5 vector-equivalence probe) — parse the committed 45-probe
15467/// fixture into an ordered `Vec<&str>` (one probe per non-empty, non-`#`-comment
15468/// line). Order is stable so `probe_ordinal` is deterministic across opens.
15469fn vector_equivalence_probes() -> Vec<&'static str> {
15470 VECTOR_EQUIVALENCE_PROBE_FIXTURE
15471 .lines()
15472 .map(str::trim_end)
15473 .filter(|line| {
15474 let t = line.trim_start();
15475 !t.is_empty() && !t.starts_with('#')
15476 })
15477 .collect()
15478}
15479
15480/// 0.8.18 Slice 5 — outcome of the open-time #5 self-check.
15481struct VectorEquivalenceOutcome {
15482 dense_disabled: bool,
15483 reason: Option<String>,
15484}
15485
15486/// 0.8.18 Slice 5 — embed one probe under panic isolation. The probe runs at
15487/// open time on the writer connection BEFORE the projection workers spawn, so a
15488/// caller-supplied embedder that PANICS (or returns an error / a wrong-dimension
15489/// vector) must never wedge `Engine::open`. A panic/error/shape-mismatch yields
15490/// `None`; the CALLERS then fail-SAFE (fix-1 DEFECT #1) — a `None` at population
15491/// or check time means the vector arm cannot be established/verified, so dense is
15492/// REFUSED (`dense_disabled=true`), never silently served. `Engine::open` still
15493/// succeeds (no wedge; ADR-0.6.0 Invariant-5 posture, mirrored open-side).
15494fn probe_embed(embedder: &dyn Embedder, text: &str, dimension: usize) -> Option<Vec<f32>> {
15495 let embedded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| embedder.embed(text)));
15496 match embedded {
15497 Ok(Ok(vector)) if vector.len() == dimension => Some(vector),
15498 _ => None,
15499 }
15500}
15501
15502/// 0.8.18 Slice 5 (#5 vector-equivalence probe KEYSTONE) — the open-time
15503/// self-check. Per `dev/design/0.8.18-slice-0-vector-equivalence-publish-design.md`
15504/// §U1 + `dev/adr/ADR-0.8.18-vector-equivalence-self-check.md`.
15505///
15506/// Runs AFTER open-time mean-recovery/requantize + `ensure_vector_partition`
15507/// (U1-b) so it reads the FINAL live `mean_vec`. Two paths:
15508///
15509/// - **First vector-kind registration** (probe table empty): re-embed the 45
15510/// committed probes with the LIVE embedder and persist their **UN-centered
15511/// f32 reference vectors** + embedder identity (R-VEQ-1). Store f32 ONLY —
15512/// the P1 bits are NEVER persisted (U1-d). Returns `dense_disabled=false`.
15513/// - **Subsequent open** (probe table populated): re-embed the 45 probes and
15514/// assert BOTH dense-pipeline representations against the stored references:
15515/// **(P1)** the Phase-1 mean-centered `embedding_bin` sign-flip count via the
15516/// SAME `vec_quantize_binary(sign(x − mean_vec))` path as
15517/// `build_vector_phase1_sql` (floor = 0, exact); **(P2)** the un-centered
15518/// Phase-2 L2 (`vec_distance_l2` semantics) within `VECTOR_EQUIVALENCE_L2_EPSILON`.
15519/// Divergence beyond EITHER floor ⇒ `dense_disabled=true` (R-VEQ-2/3).
15520///
15521/// Mean-centering is gated by `identity_requires_mean_centering(identity)` ∧
15522/// `mean_pinned`, applied symmetrically to reference + reembed (un-centered
15523/// fallback otherwise; NoopEmbedder no-op) — R-VEQ-3c.
15524///
15525/// Fail-SAFE, never fail-open (0.8.18 Slice 5 fix-1, DEFECT #1): any inability to
15526/// RUN or VERIFY the probe — a probe embed that panics/errors/returns wrong-dim, a
15527/// malformed/missing reference row, an unreadable pinned mean, or a
15528/// `vec_quantize_binary`/L2 SQL failure — yields `dense_disabled=true` with a clear
15529/// reason (refuse the un-verifiable dense/fused arm; the text-only/FTS path still
15530/// serves). `Engine::open` still SUCCEEDS (never wedges on a panicking caller
15531/// embedder). The distinct-identity cross-vendor refusal (`check_embedder_profile`)
15532/// remains the PRIMARY gate; this probe is ADDITIVE-ONLY (R-VEQ-5), but on the
15533/// vector arm it fails CLOSED, not open — same-identity backend drift on an
15534/// un-verifiable arm is exactly what #5 must catch (R-VEQ-4 "loud typed refuse,
15535/// never silent").
15536fn run_vector_equivalence_probe(
15537 connection: &Connection,
15538 embedder: Option<&dyn Embedder>,
15539 identity: &EmbedderIdentity,
15540 mean_pinned: bool,
15541) -> VectorEquivalenceOutcome {
15542 let not_disabled = VectorEquivalenceOutcome { dense_disabled: false, reason: None };
15543
15544 // No live embedder ⇒ no dense arm to guard (EmbedderChoice::None). The probe
15545 // is inert; dense writes/queries already fail with EmbedderNotConfigured.
15546 let Some(embedder) = embedder else { return not_disabled };
15547
15548 // Gate: the probe only engages once the workspace has REGISTERED a vector
15549 // kind (`_fathomdb_vector_kinds` non-empty). A fresh workspace that has never
15550 // committed to vector indexing has no dense arm to guard yet, so the probe
15551 // does ZERO embed work at that open — this keeps `Engine::open` free of the
15552 // 45-probe re-embed on empty/vector-less workspaces (and inert for the
15553 // pathological single-session hang/panic embedder tests, which register their
15554 // kind AFTER open and never reopen).
15555 //
15556 // fix-1 DEFECT #4 — the baseline is established at OPEN, at the first open
15557 // where a vector kind already exists (population path below). This covers BOTH:
15558 // (b) the v18→v19 UPGRADE with pre-existing vector kinds: the baseline is
15559 // captured here, at the first v19 open, from the identity-matched
15560 // embedder (identity is already gated by `check_embedder_profile`, so the
15561 // baseline is the same *claimed* embedder; future backend drift is caught);
15562 // (a) a vector kind registered POST-OPEN in a prior session: the baseline is
15563 // captured at the NEXT open (this gate + population), again identity-gated.
15564 // It is deliberately NOT captured in the registering session's write path: a
15565 // write must NEVER block on the embedder (the async-projection invariant —
15566 // `ac_029_canonical_writes_complete_under_projection_stall` and the PR-9 embed
15567 // watchdog/thread-leak bounds), and 45 synchronous probe embeds there would
15568 // violate it and hang/degrade under a stalling embedder. Serving vector queries
15569 // in the registering session is SAFE regardless: the serving backend IS the
15570 // backend that built those vectors, so there is nothing to diverge from. The
15571 // residual — a same-*identity* backend that drifted between the registering
15572 // session and the next open is not retroactively caught — is IDENTICAL to the
15573 // accepted upgrade residual (R-VEQ-5 additive-only; U3 same-identity candle
15574 // CPU↔CUDA = 0/17280). See `dev/design/0.8.18-slice-5-vector-equivalence-probe.md`.
15575 let vector_kind_registered: bool = connection
15576 .query_row("SELECT EXISTS(SELECT 1 FROM _fathomdb_vector_kinds)", [], |r| r.get(0))
15577 .unwrap_or(false);
15578 if !vector_kind_registered {
15579 return not_disabled;
15580 }
15581
15582 match probe_populate_or_check(connection, embedder, identity, mean_pinned) {
15583 Ok(()) => not_disabled,
15584 Err(reason) => VectorEquivalenceOutcome { dense_disabled: true, reason: Some(reason) },
15585 }
15586}
15587
15588/// 0.8.18 Slice 5 — either PERSIST the baseline (probe table empty) or CHECK
15589/// against it (probe table populated). `Err(reason)` ⇒ refuse the dense arm
15590/// (`dense_disabled=true`); `Ok(())` ⇒ dense served. Fail-SAFE throughout.
15591fn probe_populate_or_check(
15592 connection: &Connection,
15593 embedder: &dyn Embedder,
15594 identity: &EmbedderIdentity,
15595 mean_pinned: bool,
15596) -> Result<(), String> {
15597 let probes = vector_equivalence_probes();
15598 if probes.is_empty() {
15599 // Fail-SAFE: the compiled-in probe fixture is empty ⇒ nothing to verify
15600 // the vector arm against. (Defensive; the fixture is drift-guarded
15601 // non-empty at 45 probes.)
15602 return Err(
15603 "vector-equivalence probe fixture is empty; cannot verify the dense arm".to_string()
15604 );
15605 }
15606
15607 let existing: i64 = connection
15608 .query_row("SELECT COUNT(*) FROM _fathomdb_embed_probe", [], |r| r.get(0))
15609 .map_err(|e| format!("could not read the probe reference table: {e}; cannot verify"))?;
15610
15611 if existing == 0 {
15612 // Populate, then CONFIRM the just-written baseline is complete before
15613 // enabling dense (fix-2 DEFECT #1 residual): a population that committed
15614 // a short/garbled set must never leave dense enabled on the same open.
15615 probe_populate_baseline(connection, embedder, identity, &probes)?;
15616 probe_check_against_baseline(connection, embedder, identity, mean_pinned, &probes)
15617 } else {
15618 probe_check_against_baseline(connection, embedder, identity, mean_pinned, &probes)
15619 }
15620}
15621
15622/// 0.8.18 Slice 5 — FIRST vector-kind registration: persist the 45 UN-centered
15623/// f32 reference vectors (R-VEQ-1; store f32 ONLY, never the P1 bits — U1-d).
15624/// Fail-SAFE (fix-1 DEFECT #1): if the embedder cannot produce EVERY reference
15625/// (panic/error/wrong-dim) no baseline can be established ⇒ `Err` (refuse dense).
15626/// The inserts run in a single transaction so a partial/mismatched set is NEVER
15627/// persisted (rolled back on any error).
15628fn probe_populate_baseline(
15629 connection: &Connection,
15630 embedder: &dyn Embedder,
15631 identity: &EmbedderIdentity,
15632 probes: &[&str],
15633) -> Result<(), String> {
15634 let dimension = identity.dimension as usize;
15635 // Embed ALL probes first; a single failure aborts population (store nothing).
15636 let mut rows: Vec<(i64, &str, Vec<f32>)> = Vec::with_capacity(probes.len());
15637 for (ordinal, probe) in probes.iter().enumerate() {
15638 match probe_embed(embedder, probe, dimension) {
15639 Some(vec) => rows.push((ordinal as i64, probe, vec)),
15640 None => {
15641 return Err(format!(
15642 "embedder failed to produce a reference vector for probe {ordinal}; \
15643 cannot establish a vector-equivalence baseline (dense arm refused)"
15644 ));
15645 }
15646 }
15647 }
15648 // Atomic insert — a partial reference set is never persisted (rollback on
15649 // any error, so a later open cleanly retries population).
15650 let tx = connection
15651 .unchecked_transaction()
15652 .map_err(|e| format!("could not open the probe-baseline transaction: {e}"))?;
15653 for (ordinal, probe, vec) in &rows {
15654 let blob = encode_vector_blob(vec);
15655 tx.execute(
15656 "INSERT OR REPLACE INTO _fathomdb_embed_probe(
15657 probe_ordinal, probe_text, reference_vec,
15658 embedder_name, embedder_revision, dim
15659 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
15660 params![ordinal, probe, blob, identity.name, identity.revision, identity.dimension],
15661 )
15662 .map_err(|e| format!("could not persist the probe baseline: {e}"))?;
15663 }
15664 tx.commit().map_err(|e| format!("could not commit the probe baseline: {e}"))?;
15665 Ok(())
15666}
15667
15668/// 0.8.20 Slice 22 (TC-68) — one row of the stored probe baseline:
15669/// `(probe_ordinal, probe_text, reference_vec, embedder_name, embedder_revision, dim)`.
15670type StoredProbeRow = (i64, String, Vec<u8>, String, String, i64);
15671
15672/// 0.8.20 Slice 22 (TC-68) — length-prefixed field feed for the verdict
15673/// fingerprint. The `u64` length prefix makes the concatenation UNAMBIGUOUS: two
15674/// different input tuples can never produce the same byte stream by sliding a
15675/// delimiter (e.g. name `"ab"` + revision `"c"` vs name `"a"` + revision `"bc"`).
15676fn hash_fingerprint_field(hasher: &mut Sha256, bytes: &[u8]) {
15677 hasher.update((bytes.len() as u64).to_le_bytes());
15678 hasher.update(bytes);
15679}
15680
15681/// 0.8.20 Slice 22 (TC-68) — the **embedder-identity fingerprint** the cached
15682/// equivalence verdict is keyed on: a SHA-256 over EVERY input the probe's verdict
15683/// depends on. Two opens sharing a fingerprint would, by construction, compute the
15684/// same P1/P2 answer, so the second may reuse the first's.
15685///
15686/// The inputs, and why each is load-bearing:
15687///
15688/// - **the recipe tag** ([`VECTOR_EQUIVALENCE_FINGERPRINT_RECIPE`]) — bumping it
15689/// invalidates every cached verdict in the field at once;
15690/// - **`identity.{name, revision, dimension}`** — the nominal embedder. This is
15691/// *defence in depth only*: `check_embedder_profile` already REFUSES the open
15692/// with `EmbedderIdentityMismatch`/`EmbedderDimensionMismatch` before the probe
15693/// is reached, so an identity change is never observed here in practice;
15694/// - **the live pinned `mean_vec`** (and whether centering is applied at all) —
15695/// this one is NOT optional. P1 quantizes through
15696/// `vec_quantize_binary(sign(x − mean_vec))`, so rewriting the pinned mean
15697/// changes the verdict *for the same embedder and the same baseline*. A
15698/// fingerprint over the identity triple alone would be stale by construction,
15699/// because open-time mean-recovery/requantize and the operator `recompute_mean`
15700/// verb both rewrite it;
15701/// - **the committed probe fixture** — a cached verdict computed over a different
15702/// probe set means nothing. (`vector_equivalence_probe_fixture_drift.rs` does
15703/// NOT make this redundant: it pins the engine's copy equal to the embedder
15704/// crate's copy — it guards COPY drift between two committed files, not the
15705/// fixture's content across releases. The stored-baseline completeness check
15706/// below does fail closed first on a fixture edit, so this input is belt-and-
15707/// braces rather than the only guard; it is one hash of a ~3 KB constant.);
15708/// - **both D4 floors** — a verdict that passed under a loose ε must not be
15709/// inherited by a build that tightened it;
15710/// - **the STORED baseline rows, reference blobs included** — the 0.8.18 fix-2
15711/// completeness check pins each row's shape (count, ordinal, text, blob LENGTH,
15712/// identity) but not the blob CONTENT, which the re-embed comparison used to
15713/// catch. Hashing the blobs keeps that external-tamper closure intact at
15714/// negligible cost (~69 KB of SHA-256 against 45 model invocations).
15715fn probe_verification_fingerprint(
15716 identity: &EmbedderIdentity,
15717 mean_vec: Option<&[f32]>,
15718 stored: &[StoredProbeRow],
15719) -> String {
15720 let mut hasher = Sha256::new();
15721 hash_fingerprint_field(&mut hasher, VECTOR_EQUIVALENCE_FINGERPRINT_RECIPE.as_bytes());
15722 hash_fingerprint_field(&mut hasher, identity.name.as_bytes());
15723 hash_fingerprint_field(&mut hasher, identity.revision.as_bytes());
15724 hash_fingerprint_field(&mut hasher, &identity.dimension.to_le_bytes());
15725 match mean_vec {
15726 Some(mean) => {
15727 hash_fingerprint_field(&mut hasher, b"mean-centered");
15728 hash_fingerprint_field(&mut hasher, &encode_vector_blob(mean));
15729 }
15730 None => hash_fingerprint_field(&mut hasher, b"un-centered"),
15731 }
15732 hash_fingerprint_field(&mut hasher, VECTOR_EQUIVALENCE_PROBE_FIXTURE.as_bytes());
15733 hash_fingerprint_field(&mut hasher, &VECTOR_EQUIVALENCE_P1_FLIP_FLOOR.to_le_bytes());
15734 hash_fingerprint_field(&mut hasher, &VECTOR_EQUIVALENCE_L2_EPSILON.to_le_bytes());
15735 hash_fingerprint_field(&mut hasher, &(stored.len() as u64).to_le_bytes());
15736 for (ordinal, probe_text, reference_vec, name, revision, dim) in stored {
15737 hash_fingerprint_field(&mut hasher, &ordinal.to_le_bytes());
15738 hash_fingerprint_field(&mut hasher, probe_text.as_bytes());
15739 hash_fingerprint_field(&mut hasher, reference_vec);
15740 hash_fingerprint_field(&mut hasher, name.as_bytes());
15741 hash_fingerprint_field(&mut hasher, revision.as_bytes());
15742 hash_fingerprint_field(&mut hasher, &dim.to_le_bytes());
15743 }
15744 hasher.finalize().iter().map(|b| format!("{b:02x}")).collect()
15745}
15746
15747/// 0.8.20 Slice 22 (TC-68) — is `fingerprint` the fingerprint under which the
15748/// probe last RAN and PASSED on this workspace?
15749///
15750/// Fail-SAFE against ACCIDENT (R-VEQ-4): **every** failure mode answers `false`,
15751/// which means "run the probe". A missing `_fathomdb_open_state` table, an absent
15752/// row, a non-TEXT value, a truncated or garbled value, a stale fingerprint, any
15753/// SQL error — none of them can be mistaken for a pass.
15754///
15755/// # What a `true` does and does not mean (fix-1, codex §9 round 2 [P1])
15756///
15757/// `true` means: **the fingerprint inputs are unchanged since *some* engine
15758/// recorded a pass.** It does NOT mean "this engine verified this backend", and it
15759/// cannot: the fingerprint is a SHA-256 over deterministic, publicly derivable DB
15760/// and build inputs, so an actor with write access to the file can compute the
15761/// current digest and write it here, skipping the 45-probe verification. This
15762/// marker is not — and cannot be — an authenticated attestation; an embedded
15763/// local-first engine holds no secret with which to authenticate one, and a salt
15764/// would be readable by the same actor.
15765///
15766/// The same actor also defeats the same arm through the **pre-slice** path, by
15767/// re-baselining `_fathomdb_embed_probe`'s `reference_vec` blobs to their drifted
15768/// backend's own output — the probe then runs in full and verifies the drifted
15769/// backend against itself. Measured by
15770/// `tests/tc68_probe_fingerprint_cache.rs::a_forged_stored_baseline_defeats_the_probe_even_when_it_fully_runs`
15771/// (marker deleted, all 45 embeds performed, dense still enabled), with the
15772/// un-forged control caught.
15773///
15774/// **That is the same actor, NOT the same cost, and fix-2 struck the claim that it
15775/// was.** Forging this marker needs only a publicly computable digest — usually the
15776/// value already sitting in the row. Re-baselining additionally needs the target
15777/// backend's 45 exact embeddings, encoded into every row. **So the cache IS a
15778/// cheaper bypass** for a writer of the database file.
15779///
15780/// What bounds it is the ruled residual, not this marker. A same-identity backend
15781/// drift moves no fingerprint input, so a marker recorded by an **honest** earlier
15782/// open already skips the probe and already serves the drifted backend, with no
15783/// forgery anywhere
15784/// (`residual_same_identity_backend_drift_is_not_caught_on_a_cached_open`). Forgery
15785/// adds capability only on an open where no valid marker exists for the *current*
15786/// fingerprint — and a digest is valid only for the state it was computed over, so
15787/// it stops working at the next change to any fingerprint input.
15788///
15789/// The equivalence probe is a **correctness self-check against backend drift, not
15790/// tamper evidence**; `dense_disabled` is not a tamper signal. Threat model, with
15791/// the concession and the bound: §8.4/§8.5 of
15792/// `dev/design/0.8.20-tc68-equivalence-probe-fingerprint-cache.md`.
15793fn probe_verification_is_cached(connection: &Connection, fingerprint: &str) -> bool {
15794 connection
15795 .query_row(
15796 "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
15797 [VECTOR_EQUIVALENCE_VERDICT_CACHE_KEY],
15798 |row| row.get::<_, String>(0),
15799 )
15800 .map(|cached| cached == fingerprint)
15801 .unwrap_or(false)
15802}
15803
15804/// 0.8.20 Slice 22 (TC-68) — record that the probe RAN and PASSED under
15805/// `fingerprint`.
15806///
15807/// A write failure is deliberately SWALLOWED rather than turned into a verdict
15808/// failure. The arm has just been verified, so refusing dense because a marker
15809/// could not be persisted (read-only file, disk full) would be a false refusal;
15810/// and the consequence of the missing marker is simply that the next open re-runs
15811/// the probe — more work, never less. That is the fail-safe direction.
15812fn record_probe_verification(connection: &Connection, fingerprint: &str) {
15813 let _ = connection.execute(
15814 "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
15815 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
15816 params![VECTOR_EQUIVALENCE_VERDICT_CACHE_KEY, fingerprint],
15817 );
15818}
15819
15820/// 0.8.20 Slice 22 (TC-68) — drop any cached verdict.
15821///
15822/// Called on EVERY failure path of the check, so a workspace that could not be
15823/// verified never carries a marker a later open might match. A failing verdict is
15824/// therefore never cached: the probe re-runs each open until it passes again.
15825fn clear_probe_verification(connection: &Connection) {
15826 let _ = connection.execute(
15827 "DELETE FROM _fathomdb_open_state WHERE key = ?1",
15828 [VECTOR_EQUIVALENCE_VERDICT_CACHE_KEY],
15829 );
15830}
15831
15832/// 0.8.18 Slice 5 — SUBSEQUENT open: re-embed the 45 probes and assert BOTH
15833/// dense-pipeline representations against the stored references — **(P1)** the
15834/// mean-centered `embedding_bin` sign-flip count (floor 0, exact) and **(P2)** the
15835/// un-centered Phase-2 L2 (within `VECTOR_EQUIVALENCE_L2_EPSILON`).
15836///
15837/// 0.8.20 Slice 22 (TC-68) — this wrapper adds the failure half of the verdict
15838/// cache: ANY `Err` from the inner check drops the cached marker, so a workspace
15839/// that could not be verified never leaves a stale "verified" marker behind for a
15840/// later open to match. (A failing verdict is never *written*; this also clears a
15841/// marker left by an earlier, passing open whose fingerprint has since changed.)
15842fn probe_check_against_baseline(
15843 connection: &Connection,
15844 embedder: &dyn Embedder,
15845 identity: &EmbedderIdentity,
15846 mean_pinned: bool,
15847 probes: &[&str],
15848) -> Result<(), String> {
15849 let outcome =
15850 probe_check_against_baseline_inner(connection, embedder, identity, mean_pinned, probes);
15851 if outcome.is_err() {
15852 clear_probe_verification(connection);
15853 }
15854 outcome
15855}
15856
15857/// 0.8.18 Slice 5 — the check proper. Fail-SAFE (fix-1 DEFECT #1): a probe embed
15858/// that panics/errors/returns wrong-dim, a malformed/missing reference row, an
15859/// unreadable pinned mean, or a `vec_quantize_binary`/L2 SQL failure each ⇒ `Err`
15860/// (cannot verify ⇒ refuse dense), never a silent skip-and-serve.
15861///
15862/// fix-2 (DEFECT #1 residual): BEFORE the divergence check, the STORED baseline is
15863/// validated to be EXACTLY the committed probe set — the expected row count, a
15864/// contiguous 0-based `probe_ordinal` per committed probe, each `probe_text` equal
15865/// to the committed fixture text at that ordinal, each `reference_vec` a well-formed
15866/// `4 * dim` f32 blob, and the stored embedder identity/dim matching the current
15867/// one. This closes the partial-baseline / external-tamper fail-open (a 44-of-45
15868/// table, or a re-attributed/mangled row, previously verified only the rows present
15869/// or re-embedded a tampered `probe_text` against itself). Any mismatch ⇒ `Err`.
15870///
15871/// 0.8.20 Slice 22 (TC-68) — the 45 re-embeds are CACHED against
15872/// [`probe_verification_fingerprint`]. Note WHERE the cache check sits: after the
15873/// mean resolution and after the fix-2 completeness validation, before the
15874/// re-embed loop. That split is deliberate — everything cheap keeps running on
15875/// EVERY open (so a short, re-attributed, mangled or fixture-mismatched baseline
15876/// still fails closed immediately), and only the expensive part, the 45 model
15877/// invocations, is skipped. The residual this buys is recorded in
15878/// `dev/design/0.8.20-tc68-equivalence-probe-fingerprint-cache.md`.
15879fn probe_check_against_baseline_inner(
15880 connection: &Connection,
15881 embedder: &dyn Embedder,
15882 identity: &EmbedderIdentity,
15883 mean_pinned: bool,
15884 probes: &[&str],
15885) -> Result<(), String> {
15886 let dimension = identity.dimension as usize;
15887
15888 // Resolve the live mean. Fail-SAFE: if centering is required + pinned but the
15889 // mean cannot be read, we cannot reproduce `embedding_bin` ⇒ refuse (P1
15890 // un-verifiable). NoopEmbedder / no-pin ⇒ un-centered on BOTH sides (R-VEQ-3c).
15891 let mean_vec = if identity_requires_mean_centering(identity) && mean_pinned {
15892 match read_pinned_mean_vec(connection, identity.dimension) {
15893 Ok(Some(mean)) => Some(mean),
15894 Ok(None) => {
15895 return Err("mean-centering is required and pinned but mean_vec is absent; \
15896 cannot verify P1 (dense arm refused)"
15897 .to_string());
15898 }
15899 Err(_) => {
15900 return Err(
15901 "could not read the pinned mean_vec; cannot verify P1 (dense arm refused)"
15902 .to_string(),
15903 );
15904 }
15905 }
15906 } else {
15907 None
15908 };
15909
15910 let mut stmt = connection
15911 .prepare(
15912 "SELECT probe_ordinal, probe_text, reference_vec, embedder_name, embedder_revision, dim \
15913 FROM _fathomdb_embed_probe ORDER BY probe_ordinal",
15914 )
15915 .map_err(|e| format!("could not read the stored probe references: {e}; cannot verify"))?;
15916 let stored: Vec<StoredProbeRow> = stmt
15917 .query_map([], |row| {
15918 Ok((
15919 row.get::<_, i64>(0)?,
15920 row.get::<_, String>(1)?,
15921 row.get::<_, Vec<u8>>(2)?,
15922 row.get::<_, String>(3)?,
15923 row.get::<_, String>(4)?,
15924 row.get::<_, i64>(5)?,
15925 ))
15926 })
15927 .and_then(|rows| rows.collect::<rusqlite::Result<Vec<_>>>())
15928 .map_err(|e| format!("could not read the stored probe references: {e}; cannot verify"))?;
15929
15930 // fix-2 (DEFECT #1 residual) — COMPLETENESS validation of the STORED baseline.
15931 // `COUNT(*) > 0` is NOT proof of a complete, trustworthy baseline: a partially
15932 // populated or externally-tampered probe table (44 of 45 rows, a gap/dupe in the
15933 // ordinals, a mangled reference blob, a mismatched probe_text, or a foreign
15934 // embedder identity) is UNVERIFIABLE stored state. The prior code re-embedded
15935 // the STORED probe_text and compared it to its OWN reference, so a tampered
15936 // probe_text verified against itself and a short table verified only the rows
15937 // present — both fail-OPEN. Atomic population stops the ENGINE from writing a
15938 // partial set; this closes external corruption, a manual edit, and a future
15939 // migration bug the engine did not author. Any mismatch ⇒ fail CLOSED (dense
15940 // refused); the text-only/FTS path still serves. The stored baseline must be
15941 // EXACTLY the committed probe set, in order, under the current identity.
15942 if stored.len() != probes.len() {
15943 return Err(format!(
15944 "the probe reference table has {} rows but the committed fixture defines {}; \
15945 the stored baseline is incomplete or corrupt — cannot verify the dense arm (refused)",
15946 stored.len(),
15947 probes.len()
15948 ));
15949 }
15950 for (idx, (ordinal, probe_text, ref_blob, name, revision, dim)) in stored.iter().enumerate() {
15951 // Contiguous 0-based ordinals, one per committed probe (no gaps/dupes).
15952 if *ordinal != idx as i64 {
15953 return Err(format!(
15954 "probe reference ordinals are non-contiguous (row {idx} carries ordinal {ordinal}); \
15955 the stored baseline is corrupt — cannot verify the dense arm (refused)"
15956 ));
15957 }
15958 // The stored text MUST be the committed fixture text at this ordinal —
15959 // otherwise a tampered probe_text re-embeds and verifies against ITSELF,
15960 // masking drift (the exact fail-open this fix closes).
15961 if probe_text != probes[idx] {
15962 return Err(format!(
15963 "probe reference {ordinal} text does not match the committed fixture; \
15964 the stored baseline is tampered or corrupt — cannot verify the dense arm (refused)"
15965 ));
15966 }
15967 // Well-formed f32[dim] reference (4*dim little-endian bytes).
15968 if ref_blob.len() != dimension * 4 {
15969 return Err(format!(
15970 "probe reference {ordinal} is malformed (len {} != {}); \
15971 cannot verify the dense arm (refused)",
15972 ref_blob.len(),
15973 dimension * 4
15974 ));
15975 }
15976 // The stored embedder identity/dim must match the CURRENT expected identity
15977 // (defence-in-depth beyond `check_embedder_profile`: catches a baseline row
15978 // re-attributed to a foreign embedder by external edit/migration).
15979 if *dim != identity.dimension as i64
15980 || name != &identity.name
15981 || revision != &identity.revision
15982 {
15983 return Err(format!(
15984 "probe reference {ordinal} was captured under embedder {name}/{revision}/dim={dim} \
15985 but the current embedder is {}/{}/dim={}; the stored baseline does not match — \
15986 cannot verify the dense arm (refused)",
15987 identity.name, identity.revision, identity.dimension
15988 ));
15989 }
15990 }
15991
15992 // 0.8.20 Slice 22 (TC-68) — the CACHE gate. Everything above this line ran on
15993 // this open and still fails closed; everything below it is the 45 model
15994 // invocations that made `Engine::open` cost a flat 45 embeds FOREVER (measured
15995 // at `94bb33ef`: 0 with no enrolled kind, 90 on the one-time population open,
15996 // 45 on every open thereafter — independent of the enrolled-kind count, since
15997 // the probe gate is an `EXISTS` and the body never iterates kinds).
15998 //
15999 // If the probe already RAN and PASSED under this exact fingerprint, re-running
16000 // it is a pure re-computation of a known answer, so the verdict is reused.
16001 // Fail-SAFE: `probe_verification_is_cached` answers `false` for every failure
16002 // mode — missing table, absent row, garbled value, SQL error — so an
16003 // unreadable cache RUNS the probe, it never short-circuits to trusting it.
16004 let fingerprint = probe_verification_fingerprint(identity, mean_vec.as_deref(), &stored);
16005 if probe_verification_is_cached(connection, &fingerprint) {
16006 return Ok(());
16007 }
16008
16009 let mut total_flips: u64 = 0;
16010 let mut max_l2: f32 = 0.0;
16011 let mut worst_probe: Option<String> = None;
16012
16013 for (ordinal, probe_text, ref_blob, _, _, _) in &stored {
16014 let reference = decode_vector_blob(ref_blob);
16015 let reembed = probe_embed(embedder, probe_text, dimension).ok_or_else(|| {
16016 format!(
16017 "embedder failed/panicked re-embedding probe {ordinal}; \
16018 cannot verify the dense arm (refused)"
16019 )
16020 })?;
16021
16022 // (P2) un-centered L2 — `vec_distance_l2(embedding, vec_f32(query))`.
16023 let l2 = l2_distance(&reembed, &reference);
16024 if l2 > max_l2 {
16025 max_l2 = l2;
16026 worst_probe = Some(probe_text.clone());
16027 }
16028
16029 // (P1) mean-centered Phase-1 flip count — same
16030 // `vec_quantize_binary(sign(x − mean_vec))` path as build_vector_phase1_sql.
16031 let (ref_c, reembed_c) = match &mean_vec {
16032 Some(mean) => (subtract_mean(&reference, mean), subtract_mean(&reembed, mean)),
16033 None => (reference.clone(), reembed.clone()),
16034 };
16035 let ref_bits = quantize_binary_via_sql(connection, &ref_c).ok_or_else(|| {
16036 format!("vec_quantize_binary SQL failed for probe {ordinal}; cannot verify P1")
16037 })?;
16038 let reembed_bits = quantize_binary_via_sql(connection, &reembed_c).ok_or_else(|| {
16039 format!("vec_quantize_binary SQL failed for probe {ordinal}; cannot verify P1")
16040 })?;
16041 total_flips = total_flips.saturating_add(hamming_bytes(&ref_bits, &reembed_bits));
16042 }
16043
16044 let p1_tripped = total_flips > VECTOR_EQUIVALENCE_P1_FLIP_FLOOR;
16045 let p2_tripped = max_l2 > VECTOR_EQUIVALENCE_L2_EPSILON;
16046 if p1_tripped || p2_tripped {
16047 let probe_hint = worst_probe.as_deref().unwrap_or("<unknown>");
16048 return Err(format!(
16049 "P1 mean-centered embedding_bin flips={total_flips} (floor={VECTOR_EQUIVALENCE_P1_FLIP_FLOOR}), \
16050 P2 max un-centered L2={max_l2:.3e} (epsilon={VECTOR_EQUIVALENCE_L2_EPSILON:.3e}); \
16051 worst probe {probe_hint:?}"
16052 ));
16053 }
16054
16055 // 0.8.20 Slice 22 (TC-68) — the probe RAN and PASSED; record the fingerprint
16056 // so the next open with identical inputs need not repeat it. Only a verdict
16057 // this engine reached itself is ever written (the failure paths above return
16058 // early, and the wrapper clears any prior marker on them).
16059 record_probe_verification(connection, &fingerprint);
16060 Ok(())
16061}
16062
16063/// 0.8.18 Slice 5 — un-centered Euclidean (L2) distance, matching the
16064/// `vec_distance_l2` semantics used by the Phase-2 rerank.
16065fn l2_distance(a: &[f32], b: &[f32]) -> f32 {
16066 a.iter().zip(b.iter()).map(|(x, y)| (x - y) * (x - y)).sum::<f32>().sqrt()
16067}
16068
16069/// 0.8.18 Slice 5 — produce the packed 1-bit `embedding_bin` blob for a (possibly
16070/// mean-centered) f32 vector via the SAME SQL `vec_quantize_binary` the production
16071/// Phase-1 path uses, so the probe's bits are byte-equal to the engine's
16072/// `embedding_bin` production. `None` on any SQL/serialization error.
16073fn quantize_binary_via_sql(connection: &Connection, vector: &[f32]) -> Option<Vec<u8>> {
16074 let json = serde_json::to_string(vector).ok()?;
16075 connection
16076 .query_row("SELECT vec_quantize_binary(vec_f32(?1))", [json], |row| {
16077 row.get::<_, Vec<u8>>(0)
16078 })
16079 .ok()
16080}
16081
16082/// 0.8.18 Slice 5 — Hamming distance (differing bit count) between two equal-length
16083/// packed bit blobs. Unequal lengths ⇒ count every bit of the length delta as
16084/// differing (a shape divergence is a divergence).
16085fn hamming_bytes(a: &[u8], b: &[u8]) -> u64 {
16086 let common = a.len().min(b.len());
16087 let mut flips: u64 = 0;
16088 for i in 0..common {
16089 flips += u64::from((a[i] ^ b[i]).count_ones());
16090 }
16091 let extra = a.len().abs_diff(b.len());
16092 flips + (extra as u64) * 8
16093}
16094
16095/// Maps the writer-facing `kind` value to the locked Pack 1
16096/// `source_type` partition-key vocabulary. Must stay in lockstep with
16097/// the CASE WHEN inlined in migration step 9
16098/// (`fathomdb-schema/src/lib.rs`); the drift-detection unit test in
16099/// this module's `tests` mod enforces that. Per
16100/// `dev/design/0.7.0-vector-quant-pack1.md` D3.
16101fn resolve_source_type(kind: &str) -> Result<&'static str, EngineError> {
16102 Ok(match kind {
16103 "email" => "email",
16104 "article" => "article",
16105 "paper" => "paper",
16106 "meeting" => "meeting",
16107 "note" => "note",
16108 "todo" => "todo",
16109 // Synthetic AC-013 test fixture; coerced so the 6-value HITL lock holds.
16110 "doc" => "article",
16111 // G11 (Slice 15) — edge-body projection; separate `source_type` partition
16112 // key distinguishes edge vectors from node vectors in `vector_default`.
16113 "edge_fact" => "edge_fact",
16114 _ => return Err(EngineError::Storage),
16115 })
16116}
16117
16118/// 0.8.20 Slice 20c fix-2 (codex §9 [P1]) — **can the vector writer COMMIT a row
16119/// of this kind?** The ONE definition of the vector pipeline's kind domain, shared
16120/// by every enrolment path.
16121///
16122/// [`commit_projection_outcomes`] resolves `kind -> source_type` through
16123/// [`resolve_source_type`] and returns `Err` for anything outside its locked
16124/// vocabulary — *before* it records the row's terminal. `PreparedWrite::Node`, by
16125/// contrast, accepts ANY non-empty `kind` (`validate_write` constrains the body,
16126/// the identity and the validity window, never the kind against that vocabulary),
16127/// so a corpus can legitimately hold e.g. an `"invoice"` node.
16128///
16129/// Enrolling such a kind is therefore a permanent LIVENESS WEDGE for the whole
16130/// workspace: the scheduler picks the row up, the commit fails, no terminal is
16131/// ever written, the scanner re-enqueues it forever, `drain` burns its entire
16132/// timeout into [`EngineError::Scheduler`] and `dense_readiness` sticks on
16133/// `embedding` — starving the rows whose kinds ARE commit-able along with it.
16134///
16135/// So enrolment is RESTRICTED to this predicate rather than the vector writer
16136/// being taught arbitrary kinds (which would reach into `resolve_source_type`'s
16137/// locked Pack-1 partition-key semantics — `dev/design/0.7.0-vector-quant-pack1.md`
16138/// D3, a HITL lock). A non-commit-able kind simply gets NO dense arm, which is
16139/// precisely its pre-slice status quo; it is deliberately **not** a new typed
16140/// error and adds no governed surface.
16141///
16142/// It DELEGATES to `resolve_source_type` instead of restating the list. A
16143/// hand-copied second vocabulary is the TC-56 defect shape (a mirror that silently
16144/// drifts from its original), and here the drift would be silent in the worst
16145/// direction: a kind added to `resolve_source_type` but missing from a copied
16146/// filter would just never be embedded.
16147fn kind_is_vector_committable(kind: &str) -> bool {
16148 resolve_source_type(kind).is_ok()
16149}
16150
16151/// G11 (Slice 15) — derive a stable hex-encoded sha256 logical_id from a
16152/// `(kind, name)` pair. Both inputs are lowercased before hashing so that
16153/// entity identity is case-insensitive (`"Alice"` == `"alice"`). The
16154/// canonical form is `sha256("<kind>:<name>")` — identical to the
16155/// ADR-0.8.1-byo-llm derivation rule.
16156///
16157/// fix-34 [P1]: because `:` is the delimiter, a `:` in `kind` would let the
16158/// split point move and collide two distinct `(kind, name)` pairs onto one
16159/// identity (e.g. `("a:b","c")` and `("a","b:c")` both hash `"a:b:c"`),
16160/// silently dropping one entity via batch dedup / G0 supersession. An empty
16161/// `name` collapses every name-less entity of a kind onto `sha256("<kind>:")`.
16162/// We reject both at the boundary; this preserves the ADR derivation rule
16163/// (a colon-free `kind` makes the first `:` an unambiguous delimiter, so a `:`
16164/// in `name` stays safe — edge keys deliberately rely on that).
16165fn derive_logical_id(kind: &str, name: &str) -> Result<String, EngineError> {
16166 if kind.contains(':') || name.is_empty() {
16167 return Err(EngineError::Extractor);
16168 }
16169 let input = format!("{}:{}", kind.to_lowercase(), name.to_lowercase());
16170 let mut hasher = Sha256::new();
16171 hasher.update(input.as_bytes());
16172 // digest 0.11 returns `hybrid_array::Array`, which (unlike the old
16173 // `GenericArray`) does not implement `LowerHex`. Format the bytes
16174 // explicitly — byte-identical lowercase, zero-padded hex to the prior
16175 // `{:x}` rendering, preserving the load-bearing logical-id derivation.
16176 Ok(hasher.finalize().iter().map(|b| format!("{b:02x}")).collect())
16177}
16178
16179/// Cause-A (0.8.11.2) / C-2 (0.8.19, TC-8) — derive the typed **stable hit-id**
16180/// ([`IdSpace`]) carried on [`SearchHit::id`] for cross-session real-gold keying.
16181///
16182/// The stable id is the active canonical node's `logical_id` — the post-G0
16183/// supersession-stable identity, preserved across re-projection/re-ingest by the
16184/// tombstone-then-insert contract (whereas the engine-internal `write_cursor` is
16185/// reassigned on every re-ingest). When `logical_id` is NULL — the doc-seeded
16186/// node case, the *dominant* corpus hit type today — we fall back to a content
16187/// hash of the body so doc hits still carry a re-ingest-survivable key.
16188///
16189/// The result is a typed [`IdSpace`]; its `to_prefixed()` reproduces the pre-C-2
16190/// `stable_id` string byte-for-byte so real-gold keying is a no-op:
16191/// - [`IdSpace::logical`] (`"l:<logical_id>"`) — entities + edges (graph-arm,
16192/// vector-node, and edge hits when `logical_id` is present);
16193/// - [`IdSpace::content`] (`"h:<sha256(body)>"`) — doc nodes with NULL
16194/// `logical_id`, and any branch that cannot cheaply resolve a `logical_id`.
16195///
16196/// Behaviour-neutral: the value never participates in ranking/scoring (same
16197/// additive posture as `source_id` / `ce_score`).
16198fn derive_stable_id(logical_id: Option<&str>, body: &str) -> IdSpace {
16199 match logical_id {
16200 Some(lid) if !lid.is_empty() => IdSpace::logical(lid),
16201 _ => {
16202 let mut hasher = Sha256::new();
16203 hasher.update(body.as_bytes());
16204 IdSpace::content(
16205 hasher.finalize().iter().map(|b| format!("{b:02x}")).collect::<String>(),
16206 )
16207 }
16208 }
16209}
16210
16211/// fix-34 [P2]: dedup a batch of [`PreparedWrite`]s by `logical_id`, keeping the
16212/// first occurrence. Shared by the entity and edge arms of the BYO-LLM ingest
16213/// path so a harness that returns the same node/edge twice in one response does
16214/// not write a row that immediately supersedes its sibling.
16215///
16216/// **TC-32 (0.8.20) — single-provenance entity dedupe is INTENTIONAL and
16217/// ACCEPTED.** Because dedupe keeps the FIRST occurrence, same-name entities
16218/// collapse onto one `logical_id` row that carries only the FIRST document's
16219/// `source_id`; erasing a later document therefore does not remove the shared
16220/// entity row. The HITL has ruled this acceptable for now and explicitly
16221/// declined a multi-source-provenance model. Tracked as TC-32 — do not "fix"
16222/// this by changing dedupe behaviour without a fresh decision.
16223fn dedup_prepared_by_logical_id(batch: Vec<PreparedWrite>) -> Vec<PreparedWrite> {
16224 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
16225 batch
16226 .into_iter()
16227 .filter(|w| match w {
16228 PreparedWrite::Node { logical_id: Some(id), .. }
16229 | PreparedWrite::Edge { logical_id: Some(id), .. } => seen.insert(id.clone()),
16230 _ => true,
16231 })
16232 .collect()
16233}
16234
16235/// 0.8.6 Slice 5 (ADR-0.8.6) — the family of caller-supplied provider tasks that
16236/// ride the one NDJSON-over-stdio transport. Each task maps to a wire protocol
16237/// string `fathomdb.<task>.v1` and a task discriminator name. `Extract` shipped
16238/// in 0.8.6; `Consolidate` (0.8.12 Slice 15, OPP-2) is the SECOND consumer of
16239/// this one transport — it adds only a variant, a payload, and an `EngineError`
16240/// leaf, WITHOUT a second handshake or a second transport.
16241#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16242enum ProviderTask {
16243 Extract,
16244 /// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — consolidation / recency provider.
16245 Consolidate,
16246}
16247
16248impl ProviderTask {
16249 /// The wire task discriminator, e.g. `"extract"`. Used for `supported_tasks`
16250 /// negotiation and as the request envelope `type`.
16251 fn name(self) -> &'static str {
16252 match self {
16253 ProviderTask::Extract => "extract",
16254 ProviderTask::Consolidate => "consolidate",
16255 }
16256 }
16257
16258 /// The protocol string FathomDB sends in `hello`/requests and requires in
16259 /// `ready`. For `Extract` this is the UNCHANGED `fathomdb.extract.v1` —
16260 /// byte-identical back-compat for existing ELPS harnesses (ADR-0.8.6 §2.1).
16261 /// For `Consolidate` it is `fathomdb.consolidate.v1` (ADR-0.8.12 §2).
16262 fn protocol(self) -> &'static str {
16263 match self {
16264 ProviderTask::Extract => "fathomdb.extract.v1",
16265 ProviderTask::Consolidate => "fathomdb.consolidate.v1",
16266 }
16267 }
16268}
16269
16270/// 0.8.6 Slice 5 (ADR-0.8.6) — an open provider transport session: the spawned
16271/// caller subprocess, the buffered stdin writer, the detached stdout-drain
16272/// channel, the bounded-recv timeout, and the negotiated handshake state
16273/// (`model` provenance + `max_docs_per_request`). One session serves one task
16274/// family; the `request`/framing is identical across tasks. `Drop` reaps the
16275/// child (sends stdin EOF via the writer field's own drop, then kill/wait),
16276/// replacing the prior explicit outer kill/wait.
16277struct ProviderSession {
16278 task: ProviderTask,
16279 child: std::process::Child,
16280 writer: std::io::BufWriter<std::process::ChildStdin>,
16281 line_rx: Receiver<std::io::Result<String>>,
16282 io_timeout: Duration,
16283 /// `ready.model`, recorded as output-row provenance (`extractor_model_id`).
16284 model: Option<String>,
16285 max_docs_per_request: usize,
16286}
16287
16288impl Drop for ProviderSession {
16289 fn drop(&mut self) {
16290 // The detached stdout-drain thread exits when the child's stdout closes;
16291 // kill() guarantees that even for a child that ignores stdin EOF. The
16292 // `writer` field drops after this (declaration order) sending EOF too.
16293 let _ = self.child.kill();
16294 let _ = self.child.wait();
16295 }
16296}
16297
16298impl ProviderSession {
16299 /// Run the `hello` → `ready` handshake and `supported_tasks` negotiation.
16300 /// Validates protocol + schema_version (fix-23 [P2]); rejects a zero
16301 /// `max_docs_per_request` (fix-1 [P2]); and, when the harness advertises
16302 /// `supported_tasks`, refuses to proceed unless this session's task is in it.
16303 /// When `supported_tasks` is absent, the harness is assumed to serve the
16304 /// requested task (back-compat: existing extract-only harnesses unchanged).
16305 fn handshake(&mut self) -> Result<(), EngineError> {
16306 let protocol = self.task.protocol();
16307 let hello = serde_json::json!({
16308 "protocol": protocol,
16309 "type": "hello",
16310 "schema_version": 1,
16311 });
16312 let hello_line = serde_json::to_string(&hello).map_err(|_| EngineError::Extractor)?;
16313 writeln!(self.writer, "{hello_line}").map_err(|_| EngineError::Extractor)?;
16314 self.writer.flush().map_err(|_| EngineError::Extractor)?;
16315
16316 let line = recv_extractor_line(&self.line_rx, self.io_timeout)?;
16317 let ready: Value = serde_json::from_str(line.trim()).map_err(|_| EngineError::Extractor)?;
16318 // fix-23 [P2]: validate protocol + schema_version in the ready message per ADR.
16319 if ready.get("type").and_then(|v| v.as_str()) != Some("ready")
16320 || ready.get("protocol").and_then(|v| v.as_str()) != Some(protocol)
16321 || ready.get("schema_version").and_then(|v| v.as_u64()) != Some(1)
16322 {
16323 return Err(EngineError::Extractor);
16324 }
16325
16326 // 0.8.6 Slice 5 (ADR-0.8.6 §2.2): additive, optional `supported_tasks`
16327 // negotiation. If present, the harness must advertise this session's task
16328 // or FathomDB refuses to dispatch it. If absent, default to "serves the
16329 // requested task" so extract-only harnesses keep working unchanged.
16330 if let Some(supported) = ready.get("supported_tasks").and_then(|v| v.as_array()) {
16331 let task_name = self.task.name();
16332 let advertised = supported.iter().any(|t| t.as_str() == Some(task_name));
16333 if !advertised {
16334 return Err(EngineError::Extractor);
16335 }
16336 }
16337
16338 self.model = ready.get("model").and_then(|v| v.as_str()).map(|s| s.to_string());
16339 let max_docs =
16340 ready.get("max_docs_per_request").and_then(|v| v.as_u64()).unwrap_or(8) as usize;
16341 // fix-1 [P2]: reject zero max_docs_per_request to prevent chunks(0) panic.
16342 if max_docs == 0 {
16343 return Err(EngineError::Extractor);
16344 }
16345 self.max_docs_per_request = max_docs;
16346 Ok(())
16347 }
16348
16349 /// Send one framed request for this session's task and receive its matching
16350 /// response. `payload` carries the task-specific fields; the envelope keys
16351 /// (`protocol`, `type`, `request_id`) are added here. The response must have
16352 /// `type == "result"` and a matching `request_id` (fix-24 [P2]); anything
16353 /// else (error, wrong id, missing type) is a protocol fault. For `Extract`
16354 /// the serialized request bytes are identical to the pre-0.8.6 path (serde_json
16355 /// serializes map keys sorted, independent of insertion order).
16356 fn request(
16357 &mut self,
16358 request_id: &str,
16359 payload: Vec<(String, Value)>,
16360 ) -> Result<Value, EngineError> {
16361 let mut req = serde_json::Map::new();
16362 req.insert("protocol".to_string(), Value::from(self.task.protocol()));
16363 req.insert("type".to_string(), Value::from(self.task.name()));
16364 req.insert("request_id".to_string(), Value::from(request_id));
16365 for (k, v) in payload {
16366 req.insert(k, v);
16367 }
16368 let req_line =
16369 serde_json::to_string(&Value::Object(req)).map_err(|_| EngineError::Extractor)?;
16370 writeln!(self.writer, "{req_line}").map_err(|_| EngineError::Extractor)?;
16371 self.writer.flush().map_err(|_| EngineError::Extractor)?;
16372
16373 let result_line = recv_extractor_line(&self.line_rx, self.io_timeout)?;
16374 let result: Value =
16375 serde_json::from_str(result_line.trim()).map_err(|_| EngineError::Extractor)?;
16376 let resp_type = result.get("type").and_then(|v| v.as_str());
16377 let resp_id = result.get("request_id").and_then(|v| v.as_str());
16378 if resp_type != Some("result") || resp_id != Some(request_id) {
16379 return Err(EngineError::Extractor);
16380 }
16381 Ok(result)
16382 }
16383}
16384
16385/// fix-35 [P2]: BYO-LLM extractor I/O timeout. Defaults to 300s to accommodate
16386/// slow LLM harnesses; override (in milliseconds) via
16387/// `FATHOMDB_EXTRACTOR_TIMEOUT_MS` (tests use this to exercise the hung-harness
16388/// path quickly).
16389fn extractor_io_timeout() -> Duration {
16390 std::env::var("FATHOMDB_EXTRACTOR_TIMEOUT_MS")
16391 .ok()
16392 .and_then(|s| s.parse::<u64>().ok())
16393 .map(Duration::from_millis)
16394 .unwrap_or_else(|| Duration::from_secs(300))
16395}
16396
16397/// fix-35 [P1/P2]: receive one line from the stdout reader thread, bounded by
16398/// `timeout`. A timeout, a closed channel (reader thread ended / child EOF), or
16399/// an underlying io error all map to [`EngineError::Extractor`].
16400fn recv_extractor_line(
16401 rx: &Receiver<std::io::Result<String>>,
16402 timeout: Duration,
16403) -> Result<String, EngineError> {
16404 match rx.recv_timeout(timeout) {
16405 Ok(Ok(line)) => Ok(line),
16406 _ => Err(EngineError::Extractor),
16407 }
16408}
16409
16410fn map_runtime_embedder_error(err: RuntimeEmbedderError) -> EngineError {
16411 match err {
16412 RuntimeEmbedderError::Failed { .. } | RuntimeEmbedderError::Timeout => {
16413 EngineError::Embedder
16414 }
16415 }
16416}
16417
16418fn default_embedder_identity() -> EmbedderIdentity {
16419 EmbedderIdentity::new(
16420 DEFAULT_EMBEDDER_NAME,
16421 DEFAULT_EMBEDDER_REVISION,
16422 DEFAULT_EMBEDDER_DIMENSION,
16423 )
16424}
16425
16426fn check_embedder_profile(
16427 connection: &Connection,
16428 supplied: &EmbedderIdentity,
16429) -> Result<bool, EngineOpenError> {
16430 // Returns `true` iff `_fathomdb_embedder_profiles.mean_vec IS NOT NULL`
16431 // for the default profile (and its byte length matches `4 * dimension`
16432 // per `dev/design/embedder.md` §0.2). EU-5a2: column lands in step 10.
16433 let mut statement = match connection.prepare(
16434 "SELECT name, revision, dimension, mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
16435 ) {
16436 Ok(statement) => statement,
16437 Err(_) => return Ok(false),
16438 };
16439 let mut rows = statement.query([]).map_err(|_| {
16440 EngineOpenError::Corruption(CorruptionDetail {
16441 kind: CorruptionKind::EmbedderIdentityDrift,
16442 stage: OpenStage::EmbedderIdentity,
16443 locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
16444 recovery_hint: RecoveryHint {
16445 code: "E_CORRUPT_EMBEDDER_IDENTITY",
16446 doc_anchor: "design/recovery.md#embedder-identity-drift",
16447 },
16448 })
16449 })?;
16450
16451 let Some(row) = rows.next().map_err(|_| {
16452 EngineOpenError::Corruption(CorruptionDetail {
16453 kind: CorruptionKind::EmbedderIdentityDrift,
16454 stage: OpenStage::EmbedderIdentity,
16455 locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
16456 recovery_hint: RecoveryHint {
16457 code: "E_CORRUPT_EMBEDDER_IDENTITY",
16458 doc_anchor: "design/recovery.md#embedder-identity-drift",
16459 },
16460 })
16461 })?
16462 else {
16463 connection
16464 .execute(
16465 "INSERT INTO _fathomdb_embedder_profiles(profile, name, revision, dimension)
16466 VALUES(?1, ?2, ?3, ?4)",
16467 params![
16468 DEFAULT_VECTOR_PROFILE,
16469 supplied.name,
16470 supplied.revision,
16471 supplied.dimension
16472 ],
16473 )
16474 .map_err(|_| EngineOpenError::Io {
16475 message: "could not persist embedder profile".to_string(),
16476 })?;
16477 return Ok(false);
16478 };
16479
16480 let stored_name = row.get::<_, String>(0).map_err(|_| {
16481 EngineOpenError::Corruption(CorruptionDetail {
16482 kind: CorruptionKind::EmbedderIdentityDrift,
16483 stage: OpenStage::EmbedderIdentity,
16484 locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
16485 recovery_hint: RecoveryHint {
16486 code: "E_CORRUPT_EMBEDDER_IDENTITY",
16487 doc_anchor: "design/recovery.md#embedder-identity-drift",
16488 },
16489 })
16490 })?;
16491 let stored_revision = row.get::<_, String>(1).map_err(|_| {
16492 EngineOpenError::Corruption(CorruptionDetail {
16493 kind: CorruptionKind::EmbedderIdentityDrift,
16494 stage: OpenStage::EmbedderIdentity,
16495 locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
16496 recovery_hint: RecoveryHint {
16497 code: "E_CORRUPT_EMBEDDER_IDENTITY",
16498 doc_anchor: "design/recovery.md#embedder-identity-drift",
16499 },
16500 })
16501 })?;
16502 let dimension = row.get::<_, u32>(2).map_err(|_| {
16503 EngineOpenError::Corruption(CorruptionDetail {
16504 kind: CorruptionKind::EmbedderIdentityDrift,
16505 stage: OpenStage::EmbedderIdentity,
16506 locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
16507 recovery_hint: RecoveryHint {
16508 code: "E_CORRUPT_EMBEDDER_IDENTITY",
16509 doc_anchor: "design/recovery.md#embedder-identity-drift",
16510 },
16511 })
16512 })?;
16513
16514 let stored = EmbedderIdentity::new(stored_name, stored_revision, dimension);
16515
16516 if stored.name != supplied.name || stored.revision != supplied.revision {
16517 return Err(EngineOpenError::EmbedderIdentityMismatch {
16518 stored,
16519 supplied: supplied.clone(),
16520 });
16521 }
16522 if dimension != supplied.dimension {
16523 return Err(EngineOpenError::EmbedderDimensionMismatch {
16524 stored: dimension,
16525 supplied: supplied.dimension,
16526 });
16527 }
16528
16529 // EU-5a2 / `dev/design/embedder.md` §0.2 invariant: if `mean_vec` is
16530 // populated, byte length MUST equal `4 * dimension`. Debug builds
16531 // assert; release builds fail closed via EmbedderIdentityMismatch
16532 // (the same fail-closed channel the rest of profile drift takes).
16533 let mean_vec: Option<Vec<u8>> = row.get::<_, Option<Vec<u8>>>(3).map_err(|_| {
16534 EngineOpenError::Corruption(CorruptionDetail {
16535 kind: CorruptionKind::EmbedderIdentityDrift,
16536 stage: OpenStage::EmbedderIdentity,
16537 locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
16538 recovery_hint: RecoveryHint {
16539 code: "E_CORRUPT_EMBEDDER_IDENTITY",
16540 doc_anchor: "design/recovery.md#embedder-identity-drift",
16541 },
16542 })
16543 })?;
16544 let pinned = match mean_vec {
16545 Some(bytes) => {
16546 let expected_len = (dimension as usize).saturating_mul(4);
16547 // `dev/design/embedder.md` §0.2 invariant: when populated,
16548 // `mean_vec` byte length MUST equal `4 * dimension`. Fail
16549 // closed via the existing identity-drift channel in both
16550 // debug and release builds — tests deliberately poke
16551 // malformed values to exercise this branch.
16552 if bytes.len() != expected_len {
16553 return Err(EngineOpenError::EmbedderIdentityMismatch {
16554 stored,
16555 supplied: supplied.clone(),
16556 });
16557 }
16558 true
16559 }
16560 None => false,
16561 };
16562
16563 Ok(pinned)
16564}
16565
16566#[derive(Clone, Debug, Eq, PartialEq)]
16567enum WritePlan {
16568 Node,
16569 Edge,
16570 AppendOnlyLog,
16571 LatestState,
16572 AdminSchema,
16573}
16574
16575fn validate_batch(
16576 connection: &Connection,
16577 batch: &[PreparedWrite],
16578) -> Result<Vec<WritePlan>, EngineError> {
16579 batch.iter().map(|write| validate_write(connection, write)).collect()
16580}
16581
16582fn collect_projection_jobs(
16583 connection: &Connection,
16584 batch: &[PreparedWrite],
16585) -> Result<Vec<ProjectionJob>, EngineError> {
16586 let mut jobs = Vec::new();
16587 for write in batch {
16588 if let PreparedWrite::Node { kind, body, .. } = write {
16589 // 0.8.20 Slice 20c — this probe decides whether `notify_new_work` is
16590 // called, so `Engine::enrol_batch_vector_kinds` MUST already have run
16591 // on this batch: a kind enrolled after this point would be enqueued in
16592 // the database with the dispatcher left asleep on
16593 // `pending_scan == false`, and because `drain` is a passive barrier
16594 // (C4 rider: never a trigger) the next `drain` would burn its ENTIRE
16595 // timeout and return `EngineError::Scheduler` on work ready to run.
16596 if kind_is_vector_indexed(connection, kind)? {
16597 jobs.push(ProjectionJob { cursor: 0, kind: kind.clone(), body: body.clone() });
16598 }
16599 }
16600 }
16601 Ok(jobs)
16602}
16603
16604fn validate_write(
16605 connection: &Connection,
16606 write: &PreparedWrite,
16607) -> Result<WritePlan, EngineError> {
16608 match write {
16609 PreparedWrite::Node { kind, body, logical_id, valid_from, valid_until, .. } => {
16610 if kind.trim().is_empty() || body.trim().is_empty() {
16611 return Err(EngineError::WriteValidation);
16612 }
16613 // 0.8.20 Slice 15b (TC-34) — the validity window is HALF-OPEN
16614 // `[valid_from, valid_until)`, so a pair with `from >= until` selects
16615 // no instant at all: the row would be written but no default read
16616 // could ever return it. Silently accepting that is a trap, so it is a
16617 // typed refusal.
16618 //
16619 // 0.8.20 Slice 22 (R-20-VC) — **decision #18, SETTLED: one family.**
16620 // This site used to return `EngineError::InvalidArgument { msg }`
16621 // carrying both bounds, which made `validate_write` — ONE function —
16622 // reject across TWO error families, so the same `write` call raised
16623 // `InvalidArgumentError` for an inverted window and
16624 // `WriteValidationError` for a non-integer bound. `dev/design/errors.md`
16625 // (status: locked) defines `WriteValidationError` as "malformed typed
16626 // write shape" / "the submitted typed write is malformed **before**
16627 // schema-sensitive payload checks run" — which is exactly this
16628 // boundary — so the code now agrees with the taxonomy of record.
16629 // `InvalidArgument` stays the family for caller-argument rejections
16630 // OUTSIDE this boundary (see the errors.md 2026-07-28 amendment).
16631 //
16632 // **The cost, stated:** `WriteValidation` is a UNIT variant and both
16633 // bindings map it to a fixed message-less string, so the offending
16634 // bounds are no longer recoverable from the error. That is a breaking
16635 // behaviour change on a published surface (CHANGELOG 0.8.20) and it is
16636 // the diagnostic the prior split existed to preserve. Restoring it
16637 // needs a message-carrying `WriteValidation { msg }`, which is a
16638 // cross-cutting change across every engine + binding raise site and
16639 // both binding payload shapes — its own slice, not this one.
16640 //
16641 // Only the PAIR can be empty. A one-sided window is unbounded on the
16642 // missing side and can never be empty, so it is never refused.
16643 if let (Some(from), Some(until)) = (valid_from, valid_until) {
16644 if from >= until {
16645 return Err(EngineError::WriteValidation);
16646 }
16647 }
16648 // R-20-E3: `source_id` needs no emptiness check here — `SourceId`
16649 // cannot hold an empty or reserved id, so the check has moved from
16650 // this branch into the type's constructor.
16651 // G0 — an explicit logical_id must be non-empty (NULL/None is the
16652 // legacy default; an empty string is never a valid identity).
16653 // Also reject char(30) = \x1e (ASCII RS), which is the BFS cycle-guard
16654 // delimiter; allowing it would corrupt the visited-path substring test.
16655 if let Some(logical_id) = logical_id {
16656 if logical_id.is_empty() || logical_id.contains('\x1e') {
16657 return Err(EngineError::WriteValidation);
16658 }
16659 }
16660 Ok(WritePlan::Node)
16661 }
16662 PreparedWrite::Edge { kind, from, to, logical_id, t_valid, t_invalid, .. } => {
16663 if kind.trim().is_empty() || from.trim().is_empty() || to.trim().is_empty() {
16664 return Err(EngineError::WriteValidation);
16665 }
16666 // Reject char(30) in from/to: these become from_id/to_id in canonical_edges
16667 // and appear in BFS visited strings — an \x1e there would corrupt the guard.
16668 if from.contains('\x1e') || to.contains('\x1e') {
16669 return Err(EngineError::WriteValidation);
16670 }
16671 // R-20-E3: see the Node branch — emptiness is a `SourceId` invariant.
16672 if let Some(logical_id) = logical_id {
16673 if logical_id.is_empty() || logical_id.contains('\x1e') {
16674 return Err(EngineError::WriteValidation);
16675 }
16676 }
16677 // TC-33 fix-1 (codex §9 P2) — an epoch SQLite cannot render to
16678 // ISO-8601 must be UNSTORABLE. The governed integer surface is the
16679 // only way to reach one (inbound ISO normalisation maxes at year
16680 // 9999), so this write boundary is where it is stopped, before it
16681 // can render to a silent `null` on the consolidation wire and
16682 // resurrect an invalidated edge. Structural primary layer; the
16683 // render site keeps a defensive hard-assert as the backstop.
16684 reject_unrenderable_edge_epoch("t_valid", *t_valid)?;
16685 reject_unrenderable_edge_epoch("t_invalid", *t_invalid)?;
16686 Ok(WritePlan::Edge)
16687 }
16688 PreparedWrite::AdminSchema { name, kind, schema_json, retention_json } => {
16689 if name.trim().is_empty()
16690 || !matches!(kind.as_str(), "append_only_log" | "latest_state")
16691 || serde_json::from_str::<Value>(schema_json).is_err()
16692 || serde_json::from_str::<Value>(retention_json).is_err()
16693 || contains_external_ref(schema_json)
16694 {
16695 return Err(EngineError::SchemaValidation);
16696 }
16697 Ok(WritePlan::AdminSchema)
16698 }
16699 PreparedWrite::OpStore { collection, record_key, schema_id, body } => {
16700 if collection.trim().is_empty() || record_key.trim().is_empty() {
16701 return Err(EngineError::WriteValidation);
16702 }
16703 let (kind, schema_json) = collection_metadata(connection, collection)?;
16704 if let Some(schema_id) = schema_id {
16705 if schema_id != collection {
16706 return Err(EngineError::SchemaValidation);
16707 }
16708 validate_payload(&schema_json, body)?;
16709 } else if serde_json::from_str::<Value>(body).is_err() {
16710 return Err(EngineError::SchemaValidation);
16711 }
16712
16713 match kind.as_str() {
16714 "append_only_log" => Ok(WritePlan::AppendOnlyLog),
16715 "latest_state" => Ok(WritePlan::LatestState),
16716 _ => Err(EngineError::OpStore),
16717 }
16718 }
16719 }
16720}
16721
16722fn collection_metadata(
16723 connection: &Connection,
16724 collection: &str,
16725) -> Result<(String, String), EngineError> {
16726 connection
16727 .query_row(
16728 "SELECT kind, schema_json FROM operational_collections WHERE name = ?1",
16729 [collection],
16730 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
16731 )
16732 .map_err(|_| EngineError::OpStore)
16733}
16734
16735fn validate_payload(schema_json: &str, body: &str) -> Result<(), EngineError> {
16736 let schema =
16737 serde_json::from_str::<Value>(schema_json).map_err(|_| EngineError::SchemaValidation)?;
16738 let payload = serde_json::from_str::<Value>(body).map_err(|_| EngineError::SchemaValidation)?;
16739
16740 let compiled = JSONSchema::compile(&schema).map_err(|_| EngineError::SchemaValidation)?;
16741 compiled.validate(&payload).map_err(|_| EngineError::SchemaValidation)?;
16742
16743 Ok(())
16744}
16745
16746fn contains_external_ref(schema_json: &str) -> bool {
16747 let Ok(value) = serde_json::from_str::<Value>(schema_json) else {
16748 return false;
16749 };
16750 value_contains_external_ref(&value)
16751}
16752
16753fn value_contains_external_ref(value: &Value) -> bool {
16754 match value {
16755 Value::Object(object) => object.iter().any(|(key, value)| {
16756 if key == "$ref" {
16757 return value.as_str().is_some_and(|uri| !uri.starts_with('#'));
16758 }
16759 value_contains_external_ref(value)
16760 }),
16761 Value::Array(values) => values.iter().any(value_contains_external_ref),
16762 _ => false,
16763 }
16764}
16765
16766// fix-30 [P2]: helpers to collect active edge write_cursors BEFORE a supersession
16767// UPDATE so the callers can prune stale vector_default rows.
16768fn prior_edge_cursors_by_logical_id(
16769 tx: &rusqlite::Transaction<'_>,
16770 logical_id: &str,
16771) -> rusqlite::Result<Vec<i64>> {
16772 let mut s = tx.prepare_cached(
16773 "SELECT write_cursor FROM canonical_edges \
16774 WHERE logical_id = ?1 AND superseded_at IS NULL",
16775 )?;
16776 let rows = s.query_map(params![logical_id], |r| r.get(0))?;
16777 rows.collect()
16778}
16779
16780/// 0.8.20 Slice 15d fix-1 finding 2 [P2] — the active (non-superseded) NODE
16781/// cursors for a `logical_id`, collected BEFORE the tombstone-then-insert
16782/// supersession UPDATE so the caller can purge the about-to-be-superseded row's
16783/// row-owned attribute projections. Mirrors [`prior_edge_cursors_by_logical_id`].
16784/// The partial-unique-active index means this is at most one cursor; a `Vec`
16785/// keeps it robust and symmetric with the edge path.
16786fn prior_node_cursors_by_logical_id(
16787 tx: &rusqlite::Transaction<'_>,
16788 logical_id: &str,
16789) -> rusqlite::Result<Vec<i64>> {
16790 let mut s = tx.prepare_cached(
16791 "SELECT write_cursor FROM canonical_nodes \
16792 WHERE logical_id = ?1 AND superseded_at IS NULL",
16793 )?;
16794 let rows = s.query_map(params![logical_id], |r| r.get(0))?;
16795 rows.collect()
16796}
16797
16798fn prior_edge_cursors_by_triple(
16799 tx: &rusqlite::Transaction<'_>,
16800 from: &str,
16801 to: &str,
16802 kind: &str,
16803) -> rusqlite::Result<Vec<i64>> {
16804 let mut s = tx.prepare_cached(
16805 "SELECT write_cursor FROM canonical_edges \
16806 WHERE from_id = ?1 AND to_id = ?2 AND kind = ?3 AND superseded_at IS NULL",
16807 )?;
16808 let rows = s.query_map(params![from, to, kind], |r| r.get(0))?;
16809 rows.collect()
16810}
16811
16812/// EXP-S (0.8.14 Slice 5, D2) — the set of coexisting indexes a `row_kind`
16813/// projects into. `fts` = the FTS index (`search_index`), written SYNCHRONOUSLY
16814/// in the write transaction; `vector` = the vec0 vector index, written
16815/// ASYNCHRONOUSLY by the projection worker pool (and additionally gated per
16816/// doc-type `kind` by [`kind_is_vector_indexed`]).
16817#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16818struct IndexTargetSet {
16819 fts: bool,
16820 vector: bool,
16821}
16822
16823/// 0.8.20 Slice 5a (R-20-E1) — the class a row-owned projection table belongs
16824/// to, so the four maintenance sites can each truncate exactly the subset they
16825/// own without re-deriving a hand-rolled table list.
16826///
16827/// - `NodeFts` — same-txn lexical projection of a canonical NODE body.
16828/// - `EdgeFts` — same-txn lexical projection of a canonical EDGE body.
16829/// - `Vector` — the async vec0 materialization (written by the embed worker,
16830/// not by the write path — see [`project_canonical_node_row`]).
16831/// - `Readiness` — the terminal-cursor bookkeeping that lets
16832/// `advance_projection_cursor` walk past a row.
16833#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16834enum ProjectionClass {
16835 NodeFts,
16836 EdgeFts,
16837 Vector,
16838 Readiness,
16839 /// 0.8.20 Slice 15d (R-20-EAV) — the EAV attribute store (`filterable` +
16840 /// the value-at-rest for `searchable`). Same-transaction, row-owned.
16841 Attribute,
16842 /// 0.8.20 Slice 15d (R-20-EAV) — the property-FTS5 shadow of attribute
16843 /// values (`searchable→FTS`). Same-transaction, row-owned.
16844 PropertyFts,
16845}
16846
16847/// 0.8.20 Slice 5a (R-20-E1) — one ROW-OWNED projection table: a shadow whose
16848/// rows are 1:1 with a canonical row's `write_cursor` and therefore MUST die
16849/// with that row.
16850#[derive(Clone, Copy, Debug)]
16851struct RowOwnedProjection {
16852 /// Table name. `'static` and never caller-derived: safe to interpolate.
16853 table: &'static str,
16854 /// The column carrying the owning canonical row's `write_cursor`. For the
16855 /// vec0 table this is `rowid` — vec0 rowid IS the write_cursor (see the
16856 /// `_fathomdb_vector_rows.write_cursor UNIQUE` identity).
16857 cursor_column: &'static str,
16858 class: ProjectionClass,
16859}
16860
16861/// 0.8.20 Slice 5a (R-20-E1) — **the** registry of row-owned projections.
16862///
16863/// Every table here is 1:1 with a canonical `write_cursor` and is erased by
16864/// [`erase_row_projections`] whenever that canonical row is erased. Adding a
16865/// projection table WITHOUT registering it here re-opens the defect this slice
16866/// closes (`search_index_v2` was written by one site and deleted by one site,
16867/// out of five that maintain projections — so `excise_source` left the erased
16868/// body on disk in a content-storing FTS5 table). The `guard_row_owned_registry`
16869/// unit test introspects `sqlite_master` and fails if a `write_cursor`-keyed
16870/// table is missing from this list.
16871///
16872/// **NOT here, deliberately (design v5 §1.1): `_fathomdb_projection_state`.**
16873/// That table is KIND-owned — keyed by `kind`, holding a per-kind enqueue
16874/// watermark. Erasing one row must NOT rewind a whole kind's watermark, so it
16875/// must never be deleted per-cursor. A rebuild resets it deliberately; erasure
16876/// leaves it alone.
16877const ROW_OWNED_PROJECTIONS: &[RowOwnedProjection] = &[
16878 RowOwnedProjection {
16879 table: "search_index",
16880 cursor_column: "write_cursor",
16881 class: ProjectionClass::NodeFts,
16882 },
16883 RowOwnedProjection {
16884 table: "search_index_v2",
16885 cursor_column: "write_cursor",
16886 class: ProjectionClass::NodeFts,
16887 },
16888 RowOwnedProjection {
16889 table: "search_index_edges",
16890 cursor_column: "write_cursor",
16891 class: ProjectionClass::EdgeFts,
16892 },
16893 RowOwnedProjection {
16894 table: "vector_default",
16895 cursor_column: "rowid",
16896 class: ProjectionClass::Vector,
16897 },
16898 RowOwnedProjection {
16899 table: "_fathomdb_vector_rows",
16900 cursor_column: "write_cursor",
16901 class: ProjectionClass::Vector,
16902 },
16903 RowOwnedProjection {
16904 table: "_fathomdb_projection_terminal",
16905 cursor_column: "write_cursor",
16906 class: ProjectionClass::Readiness,
16907 },
16908 // 0.8.20 Slice 15d (R-20-EAV) — the EAV attribute store and its property-FTS
16909 // shadow both hold declared attribute VALUES at rest (potential PII), keyed
16910 // 1:1 with the owning node's write_cursor. They MUST be reachable by
16911 // `purge`/`excise_source`: registering them here is what makes
16912 // `erase_row_projections` delete them without a hand-rolled list (an
16913 // unregistered content-storing table is exactly the `search_index_v2` leak
16914 // class this registry closes). The `guard_row_owned_registry` unit test
16915 // FAILS if either is left unregistered.
16916 RowOwnedProjection {
16917 table: "canonical_attributes",
16918 cursor_column: "write_cursor",
16919 class: ProjectionClass::Attribute,
16920 },
16921 RowOwnedProjection {
16922 table: "property_search_index",
16923 cursor_column: "write_cursor",
16924 class: ProjectionClass::PropertyFts,
16925 },
16926];
16927
16928/// 0.8.20 Slice 5a (R-20-E1) — erase EVERY row-owned projection for one
16929/// canonical `write_cursor`. Returns the number of shadow rows deleted.
16930///
16931/// This is the single erasure primitive: `purge_inner` and `excise_source_inner`
16932/// both call it, so a new projection table becomes erasable by registering it in
16933/// [`ROW_OWNED_PROJECTIONS`] — not by remembering to patch two hand-rolled
16934/// delete lists (the omission that left erased bodies in `search_index_v2`).
16935fn erase_row_projections(tx: &Connection, write_cursor: i64) -> rusqlite::Result<u64> {
16936 let mut deleted: u64 = 0;
16937 for projection in ROW_OWNED_PROJECTIONS {
16938 deleted =
16939 saturating_add_u64(deleted, delete_row_owned_projection(tx, projection, write_cursor)?);
16940 }
16941 Ok(deleted)
16942}
16943
16944/// TC-76 — delete one row-owned projection's rows for one `write_cursor`. The vec0
16945/// partition is routed through [`delete_vector_partition_row`] (sqlite-vec `#99`
16946/// makes a naked `DELETE` fail whenever a TEXT metadata value spills the 12-byte
16947/// inline view); every other table is the plain registry-driven statement.
16948fn delete_row_owned_projection(
16949 tx: &Connection,
16950 projection: &RowOwnedProjection,
16951 write_cursor: i64,
16952) -> rusqlite::Result<usize> {
16953 if projection.table == DEFAULT_VECTOR_PARTITION {
16954 return delete_vector_partition_row(tx, write_cursor);
16955 }
16956 let sql = format!("DELETE FROM {} WHERE {} = ?1", projection.table, projection.cursor_column);
16957 tx.execute(&sql, [write_cursor])
16958}
16959
16960fn saturating_add_u64(acc: u64, n: usize) -> u64 {
16961 acc.saturating_add(n as u64)
16962}
16963
16964/// 0.8.20 Slice 15d fix-1 finding 2 [P2] — purge the row-owned projections in
16965/// `classes` for ONE canonical `write_cursor`. Same registry-driven mechanism as
16966/// [`erase_row_projections`] (iterate [`ROW_OWNED_PROJECTIONS`], delete by the
16967/// declared cursor column) but scoped to a class SUBSET, so the write path can
16968/// drop a SUPERSEDED node's `Attribute` + `PropertyFts` rows — making the at-rest
16969/// property projection active-only — WITHOUT touching the `NodeFts`/`Vector`
16970/// shadows, whose stale rows the node read path already excludes by joining
16971/// `canonical_nodes WHERE superseded_at IS NULL`. Consistent with the erasure
16972/// model: an unregistered table is unreachable here, exactly as with erasure.
16973fn purge_row_projections_for_cursor_in(
16974 tx: &Connection,
16975 write_cursor: i64,
16976 classes: &[ProjectionClass],
16977) -> rusqlite::Result<u64> {
16978 let mut deleted: u64 = 0;
16979 for projection in ROW_OWNED_PROJECTIONS.iter().filter(|p| classes.contains(&p.class)) {
16980 deleted =
16981 saturating_add_u64(deleted, delete_row_owned_projection(tx, projection, write_cursor)?);
16982 }
16983 Ok(deleted)
16984}
16985
16986/// 0.8.20 Slice 5a (R-20-E1) — truncate the row-owned projections in `classes`.
16987/// Returns the number of shadow rows deleted.
16988fn truncate_row_projections_in(
16989 tx: &Connection,
16990 classes: &[ProjectionClass],
16991) -> rusqlite::Result<u64> {
16992 let mut deleted: u64 = 0;
16993 for projection in ROW_OWNED_PROJECTIONS.iter().filter(|p| classes.contains(&p.class)) {
16994 if projection.table == DEFAULT_VECTOR_PARTITION {
16995 // TC-76 (sqlite-vec `#99`) — an unqualified `DELETE FROM vector_default`
16996 // still dispatches vec0's per-row delete, so it fails on the FIRST row
16997 // whose TEXT metadata spills the 12-byte inline view. Blank the whole
16998 // column first.
16999 neutralize_vector_partition_attr_values(tx, None)?;
17000 }
17001 let sql = format!("DELETE FROM {}", projection.table);
17002 deleted = deleted.saturating_add(tx.execute(&sql, [])? as u64);
17003 }
17004 Ok(deleted)
17005}
17006
17007/// 0.8.20 Slice 5a (R-20-E1) — truncate EVERY row-owned projection (the full
17008/// `rebuild_projections` invalidation). Kind-owned watermark state
17009/// (`_fathomdb_projection_state`) is deliberately untouched; the rebuild resets
17010/// readiness by rewinding the projection cursor instead.
17011#[cfg(feature = "operator")]
17012fn truncate_all_row_projections(tx: &Connection) -> rusqlite::Result<u64> {
17013 truncate_row_projections_in(
17014 tx,
17015 &[
17016 ProjectionClass::NodeFts,
17017 ProjectionClass::EdgeFts,
17018 ProjectionClass::Vector,
17019 ProjectionClass::Readiness,
17020 ProjectionClass::Attribute,
17021 ProjectionClass::PropertyFts,
17022 ],
17023 )
17024}
17025
17026/// 0.8.20 Slice 5a (R-20-E1) — which half of a projector's work a call site
17027/// wants. The projectors are TOTAL (they own every row-owned projection for a
17028/// canonical row); the pass selects the subset a replay site is rebuilding, so
17029/// no call site re-implements projection SQL inline.
17030#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17031enum ProjectionPass {
17032 /// The write path: same-txn FTS **and** async vector enqueue / readiness
17033 /// termination.
17034 Write,
17035 /// Lexical replay only (the open-path tokenizer reproject). Readiness and
17036 /// vector state are already correct and must not be perturbed.
17037 FtsOnly,
17038 /// Readiness + async-vector replay only (`rebuild_vec0`, i.e. a rebuild with
17039 /// `include_fts = false`): the FTS shadows are not being rebuilt in this
17040 /// pass, so they must not be written.
17041 ///
17042 /// Only the `operator` rebuild seam constructs this pass, so the DEFAULT
17043 /// (recovery-clean) build sees it as unconstructed — same gate rationale as
17044 /// the operator methods themselves (feature = gate, not delete).
17045 #[cfg_attr(not(feature = "operator"), allow(dead_code))]
17046 VectorOnly,
17047}
17048
17049impl ProjectionPass {
17050 fn writes_fts(self) -> bool {
17051 matches!(self, ProjectionPass::Write | ProjectionPass::FtsOnly)
17052 }
17053
17054 fn writes_vector_state(self) -> bool {
17055 matches!(self, ProjectionPass::Write | ProjectionPass::VectorOnly)
17056 }
17057
17058 /// 0.8.20 Slice 15d (R-20-EAV) — whether this pass (re)projects the declared
17059 /// attribute set into the EAV store + property-FTS. Only the full `Write`
17060 /// pass does: the `FtsOnly` tokenizer-upgrade reproject predates step 24 (no
17061 /// registry/attribute tables exist at that migration point, so it must not
17062 /// touch them), and `VectorOnly` rebuilds only the async vector shadows. The
17063 /// operator FTS rebuild uses `Write`, so a full `rebuild_projections`
17064 /// re-derives attributes cleanly after `truncate_all_row_projections` clears
17065 /// the two attribute classes.
17066 fn writes_attributes(self) -> bool {
17067 matches!(self, ProjectionPass::Write)
17068 }
17069}
17070
17071/// 0.8.20 Slice 15d (R-20-PR) — the on-disk registry row for one declared
17072/// projection, read back from `_fathomdb_projection_registry`.
17073///
17074/// **On-disk encoding of the optional sub-objects.** The `fts_tokenizer` column
17075/// is tri-valued: SQL `NULL` = no `fts` sub-object; empty string `""` = `fts`
17076/// present with the engine-default tokenizer; a non-empty string = `fts` with a
17077/// custom tokenizer. This is what lets `searchable→FTS with default tokenizer`
17078/// be distinguished durably from `searchable` with no FTS sub-target. `vector`
17079/// mirrors it with an explicit `vector_declared` bit plus a nullable
17080/// `vector_embedder`.
17081#[derive(Clone, Debug, Eq, PartialEq)]
17082struct StoredProjection {
17083 roles: BTreeSet<ProjectionRole>,
17084 fts_present: bool,
17085 /// `Some(custom)` custom tokenizer; `None` = engine default (only
17086 /// meaningful when `fts_present`).
17087 fts_tokenizer: Option<String>,
17088 vector_declared: bool,
17089 vector_embedder: Option<String>,
17090}
17091
17092impl StoredProjection {
17093 /// True iff the declared roles want the attribute VALUE stored at rest in
17094 /// the EAV store: `filterable` (the value IS the filter target) or
17095 /// `searchable` (the value is the retrievable meaning, and Slice 20's vector
17096 /// embed will read it from here). `rankable`-only wants no value at rest.
17097 fn wants_eav(&self) -> bool {
17098 self.roles.contains(&ProjectionRole::Filterable)
17099 || self.roles.contains(&ProjectionRole::Searchable)
17100 }
17101
17102 /// True iff a `searchable→FTS` property-FTS row should be written: the
17103 /// `searchable` role AND an `fts` sub-object.
17104 fn wants_property_fts(&self) -> bool {
17105 self.roles.contains(&ProjectionRole::Searchable) && self.fts_present
17106 }
17107
17108 /// 0.8.20 Slice 21c (ledger `TC-71`) — **THE `searchable→vector` predicate.**
17109 /// True iff this declaration puts the attribute on the dense arm: the
17110 /// `searchable` role AND a `vector` sub-object. The exact analogue of
17111 /// [`StoredProjection::wants_property_fts`], for the same reason — the
17112 /// sub-object SELECTS a sub-target of `searchable`; it does not confer one.
17113 ///
17114 /// [`vector_projection_declared`] — the corpus-wide predicate gating all
17115 /// three enrolment paths (declare-time backfill, its drop inverse, and the
17116 /// write path's late enrolment) — routes through this so no call site can
17117 /// re-derive the rule and drift. Before it existed, that predicate keyed off
17118 /// `vector_declared` ALONE, so `{roles: [filterable], vector: {}}` — the
17119 /// combination Slice 15d documented as inert-but-round-trippable — enrolled
17120 /// node kinds, backfilled the corpus and made every later write enqueue an
17121 /// embedding in any session with a live embedder.
17122 ///
17123 /// **0.8.20 Slice 23 (`R-20-SV`):** that combination is no longer DECLARABLE
17124 /// — [`apply_projection_config`] rejects it as an invalid spec. This
17125 /// predicate still governs, because the shape survives at rest in every
17126 /// database that declared it while the engine accepted it, and it is read
17127 /// from the registry, not from a caller's spec.
17128 ///
17129 /// **Deliberately NOT the same as [`StoredProjection::has_deferred`]**, which
17130 /// keys off `vector_declared` alone and must keep doing so: that one feeds
17131 /// `ProjectionDelta.deferred`, a REPORTING field, and the round-trip contract
17132 /// wants a stored-but-unbuilt `vector` sub-object reported however it was
17133 /// declared. TC-71 changes what the engine DOES, not what it says.
17134 fn wants_vector(&self) -> bool {
17135 self.roles.contains(&ProjectionRole::Searchable) && self.vector_declared
17136 }
17137
17138 /// The `fts_tokenizer` column value: `None` (SQL NULL) when no `fts`
17139 /// sub-object, else the custom tokenizer or `""` for engine-default.
17140 fn fts_column(&self) -> Option<String> {
17141 if self.fts_present {
17142 Some(self.fts_tokenizer.clone().unwrap_or_default())
17143 } else {
17144 None
17145 }
17146 }
17147
17148 /// Build from the public [`ProjectionSpec`].
17149 ///
17150 /// 0.8.20 Slice 20 (R-20-DR) — note what is DELIBERATELY not read here:
17151 /// `spec.vector.dense_readiness`. Readiness is engine-set READ METADATA, not
17152 /// part of the declaration, so it never reaches the durable registry. That
17153 /// is what makes a caller-supplied value INERT (the engine always reports the
17154 /// derived truth) and what keeps it out of the destructive-change diff — a
17155 /// readiness difference can never look like a projection change.
17156 fn from_spec(spec: &ProjectionSpec) -> Self {
17157 StoredProjection {
17158 roles: spec.roles.clone(),
17159 fts_present: spec.fts.is_some(),
17160 fts_tokenizer: spec
17161 .fts
17162 .as_ref()
17163 .and_then(|f| f.tokenizer.clone())
17164 .filter(|t| !t.is_empty()),
17165 vector_declared: spec.vector.is_some(),
17166 vector_embedder: spec
17167 .vector
17168 .as_ref()
17169 .and_then(|v| v.embedder.clone())
17170 .filter(|e| !e.is_empty()),
17171 }
17172 }
17173
17174 /// Reconstruct the public [`ProjectionSpec`] for `read_projections`.
17175 fn to_spec(&self, name: &str) -> ProjectionSpec {
17176 ProjectionSpec {
17177 name: name.to_string(),
17178 roles: self.roles.clone(),
17179 fts: if self.fts_present {
17180 Some(ProjectionFts { tokenizer: self.fts_tokenizer.clone() })
17181 } else {
17182 None
17183 },
17184 vector: if self.vector_declared {
17185 // 0.8.20 Slice 20 (R-20-DR) — the registry knows nothing about
17186 // readiness (it is DERIVED, never stored), so the durable shape
17187 // reconstructs with `dense_readiness: None`.
17188 // [`Engine::read_projections`] fills it from
17189 // [`derive_dense_readiness`] on the way out.
17190 Some(ProjectionVector {
17191 embedder: self.vector_embedder.clone(),
17192 dense_readiness: None,
17193 })
17194 } else {
17195 None
17196 },
17197 }
17198 }
17199
17200 /// The set of ROLE spellings this declaration DEFERS rather than builds:
17201 /// `rankable` (F9 not live) and, since 15d builds no embedding, the
17202 /// `searchable→vector` sub-target. Used to populate `ProjectionDelta.deferred`.
17203 fn has_deferred(&self) -> bool {
17204 self.roles.contains(&ProjectionRole::Rankable) || self.vector_declared
17205 }
17206}
17207
17208/// 0.8.20 Slice 15d (R-20-PR) — is `name` a well-formed attribute name?
17209///
17210/// Establishes the invariant "a name that `configure_projections` ACCEPTS must be
17211/// POPULATABLE": the write-path extraction compiles the SQLite JSON path
17212/// `$."<name>"` (double-quoted key). A name must therefore round-trip through
17213/// that quoted-key form unchanged. Rejects:
17214/// - empty;
17215/// - a double-quote `"` (would terminate the quoted key early → malformed path,
17216/// ERRORing inside the write transaction);
17217/// - a BACKSLASH `\` (fix-4 finding 1 [P2]): SQLite treats `\` as an escape
17218/// introducer inside the double-quoted JSON-path key, so a body key literally
17219/// containing `\` (e.g. `a\b`) is NOT matched by `$."a\b"`. Pre-fix the name
17220/// was accepted yet the attribute silently NEVER populated
17221/// `canonical_attributes` — an accept-then-never-populate footgun. Rejecting
17222/// it keeps the accept ⟹ works contract (mirrors the TC-33 hard-reject
17223/// philosophy);
17224/// - any ASCII control char (incl. NUL): not a safe/legible key spelling and
17225/// not reliably matchable through the quoted-key form.
17226///
17227/// Projection names are app-declared identifiers, so this charset restriction is
17228/// a legitimate contract. Caller-supplied, so it is validated at
17229/// `configure_projections` time (spec names AND the `drop` list).
17230fn is_valid_attribute_name(name: &str) -> bool {
17231 !name.is_empty()
17232 && !name.contains('"')
17233 && !name.contains('\\')
17234 && !name.chars().any(|c| c.is_control())
17235}
17236
17237/// 0.8.20 Slice 15d — the SQLite JSON path that extracts attribute `name` from a
17238/// node body. `name` is pre-validated by [`is_valid_attribute_name`]; the path
17239/// is bound as a PARAMETER (never interpolated into SQL), so this is not an
17240/// injection surface even before that validation.
17241fn attribute_json_path(name: &str) -> String {
17242 format!("$.\"{name}\"")
17243}
17244
17245/// 0.8.20 Slice 15d (R-20-PR) — load the durable projection registry
17246/// (`_fathomdb_projection_registry`) into a name→[`StoredProjection`] map. This
17247/// is the derived-cache source (Q5) that boot re-derive and every
17248/// `configure_projections` diff read.
17249fn load_projection_registry(
17250 conn: &Connection,
17251) -> rusqlite::Result<BTreeMap<String, StoredProjection>> {
17252 let mut out = BTreeMap::new();
17253 // The registry table is created by schema step 24; a DB migrated to a
17254 // pre-24 head (e.g. a compatibility/partial-migration test open) does not
17255 // have it. Absent ⇒ no projections declared ⇒ empty registry, not an error.
17256 // This keeps boot re-derive and the write-path attribute projector safe on
17257 // every pre-24 schema.
17258 let table_exists: bool = conn
17259 .query_row(
17260 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_fathomdb_projection_registry'",
17261 [],
17262 |_| Ok(true),
17263 )
17264 .optional()?
17265 .unwrap_or(false);
17266 if !table_exists {
17267 return Ok(out);
17268 }
17269 let mut stmt = conn.prepare(
17270 "SELECT name, roles, fts_tokenizer, vector_embedder, vector_declared
17271 FROM _fathomdb_projection_registry",
17272 )?;
17273 let rows = stmt.query_map([], |row| {
17274 let name: String = row.get(0)?;
17275 let roles_json: String = row.get(1)?;
17276 let fts_tokenizer: Option<String> = row.get(2)?;
17277 let vector_embedder: Option<String> = row.get(3)?;
17278 let vector_declared: i64 = row.get(4)?;
17279 Ok((name, roles_json, fts_tokenizer, vector_embedder, vector_declared))
17280 })?;
17281 for row in rows {
17282 let (name, roles_json, fts_col, vector_embedder, vector_declared) = row?;
17283 let roles: BTreeSet<ProjectionRole> = parse_roles_json(&roles_json);
17284 let fts_present = fts_col.is_some();
17285 let fts_tokenizer = fts_col.filter(|t| !t.is_empty());
17286 out.insert(
17287 name,
17288 StoredProjection {
17289 roles,
17290 fts_present,
17291 fts_tokenizer,
17292 vector_declared: vector_declared != 0,
17293 vector_embedder,
17294 },
17295 );
17296 }
17297 Ok(out)
17298}
17299
17300/// Roles are persisted as a compact, sorted, comma-separated list (set
17301/// semantics; order-independent). Unknown tokens are ignored (forward-compat).
17302fn parse_roles_json(s: &str) -> BTreeSet<ProjectionRole> {
17303 s.split(',').filter_map(|t| ProjectionRole::from_str_opt(t.trim())).collect()
17304}
17305
17306fn roles_to_storage(roles: &BTreeSet<ProjectionRole>) -> String {
17307 roles.iter().map(|r| r.as_str()).collect::<Vec<_>>().join(",")
17308}
17309
17310/// 0.8.20 Slice 15d (R-20-PR) — write/overwrite one registry row.
17311fn persist_projection_row(
17312 tx: &Connection,
17313 name: &str,
17314 stored: &StoredProjection,
17315) -> rusqlite::Result<()> {
17316 tx.execute(
17317 "INSERT INTO _fathomdb_projection_registry
17318 (name, roles, fts_tokenizer, vector_embedder, vector_declared)
17319 VALUES(?1, ?2, ?3, ?4, ?5)
17320 ON CONFLICT(name) DO UPDATE SET
17321 roles = excluded.roles,
17322 fts_tokenizer = excluded.fts_tokenizer,
17323 vector_embedder = excluded.vector_embedder,
17324 vector_declared = excluded.vector_declared",
17325 params![
17326 name,
17327 roles_to_storage(&stored.roles),
17328 stored.fts_column(),
17329 stored.vector_embedder,
17330 i64::from(stored.vector_declared),
17331 ],
17332 )?;
17333 Ok(())
17334}
17335
17336/// 0.8.20 Slice 15d (R-20-PR) — delete one registry row.
17337fn remove_projection_row(tx: &Connection, name: &str) -> rusqlite::Result<()> {
17338 tx.execute("DELETE FROM _fathomdb_projection_registry WHERE name = ?1", params![name])?;
17339 Ok(())
17340}
17341
17342/// 0.8.20 Slice 15d (R-20-EAV) — delete every EAV + property-FTS row for one
17343/// attribute `name` (all owning nodes). The idempotent-rebuild primitive: a
17344/// changed or dropped projection clears its rows before (re)backfill.
17345fn clear_attribute_projection(tx: &Connection, name: &str) -> rusqlite::Result<()> {
17346 tx.execute("DELETE FROM property_search_index WHERE attr_name = ?1", params![name])?;
17347 tx.execute("DELETE FROM canonical_attributes WHERE attr_name = ?1", params![name])?;
17348 Ok(())
17349}
17350
17351/// 0.8.20 Slice 15d (R-20-EAV) — project ONE attribute value for ONE node row
17352/// into the EAV store and (if `searchable→FTS`) the property-FTS shadow. Skips a
17353/// NULL/absent extraction (an absent attribute means no row, so a `filterable`
17354/// equality simply never matches it — correct). Shared by the write path and
17355/// the backfill so they cannot drift.
17356fn project_one_attribute(
17357 tx: &Connection,
17358 cursor: i64,
17359 body: &str,
17360 name: &str,
17361 stored: &StoredProjection,
17362) -> rusqlite::Result<()> {
17363 if !stored.wants_eav() {
17364 return Ok(());
17365 }
17366 // json_extract over a non-JSON body would error; guard with json_valid so a
17367 // plain-text body simply yields no attribute rows. The canonical scalar
17368 // extraction is shared with the Slice-15e vec0 pre-KNN column via
17369 // [`extract_scalar_attribute`], so the EAV value and the `attr_<hex>` value
17370 // are IDENTICAL by construction.
17371 //
17372 // fix-1 finding 1 [P2] — project EVERY JSON scalar type, not just strings.
17373 // The prior form read the extraction as `Option<String>`; for a JSON number
17374 // or bool, `json_extract` returns an INTEGER/REAL, the `get::<Option<String>>`
17375 // conversion FAILED, and `.unwrap_or(None)` silently treated the attribute as
17376 // absent — so a numeric/boolean filterable value never projected. We now
17377 // render a single canonical TEXT form per JSON type, keyed on `json_type` so
17378 // the stored value is deterministic and the SAME value flows to BOTH
17379 // `canonical_attributes` and `property_search_index` (consistency by
17380 // construction — one `value` binding below):
17381 // - string -> the text verbatim
17382 // - integer -> decimal text (CAST AS TEXT); e.g. 3 -> "3"
17383 // - real -> decimal text (CAST AS TEXT); e.g. 3.5 -> "3.5"
17384 // - true -> "true", false -> "false" (preserve the JSON literal, NOT the
17385 // SQLite `1`/`0` that a bare `CAST(json_extract(...) AS TEXT)`
17386 // would yield — so a bool filter matches the value the caller
17387 // wrote, and "true" never collides with the number 1).
17388 // - null / absent path -> SQL NULL -> no row (an absent attribute correctly
17389 // never matches a `filterable` equality).
17390 // - object / array -> DELIBERATELY SKIPPED (SQL NULL -> no row): a composite
17391 // value is not a scalar filter/FTS target in 15d; projecting its
17392 // raw JSON text would be a footgun (nested-field filtering is the
17393 // >=0.9.x multi-field work). Skipping is deliberate, not an
17394 // accidental type-conversion drop — no scalar type is dropped.
17395 let Some(value) = extract_scalar_attribute(tx, body, name)? else {
17396 return Ok(());
17397 };
17398 tx.execute(
17399 "INSERT INTO canonical_attributes(write_cursor, attr_name, attr_value)
17400 VALUES(?1, ?2, ?3)",
17401 params![cursor, name, value],
17402 )?;
17403 if stored.wants_property_fts() {
17404 tx.execute(
17405 "INSERT INTO property_search_index(attr_value, attr_name, write_cursor)
17406 VALUES(?1, ?2, ?3)",
17407 params![value, name, cursor],
17408 )?;
17409 }
17410 Ok(())
17411}
17412
17413/// 0.8.20 Slice 15e fix-3 [P2] — the leading marker byte that the vec0
17414/// `attr_<hex>` FILTER column prepends to every PRESENT scalar value, so that
17415/// PRESENT and ABSENT are DISJOINT in a NOT-NULL TEXT column.
17416///
17417/// The `''` empty-string sentinel used to mean BOTH "attribute absent" AND
17418/// "attribute present with value `''`", so a `status == ""` equality filter
17419/// false-matched every absent row. vec0 TEXT metadata is NOT-NULL-able (TC-46
17420/// condition #3), so absent cannot be `NULL`; instead absent stays `''` and every
17421/// PRESENT value `V` is encoded `enc(V) = "\x01" || V`. This is injective and
17422/// non-empty for ALL `V` (including `V=""`, whose encoding is the bare marker),
17423/// so `attr_<hex> = enc("")` matches present-empty but NEVER the `''`-absent rows.
17424///
17425/// This encoding is CONFINED to the vec0 filter column and the filter-value
17426/// lowering ([`vector_filter_values`]). `property_search_index` (the searchable→FTS
17427/// projection) and `canonical_attributes.attr_value` keep the RAW value — the FTS
17428/// arm distinguishes absent from present-empty by canonical_attributes row
17429/// EXISTENCE instead (see [`hit_attributes_pass_filter`]).
17430const ATTR_VEC0_PRESENT_MARKER: char = '\u{1}';
17431
17432/// 0.8.20 Slice 15e fix-3 — encode a PRESENT scalar value for the vec0 filter
17433/// column / filter-value lowering (see [`ATTR_VEC0_PRESENT_MARKER`]). ABSENT is
17434/// NOT encoded (it stays the bare `''` sentinel), so this is only ever called on a
17435/// value known to be present.
17436fn encode_attr_vec0_present(value: &str) -> String {
17437 let mut s = String::with_capacity(value.len() + 1);
17438 s.push(ATTR_VEC0_PRESENT_MARKER);
17439 s.push_str(value);
17440 s
17441}
17442
17443/// 0.8.20 Slice 15e — extract the canonical TEXT form of attribute `name` from a
17444/// node `body`, using the SAME `json_type` CASE as [`project_one_attribute`] so
17445/// the vec0 pre-KNN `attr_<hex>` column value equals the EAV
17446/// `canonical_attributes` value (consistency by construction — a `filterable`
17447/// filter routed pre-KNN sees exactly what the EAV path stored). Returns `None`
17448/// for an absent / null / object / array / non-JSON extraction (⇒ the `''`
17449/// sentinel at the vec0 column, ⇒ fail-to-match).
17450fn extract_scalar_attribute(
17451 conn: &Connection,
17452 body: &str,
17453 name: &str,
17454) -> rusqlite::Result<Option<String>> {
17455 let path = attribute_json_path(name);
17456 let value: Option<String> = conn
17457 .query_row(
17458 "SELECT CASE WHEN json_valid(?1) THEN
17459 CASE json_type(?1, ?2)
17460 WHEN 'true' THEN 'true'
17461 WHEN 'false' THEN 'false'
17462 WHEN 'null' THEN NULL
17463 WHEN 'object' THEN NULL
17464 WHEN 'array' THEN NULL
17465 ELSE CAST(json_extract(?1, ?2) AS TEXT)
17466 END
17467 END",
17468 params![body, path],
17469 |row| row.get::<_, Option<String>>(0),
17470 )
17471 .unwrap_or(None);
17472 Ok(value)
17473}
17474
17475/// 0.8.20 Slice 15e — for a node `body`, build the `, attr_<hex>` column suffix,
17476/// the `, ?N` placeholder suffix (numbered from `start_idx`), and the bound TEXT
17477/// values for EVERY attribute column CURRENTLY on the live `vector_default` (read
17478/// from the table's own SQL, so the INSERT always matches the table shape exactly —
17479/// vec0 rejects a partial-column INSERT). Each value is the body's canonical
17480/// scalar extraction, or the `''` sentinel when absent. Returns empty fragments
17481/// (and no values) when the table has no attribute columns, so the INSERT stays
17482/// byte-identical to the shipped statement.
17483fn vector_attr_insert_fragments(
17484 conn: &Connection,
17485 body: &str,
17486 start_idx: usize,
17487) -> rusqlite::Result<(String, String, Vec<rusqlite::types::Value>)> {
17488 let cols = actual_vector_attr_columns(conn)?;
17489 let mut col_sql = String::new();
17490 let mut ph_sql = String::new();
17491 let mut values: Vec<rusqlite::types::Value> = Vec::new();
17492 for (i, col) in cols.iter().enumerate() {
17493 let name = decode_attr_vec0_column(col).unwrap_or_default();
17494 // fix-3 [P2] — a PRESENT scalar value is encoded `\x01 || V` so it is
17495 // DISJOINT from the `''`-absent sentinel (present-empty ⇒ the bare marker,
17496 // never `''`). Absent stays the bare `''` sentinel.
17497 let value = match extract_scalar_attribute(conn, body, &name)? {
17498 Some(v) => encode_attr_vec0_present(&v),
17499 None => String::new(),
17500 };
17501 col_sql.push_str(&format!(", {col}"));
17502 ph_sql.push_str(&format!(", ?{}", start_idx + i));
17503 values.push(rusqlite::types::Value::Text(value));
17504 }
17505 Ok((col_sql, ph_sql, values))
17506}
17507
17508/// 0.8.20 Slice 15d (R-20-EAV) — the write-path attribute projector: for a
17509/// just-inserted node, project EVERY declared attribute (reading the live
17510/// registry from `tx`). Same-transaction, so the node is filter/FTS-retrievable
17511/// on commit. A no-op when the registry is empty (the pre-`configure_projections`
17512/// default), so it costs one empty-table scan per node and is behaviour-neutral
17513/// until a projection is declared.
17514fn project_node_attributes(tx: &Connection, cursor: i64, body: &str) -> rusqlite::Result<()> {
17515 let registry = load_projection_registry(tx)?;
17516 for (name, stored) in ®istry {
17517 project_one_attribute(tx, cursor, body, name, stored)?;
17518 }
17519 Ok(())
17520}
17521
17522/// 0.8.20 Slice 15d (R-20-PR) — backfill ONE attribute across every ACTIVE,
17523/// non-superseded canonical node. Called by `configure_projections` when a
17524/// projection is added/changed (after `clear_attribute_projection`), and by boot
17525/// re-derive. Idempotent when paired with the clear.
17526fn backfill_attribute(
17527 tx: &Connection,
17528 name: &str,
17529 stored: &StoredProjection,
17530) -> rusqlite::Result<()> {
17531 if !stored.wants_eav() {
17532 return Ok(());
17533 }
17534 let rows: Vec<(i64, String)> = {
17535 let mut stmt = tx.prepare(
17536 "SELECT write_cursor, body FROM canonical_nodes
17537 WHERE superseded_at IS NULL AND state = 'active'",
17538 )?;
17539 let collected = stmt
17540 .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)))?
17541 .collect::<rusqlite::Result<Vec<_>>>()?;
17542 collected
17543 };
17544 for (cursor, body) in rows {
17545 project_one_attribute(tx, cursor, &body, name, stored)?;
17546 }
17547 Ok(())
17548}
17549
17550/// 0.8.20 Slice 15d (R-20-PR) — is `desired` an INCOMPATIBLE/DESTRUCTIVE change
17551/// to a live `existing` projection? A destructive change discards an
17552/// expensive-to-rebuild resource and so REQUIRES an explicit `drop` (C3): a role
17553/// REMOVAL, dropping the `fts`/`vector` sub-target, or changing the tokenizer /
17554/// embedder. Purely ADDITIVE changes (adding a role, adding an `fts`/`vector`
17555/// sub-object) are non-destructive and applied in place.
17556fn is_destructive_projection_change(
17557 existing: &StoredProjection,
17558 desired: &StoredProjection,
17559) -> bool {
17560 if existing.roles.iter().any(|r| !desired.roles.contains(r)) {
17561 return true;
17562 }
17563 if existing.fts_present
17564 && (!desired.fts_present || existing.fts_tokenizer != desired.fts_tokenizer)
17565 {
17566 return true;
17567 }
17568 if existing.vector_declared
17569 && (!desired.vector_declared || existing.vector_embedder != desired.vector_embedder)
17570 {
17571 return true;
17572 }
17573 false
17574}
17575
17576/// Human-readable summary of the destructive delta, surfaced in
17577/// [`EngineError::ProjectionDestructive`] so the caller sees WHAT it must drop.
17578fn describe_projection_delta(existing: &StoredProjection, desired: &StoredProjection) -> String {
17579 let mut parts: Vec<String> = Vec::new();
17580 for r in &existing.roles {
17581 if !desired.roles.contains(r) {
17582 parts.push(format!("role '{}' removed", r.as_str()));
17583 }
17584 }
17585 if existing.fts_present && !desired.fts_present {
17586 parts.push("fts sub-target removed".to_string());
17587 } else if existing.fts_present && existing.fts_tokenizer != desired.fts_tokenizer {
17588 parts.push("fts tokenizer changed".to_string());
17589 }
17590 if existing.vector_declared && !desired.vector_declared {
17591 parts.push("vector sub-target removed".to_string());
17592 } else if existing.vector_declared && existing.vector_embedder != desired.vector_embedder {
17593 parts.push("vector embedder changed".to_string());
17594 }
17595 if parts.is_empty() {
17596 "incompatible change".to_string()
17597 } else {
17598 parts.join("; ")
17599 }
17600}
17601
17602/// 0.8.20 Slice 15d (R-20-PR) — the declarative, idempotent diff+backfill apply
17603/// that backs [`Engine::configure_projections`]. Runs inside the caller's write
17604/// transaction `tx`. Order: apply `drop`s first (so a drop+re-declare in one
17605/// call rebuilds fresh), then diff each spec. Idempotent re-registration diffs to
17606/// an empty delta (`unchanged`). A destructive change without an explicit drop is
17607/// refused with [`EngineError::ProjectionDestructive`].
17608///
17609/// 0.8.20 Slice 20c (R-20-DR remainder) — returns `(delta, enqueued_backfill)`.
17610/// The second member is `true` iff [`enqueue_declared_vector_backfill`] put
17611/// deferred embed work on the queue, in which case the CALLER must
17612/// `notify_new_work()` after committing (the flag cannot ride on
17613/// [`ProjectionDelta`]: that is the caller-facing diff, and this is a runtime
17614/// signal, not part of the declaration's result).
17615///
17616/// 0.8.20 Slice 20c fix-1 (codex §9 [P2]) — and the symmetric inverse: a call
17617/// that removes the LAST `searchable→vector` declaration un-enrols the node kinds
17618/// the forward path enrolled ([`unenrol_registry_vector_node_kinds`]), on this
17619/// same transaction. It deletes no embedding.
17620///
17621/// 0.8.20 Slice 21 fix-1 (codex §9 round 1 [P2]) — and, beside that transition, a
17622/// state-keyed RECONCILIATION ([`registry_governs_an_inert_dense_arm`]) so that a
17623/// database already carrying an inert enrolment from before the Slice-21c role
17624/// gate is healed by any `configure_projections` call, not only by a
17625/// searchable-vector-to-none transition it may never perform. The boot arm is
17626/// [`reconcile_inert_vector_enrolments_on_boot`].
17627fn apply_projection_config(
17628 tx: &Connection,
17629 specs: &[ProjectionSpec],
17630 drop: &[String],
17631 dense_arm_live: bool,
17632) -> Result<(ProjectionDelta, bool), EngineError> {
17633 // Validate up-front so a bad name aborts before any write.
17634 //
17635 // fix-6 finding [P2] — REJECT a duplicate projection `name` within `specs`
17636 // (and a duplicate entry within `drop`) up front. The diff loop below diffs
17637 // every spec against the ONE pre-loop registry snapshot, so a name repeated
17638 // in `specs` diffed the SECOND spec against state that never saw the first
17639 // spec's just-persisted row: on a fresh DB `[status(searchable+fts),
17640 // status(rankable-only)]` reported `built=[status]` in the delta while the
17641 // registry ended rankable-only (which builds nothing) — the returned delta
17642 // DIVERGED from the persisted registry, breaking the fix-4 "accept ⟹ correct"
17643 // contract. A duplicate `drop` entry likewise reported the drop twice though
17644 // the row was removed once. A single request naming the same projection twice
17645 // is ambiguous/malformed, so we refuse it (rejection, not last-wins coalesce)
17646 // — a rejected request is a total no-op, keeping the registry and delta
17647 // consistent with the accepted input. A name that appears in BOTH `specs` and
17648 // `drop` is NOT a duplicate: that is the documented drop-then-rebuild-fresh
17649 // pattern (drops apply first, then the fresh spec builds), so it is allowed.
17650 let mut seen_spec_names: BTreeSet<&str> = BTreeSet::new();
17651 for spec in specs {
17652 if !is_valid_attribute_name(&spec.name) {
17653 return Err(EngineError::InvalidArgument {
17654 msg: format!("invalid projection attribute name: {:?}", spec.name),
17655 });
17656 }
17657 if spec.roles.is_empty() {
17658 return Err(EngineError::InvalidArgument {
17659 msg: format!("projection '{}' declares no roles", spec.name),
17660 });
17661 }
17662 if !seen_spec_names.insert(spec.name.as_str()) {
17663 return Err(EngineError::InvalidArgument {
17664 msg: format!("duplicate projection name in one request: '{}'", spec.name),
17665 });
17666 }
17667 }
17668 let mut seen_drop_names: BTreeSet<&str> = BTreeSet::new();
17669 for name in drop {
17670 if !is_valid_attribute_name(name) {
17671 return Err(EngineError::InvalidArgument {
17672 msg: format!("invalid projection drop name: {name:?}"),
17673 });
17674 }
17675 if !seen_drop_names.insert(name.as_str()) {
17676 return Err(EngineError::InvalidArgument {
17677 msg: format!("duplicate projection drop in one request: '{name}'"),
17678 });
17679 }
17680 }
17681
17682 // 0.8.20 Slice 23 (`R-20-SV`) — REJECT an `fts` or `vector` sub-object
17683 // declared WITHOUT the `searchable` role.
17684 //
17685 // HITL ruling 2026-07-24 (`dev/plans/plan-0.8.20.md` §11 item 4, option (b)):
17686 // *"it is a meaningless config; fail-fast matches the hard-reject philosophy,
17687 // and additive strictness is safe pre-1.0"*, to be implemented "at the next
17688 // `configure_projections` slice". This OVERTURNS the shipped 15d fix-4
17689 // position, which accepted the shape because it round-tripped faithfully.
17690 //
17691 // WHY it is meaningless: `searchable→FTS` and `searchable→vector` are TIER
17692 // LABELS, not roles ([`ProjectionRole`] has exactly three members). The
17693 // sub-objects SELECT a sub-target of `searchable`; they do not CONFER one —
17694 // both build predicates ([`StoredProjection::wants_property_fts`] and
17695 // [`StoredProjection::wants_vector`]) are conjunctions with
17696 // `roles.contains(Searchable)`. So without the role the declaration builds no
17697 // property-FTS, enrols no kind and embeds nothing: it names a sub-target of a
17698 // projection that does not exist. The reject is therefore keyed on the
17699 // ABSENCE of `searchable` and on nothing else — `filterable` / `rankable` are
17700 // orthogonal axes that neither supply nor substitute for it.
17701 //
17702 // FAMILY: [`EngineError::WriteValidation`], per decision #18 (0.8.20 Slice 22)
17703 // — the write-SHAPE boundary is ONE family, and this is a shape rejection.
17704 // Deliberately a SEPARATE loop from the name checks above: those are NAME
17705 // rejections that keep `InvalidArgument { msg }` because the message naming
17706 // the offending value is the caller's only handle on it. `dev/design/errors.md`
17707 // ("Validation boundary") states that split; keeping the two loops apart keeps
17708 // the split visible in the code and this change one-line-reversible.
17709 //
17710 // KNOWN COST (TC-95/TC-98, HITL-deferred): `WriteValidation` is a UNIT
17711 // variant, so this refusal cannot name WHICH spec in `specs` was invalid —
17712 // strictly worse than the name rejections above. Recorded, not worked around.
17713 for spec in specs {
17714 if spec.roles.contains(&ProjectionRole::Searchable) {
17715 continue;
17716 }
17717 if spec.fts.is_some() || spec.vector.is_some() {
17718 return Err(EngineError::WriteValidation);
17719 }
17720 }
17721
17722 let mut delta = ProjectionDelta::default();
17723
17724 // 0.8.20 Slice 20c fix-1 (codex §9 [P2]) — snapshot "is the dense arm
17725 // declared?" BEFORE any registry mutation. Together with the same read taken
17726 // after them it identifies the ONE transition that owns the inverse of this
17727 // slice's enrolment: declared -> not-declared. See
17728 // [`unenrol_registry_vector_node_kinds`] for why the inverse is keyed to that
17729 // TRANSITION rather than to the bare post-state.
17730 let vector_declared_before =
17731 vector_projection_declared(tx).map_err(|_| EngineError::Storage)?;
17732
17733 // (1) Explicit drops. Omission never drops (C3); only this list does.
17734 let before_drop = load_projection_registry(tx).map_err(|_| EngineError::Storage)?;
17735 for name in drop {
17736 if before_drop.contains_key(name) {
17737 clear_attribute_projection(tx, name).map_err(|_| EngineError::Storage)?;
17738 remove_projection_row(tx, name).map_err(|_| EngineError::Storage)?;
17739 delta.dropped.push(name.clone());
17740 }
17741 // dropping an absent projection is an idempotent no-op, not an error.
17742 }
17743
17744 // (2) Diff each spec against the post-drop registry.
17745 let current = load_projection_registry(tx).map_err(|_| EngineError::Storage)?;
17746 for spec in specs {
17747 let desired = StoredProjection::from_spec(spec);
17748 match current.get(&spec.name) {
17749 Some(existing) if existing == &desired => {
17750 // Idempotent re-registration — no-op (the keystone acceptance).
17751 }
17752 Some(existing) => {
17753 if is_destructive_projection_change(existing, &desired) {
17754 return Err(EngineError::ProjectionDestructive {
17755 name: spec.name.clone(),
17756 delta: describe_projection_delta(existing, &desired),
17757 });
17758 }
17759 persist_projection_row(tx, &spec.name, &desired)
17760 .map_err(|_| EngineError::Storage)?;
17761 clear_attribute_projection(tx, &spec.name).map_err(|_| EngineError::Storage)?;
17762 backfill_attribute(tx, &spec.name, &desired).map_err(|_| EngineError::Storage)?;
17763 if desired.wants_eav() {
17764 delta.built.push(spec.name.clone());
17765 }
17766 // 0.8.20 Slice 15e fix-2 finding 2 [P2] — this arm ONLY runs when
17767 // the registry row actually CHANGED (`existing != desired`), so the
17768 // delta MUST reflect that change; otherwise an accepted mutation
17769 // reports `unchanged = true` — a no-op lie to SDK callers. The prior
17770 // `&& !existing.has_deferred()` guard suppressed a deferred-ONLY
17771 // change (e.g. `rankable` → `rankable + vector`, which builds no EAV
17772 // so `built` stays empty): the row persisted but `delta` came back
17773 // empty. Mirror the fresh-registration push (`if
17774 // desired.has_deferred()`). Every valid non-empty spec has
17775 // `wants_eav()` OR `has_deferred()`, so on a real change at least one
17776 // of `built`/`deferred` is now populated ⇒ `unchanged` can never be
17777 // `true` on a persisted change. A genuine no-op (identical spec)
17778 // takes the idempotent arm above and is untouched.
17779 if desired.has_deferred() {
17780 delta.deferred.push(spec.name.clone());
17781 }
17782 }
17783 None => {
17784 persist_projection_row(tx, &spec.name, &desired)
17785 .map_err(|_| EngineError::Storage)?;
17786 clear_attribute_projection(tx, &spec.name).map_err(|_| EngineError::Storage)?;
17787 backfill_attribute(tx, &spec.name, &desired).map_err(|_| EngineError::Storage)?;
17788 if desired.wants_eav() {
17789 delta.built.push(spec.name.clone());
17790 }
17791 if desired.has_deferred() {
17792 delta.deferred.push(spec.name.clone());
17793 }
17794 }
17795 }
17796 }
17797
17798 // 0.8.20 Slice 15e — after the registry mutations, reconcile the live vec0
17799 // shape with the (possibly changed) `filterable` set: a NON-DESTRUCTIVE
17800 // reshape adds/removes the `attr_<hex>` pre-KNN columns preserving every
17801 // row's embedding (TC-46, HITL Option 1). Runs on the caller's write
17802 // transaction, so the reshape commits atomically with the registry row. On an
17803 // idempotent re-registration the desired set equals the live set, so this is a
17804 // no-op (vec0 untouched) and `delta.unchanged` above is unaffected. Skipped
17805 // when there is no embedder profile (⇒ no `vector_default` to reshape).
17806 if let Ok(dimension) = default_profile_dimension(tx) {
17807 reconcile_vector_attr_columns(tx, dimension).map_err(|_| EngineError::Storage)?;
17808 }
17809
17810 delta.unchanged =
17811 delta.built.is_empty() && delta.dropped.is_empty() && delta.deferred.is_empty();
17812
17813 // 0.8.20 Slice 20c (R-20-DR remainder) — THE C4 RIDER. Everything above has
17814 // only *persisted* the `searchable→vector` declaration and pushed its name
17815 // onto `delta.deferred`. Acknowledging deferred work and then dropping it on
17816 // the floor is what made `drain` a FALSE-READY barrier; this call is where the
17817 // deferred work is actually enqueued onto the runtime `drain` waits on.
17818 //
17819 // fix-1 (codex §9 [P2]) — and its SYMMETRIC INVERSE, on the same
17820 // transaction. If this call removed the last `searchable→vector` declaration,
17821 // un-enrol the node kinds the forward path enrols; otherwise enrolment is a
17822 // one-way door and the write path keeps embedding for a projection the
17823 // registry no longer declares.
17824 let vector_declared_after = vector_projection_declared(tx).map_err(|_| EngineError::Storage)?;
17825 let enqueued = if vector_declared_after {
17826 // 0.8.20 Slice 22 (R-20-VC / TC-67) — THE REPORT. Scoped to a live dense
17827 // -arm declaration (with no `searchable→vector` projection there is
17828 // nothing for a kind to be unsupported FOR, so reporting would be noise
17829 // on every `filterable`/FTS-only call), but deliberately OUTSIDE the
17830 // `dense_arm_live` gate below — see [`unsupported_vector_kinds`].
17831 //
17832 // Placed AFTER `delta.unchanged` is computed, and it does not feed it:
17833 // this is a STATE report, not a diff, so an idempotent re-apply still
17834 // carries it (that is also the documented refresh path for the
17835 // declare-time residual).
17836 delta.vector_unsupported_kinds =
17837 unsupported_vector_kinds(tx).map_err(|_| EngineError::Storage)?;
17838 if dense_arm_live {
17839 enqueue_declared_vector_backfill(tx).map_err(|_| EngineError::Storage)?
17840 } else {
17841 false
17842 }
17843 } else {
17844 // fix-1 (codex §9 round 1 [P2], ledger `TC-71`) — the transition arm is
17845 // KEPT as-is and a state-keyed reconciliation is added BESIDE it; neither
17846 // subsumes the other. The transition fires when this very call removed the
17847 // last dense-arm declaration, including the case where it removed the last
17848 // `vector` sub-object with it (which leaves
17849 // `registry_governs_an_inert_dense_arm` false). The reconciliation covers
17850 // the ALREADY-AFFECTED database whose user calls `configure_projections`
17851 // again with anything at all: there `before` is already `false`, so the
17852 // transition arm is inert and the inert enrolment used to survive
17853 // indefinitely. `||` short-circuits, so the transition case pays nothing
17854 // extra; the other case pays two cached `EXISTS` probes on a governed call,
17855 // never on the hot write path.
17856 if vector_declared_before
17857 || registry_governs_an_inert_dense_arm(tx).map_err(|_| EngineError::Storage)?
17858 {
17859 unenrol_registry_vector_node_kinds(tx).map_err(|_| EngineError::Storage)?;
17860 }
17861 false
17862 };
17863 Ok((delta, enqueued))
17864}
17865
17866/// 0.8.20 Slice 20c fix-1 (codex §9 [P2] "Stop embedding after vector projection
17867/// drops") — **the inverse of [`enqueue_declared_vector_backfill`]'s enrolment.**
17868///
17869/// Slice 20c gave `_fathomdb_vector_kinds` its first governed-call-reachable
17870/// writer for a NODE kind (before it, the only one was the `#[doc(hidden)]`
17871/// `configure_vector_kind_for_test` hook). Forward without reverse is the defect:
17872/// after `drop`ping the last `searchable→vector` declaration,
17873/// [`project_canonical_node_row`]'s `kind_is_vector_indexed` gate and
17874/// [`connection_has_pending_projection_work`] both still see the enrolment, so
17875/// subsequent writes keep enqueueing embeds and `drain` keeps waiting on work for
17876/// a projection [`Engine::read_projections`] no longer reports.
17877///
17878/// # It DELETES NO EMBEDDING — that is the point
17879///
17880/// The shipped drop arm ([`clear_attribute_projection`] +
17881/// [`remove_projection_row`]) has never touched vec0, `_fathomdb_vector_rows` or
17882/// `_fathomdb_vector_kinds`, so "vectors already at rest survive a drop" is
17883/// ALREADY the shipped contract. Removing one registry row PRESERVES it; deleting
17884/// embeddings would be the destructive delta, and is not done here.
17885///
17886/// # Why keyed to the TRANSITION, not to the bare post-state
17887///
17888/// The rule is "this call removed the last vector declaration"
17889/// (`declared_before && !declared_after`), not "no vector declaration exists
17890/// now". A workspace can hold enrolments this registry never made — the test hook
17891/// does exactly that, and several shipped suites enrol a kind through it and then
17892/// declare an unrelated `filterable`-only projection (e.g.
17893/// `slice15e_prekn_filterable`). Firing on the bare post-state would un-enrol
17894/// those and silently kill a dense arm the registry never owned. In production
17895/// the two readings coincide: before this slice
17896/// `production_vector_kind_surface=[]`, so a node kind can only be enrolled
17897/// because a `searchable→vector` declaration existed.
17898///
17899/// It is still STATE-keyed, not delta-keyed: both members are reads of the
17900/// registry, never "was this spec new". Re-applying the same drop finds
17901/// `declared_before == false` and is a total no-op, and nothing re-enrols it
17902/// ([`Engine::enrol_vector_kind_if_declared`] is gated on
17903/// [`vector_projection_declared`]).
17904///
17905/// # 0.8.20 Slice 21 fix-1 — a SECOND, narrower authorisation now exists
17906///
17907/// The reasoning above is why the bare post-state cannot authorise this DELETE,
17908/// and it still stands. What it does not cover is a database that ran the
17909/// PRE-Slice-21c code and enrolled node kinds off a `{filterable, vector}`
17910/// declaration: there the registry DID own the enrolment, and no transition will
17911/// ever fire for it. [`registry_governs_an_inert_dense_arm`] adds exactly that
17912/// case — positively conditioned on the registry existing AND declaring a
17913/// `vector` sub-object AND declaring no `searchable→vector` projection, which is
17914/// strictly narrower than the bare post-state and in particular excludes every
17915/// workspace whose enrolment the registry never made. Its callers are
17916/// [`reconcile_inert_vector_enrolments_on_boot`] and the drop arm of
17917/// [`apply_projection_config`].
17918///
17919/// # `'edge_fact'` is excluded, deliberately
17920///
17921/// [`project_canonical_edge_row`] (G11) auto-registers `'edge_fact'` off the
17922/// presence of an edge BODY, unconditionally and independently of the projection
17923/// registry. That lifecycle predates this slice and is not the registry's to end,
17924/// so a node-projection drop must not take the edge dense arm down with it.
17925///
17926/// # What it deliberately does NOT do
17927///
17928/// It touches no `_fathomdb_projection_terminal` row and no readiness watermark.
17929/// A row enqueued-but-not-yet-embedded when the drop lands keeps its absent
17930/// terminal, which pins the watermark below it — harmless, because both the
17931/// scheduler and the pending-work probe join `_fathomdb_vector_kinds` and so no
17932/// longer see it, and it is precisely what lets a later RE-declaration pick the
17933/// row up again instead of stranding it.
17934fn unenrol_registry_vector_node_kinds(tx: &Connection) -> rusqlite::Result<()> {
17935 tx.execute("DELETE FROM _fathomdb_vector_kinds WHERE kind <> 'edge_fact'", [])?;
17936 Ok(())
17937}
17938
17939/// 0.8.20 Slice 21 fix-1 (codex §9 round 1 `[P2]`, ledger `TC-71`) — **does the
17940/// registry GOVERN the dense arm while declaring none?** The narrow,
17941/// positively-conditioned predicate that authorises
17942/// [`unenrol_registry_vector_node_kinds`] on a bare STATE rather than on the
17943/// `declared_before && !declared_after` transition.
17944///
17945/// # Why a state-keyed authorisation exists at all
17946///
17947/// Slice 21c gated the dense arm on the `searchable` ROLE, which closes the three
17948/// FORWARD doors. It cannot reach a database that already ran the old code: those
17949/// node kinds are already in `_fathomdb_vector_kinds`, and
17950///
17951/// - [`Engine::vector_kind_needs_enrolment`] returns early the moment
17952/// [`kind_is_vector_indexed`] is true, so it never consults the new role-aware
17953/// predicate for an EXISTING registration; and
17954/// - [`project_canonical_node_row`] gates the embed enqueue solely on registry
17955/// membership (deliberately — that is the hot write path, and the decision is
17956/// meant to live upstream).
17957///
17958/// So without this, upgrading does not actually stop the billable, unexpected
17959/// embeddings for exactly the population TC-71 was raised for — the finding's
17960/// whole harm survives the fix unless the user happens to perform a
17961/// searchable-vector-to-none transition later.
17962///
17963/// # THE TRAP: why it is not `!vector_projection_declared`
17964///
17965/// [`vector_projection_declared`] answers `false` when the registry table is
17966/// ABSENT (pre-step-24) or merely EMPTY — which is every LEGACY database, many of
17967/// which have a legitimately working dense arm enrolled by other means (the
17968/// `#[doc(hidden)]` `configure_vector_kind_for_test` hook is one; before this
17969/// slice `production_vector_kind_surface=[]`, but a workspace is not obliged to
17970/// have reached its enrolment through the registry). Un-enrolling on that bare
17971/// negative would silently switch vector search OFF for all of them — a far worse
17972/// regression than TC-71 itself. So the rule is POSITIVE on all three counts:
17973///
17974/// 1. `_fathomdb_projection_registry` EXISTS; **and**
17975/// 2. at least one row carries a `vector` sub-object (`vector_declared = 1`) —
17976/// someone actually asked for a dense arm through the registry, which is
17977/// precisely what identifies the affected population; **and**
17978/// 3. NO projection satisfies [`StoredProjection::wants_vector`], i.e. none of
17979/// them is `searchable`.
17980///
17981/// Condition 2 is the load-bearing one. It leaves untouched a registry-governed
17982/// database that declares no `vector` sub-object at all but holds enrolments from
17983/// a pre-registry era (`slice15e_prekn_filterable` is exactly that shape). Being
17984/// conservative here is the correct direction: never destroy a working dense arm.
17985///
17986/// Conditions 1+2 are the SAME two `prepare_cached` `EXISTS` probes
17987/// [`vector_projection_declared`] opens with, so a workspace that never declared
17988/// a `vector` sub-object — the overwhelmingly common shape — pays nothing beyond
17989/// them and never reaches the typed [`load_projection_registry`] read. Condition 3
17990/// is delegated to [`vector_projection_declared`] verbatim rather than re-derived,
17991/// so the authorisation and the gate cannot drift.
17992fn registry_governs_an_inert_dense_arm(conn: &Connection) -> rusqlite::Result<bool> {
17993 // (1) the registry must EXIST. A pre-step-24 database has no registry at all
17994 // and is therefore not registry-governed — hands off.
17995 let table_exists: bool = conn
17996 .prepare_cached(
17997 "SELECT EXISTS(
17998 SELECT 1 FROM sqlite_master
17999 WHERE type = 'table' AND name = '_fathomdb_projection_registry'
18000 )",
18001 )?
18002 .query_row([], |row| row.get(0))?;
18003 if !table_exists {
18004 return Ok(false);
18005 }
18006 // (2) …and it must actually DECLARE a `vector` sub-object somewhere. An empty
18007 // or vector-less registry governs no dense arm, so any enrolment present came
18008 // from outside it and is not ours to remove.
18009 let any_vector_subobject: bool = conn
18010 .prepare_cached(
18011 "SELECT EXISTS(SELECT 1 FROM _fathomdb_projection_registry WHERE vector_declared = 1)",
18012 )?
18013 .query_row([], |row| row.get(0))?;
18014 if !any_vector_subobject {
18015 return Ok(false);
18016 }
18017 // (3) …while declaring no `searchable→vector` projection. THE predicate,
18018 // reused, so this can never disagree with the gate the write path applies.
18019 Ok(!vector_projection_declared(conn)?)
18020}
18021
18022/// 0.8.20 Slice 21 fix-1 (codex §9 round 1 `[P2]`) — the BOOT arm of the
18023/// reconciliation: on every open, bring an already-enrolled inert vector kind
18024/// into agreement with the role-aware decision, so an affected database
18025/// self-heals without the user calling anything. Returns `true` iff it un-enrolled
18026/// something.
18027///
18028/// Authorised by [`registry_governs_an_inert_dense_arm`] (read that for the trap
18029/// this must not fall into), and performed by
18030/// [`unenrol_registry_vector_node_kinds`] — the SAME writer the drop inverse uses,
18031/// so `'edge_fact'` is excluded (G11 auto-registers it off the presence of an edge
18032/// body, independently of the projection registry) and **no embedding is deleted**.
18033///
18034/// # It mirrors the drop inverse exactly, because that inverse does nothing else
18035///
18036/// `apply_projection_config`'s drop arm is a single call to
18037/// [`unenrol_registry_vector_node_kinds`]: no terminal record is touched, no
18038/// readiness watermark is rewound, no row is un-stranded, and nothing is notified
18039/// (it returns `enqueued = false`). So leaving the database in "the state a drop
18040/// transition would have left it in" is exactly that one `DELETE`, and there is
18041/// no second half to mirror.
18042///
18043/// # Cheap when there is nothing to do, and idempotent
18044///
18045/// A workspace with no `vector` sub-object pays only the two cached `EXISTS`
18046/// probes the authorisation opens with. When the authorisation DOES fire, a third
18047/// cached `EXISTS` checks whether any node kind is actually enrolled, so the
18048/// steady state after the first healing open is a pure READ — no write
18049/// transaction, no `DELETE`, nothing to oscillate. `DELETE … WHERE kind <>
18050/// 'edge_fact'` is a single statement, hence atomic on its own; no explicit
18051/// transaction is opened around it.
18052///
18053/// # Placement
18054///
18055/// Runs inside `open_locked` on the writer connection, single-threaded, before
18056/// readers and the projection workers spawn — alongside the other boot
18057/// reconciliations ([`rederive_projections_on_boot`],
18058/// [`reconcile_vector_attr_columns`]) and therefore BEFORE
18059/// [`run_vector_equivalence_probe`], which is deliberate: on a database whose only
18060/// enrolment was the inert one, reconciling first leaves `_fathomdb_vector_kinds`
18061/// empty, so the probe correctly finds no dense arm to guard and the healing open
18062/// spends no embed calls at all.
18063///
18064/// # Not a data migration
18065///
18066/// It removes a registration row inside ONE live database to match that
18067/// database's own declarations. It converts no row across a version step, and
18068/// `SCHEMA_VERSION` stays 24.
18069fn reconcile_inert_vector_enrolments_on_boot(conn: &Connection) -> rusqlite::Result<bool> {
18070 if !registry_governs_an_inert_dense_arm(conn)? {
18071 return Ok(false);
18072 }
18073 // Nothing enrolled beyond the G11 edge arm ⇒ nothing to do. Keeps the steady
18074 // state a pure read instead of a no-op write transaction on every open.
18075 let any_node_kind: bool = conn
18076 .prepare_cached(
18077 "SELECT EXISTS(SELECT 1 FROM _fathomdb_vector_kinds WHERE kind <> 'edge_fact')",
18078 )?
18079 .query_row([], |row| row.get(0))?;
18080 if !any_node_kind {
18081 return Ok(false);
18082 }
18083 unenrol_registry_vector_node_kinds(conn)?;
18084 Ok(true)
18085}
18086
18087/// 0.8.20 Slice 20c (R-20-DR remainder) — is ANY `searchable→vector` projection
18088/// declared in the durable registry?
18089///
18090/// This is the corpus-wide "the dense arm is live" predicate. It is corpus-wide
18091/// rather than per-attribute for the same reason [`derive_dense_readiness`] is:
18092/// Slice 15d persists the `searchable→vector` sub-object but defers building any
18093/// per-attribute embedding, so every declared vector projection is served by the
18094/// ONE engine vector pipeline. When per-attribute embedding lands, this is where
18095/// the scoping goes — the same seam as readiness.
18096///
18097/// Safe on a pre-step-24 schema (the registry table is created by step 24): an
18098/// absent table means nothing is declared, not an error. Mirrors the guard in
18099/// [`load_projection_registry`], and uses `prepare_cached` because the write path
18100/// calls this once per un-registered-kind row.
18101///
18102/// # 0.8.20 Slice 21c (ledger `TC-71`) — it requires the `searchable` ROLE
18103///
18104/// This used to answer `EXISTS(… WHERE vector_declared = 1)`, reading the stored
18105/// `vector` sub-object and never the `roles` column. But the sub-object SELECTS
18106/// a sub-target of `searchable`; it does not confer one (exactly as `fts` does
18107/// not — see [`StoredProjection::wants_property_fts`]). So
18108/// `{roles: [filterable], vector: {}}`, which Slice 15d documents as
18109/// inert-but-round-trippable, turned the dense arm ON in any session with a live
18110/// embedder: it enrolled node kinds, backfilled the corpus, and made every later
18111/// write of those kinds enqueue an embedding. Wasted embed work and unexpected
18112/// vectors at rest for a projection meant to do nothing. The answer now comes
18113/// from [`StoredProjection::wants_vector`], the ONE predicate, so the three
18114/// gated paths cannot drift.
18115///
18116/// **This flips the forward AND inverse arms of [`apply_projection_config`] at
18117/// once**, which is a real semantic consequence and not an accident: demoting
18118/// the last `{searchable, vector}` projection to `{filterable, vector}` (or
18119/// dropping it while an inert `{filterable, vector}` sibling survives) now reads
18120/// `declared → not-declared` and therefore UN-ENROLS, where before the surviving
18121/// `vector_declared = 1` row masked the transition and the write path kept
18122/// embedding. Pinned in `tests/slice21c_vector_role_gate.rs`.
18123///
18124/// # Why the cheap `EXISTS` survives as a pre-filter
18125///
18126/// The write path calls this once per un-registered-kind row, and the
18127/// overwhelmingly common shape is a workspace that declared no `vector`
18128/// sub-object at all. `EXISTS(… vector_declared = 1)` is a NECESSARY condition
18129/// for [`StoredProjection::wants_vector`], so keeping it as a fast negative
18130/// leaves that workspace paying exactly the two cached `EXISTS` probes it paid
18131/// before — no typed load, no `BTreeMap`, no uncached `prepare`. Only a
18132/// workspace that HAS a `vector` sub-object somewhere pays the
18133/// [`load_projection_registry`] read, and there the registry is a handful of
18134/// app-declared rows; in the ordinary `searchable→vector` case the kind is
18135/// enrolled after the first probe and `kind_is_vector_indexed` short-circuits
18136/// this call entirely from then on.
18137fn vector_projection_declared(conn: &Connection) -> rusqlite::Result<bool> {
18138 let table_exists: bool = conn
18139 .prepare_cached(
18140 "SELECT EXISTS(
18141 SELECT 1 FROM sqlite_master
18142 WHERE type = 'table' AND name = '_fathomdb_projection_registry'
18143 )",
18144 )?
18145 .query_row([], |row| row.get(0))?;
18146 if !table_exists {
18147 return Ok(false);
18148 }
18149 // Fast negative: no `vector` sub-object anywhere ⇒ certainly no dense arm.
18150 let any_vector_subobject: bool = conn
18151 .prepare_cached(
18152 "SELECT EXISTS(SELECT 1 FROM _fathomdb_projection_registry WHERE vector_declared = 1)",
18153 )?
18154 .query_row([], |row| row.get(0))?;
18155 if !any_vector_subobject {
18156 return Ok(false);
18157 }
18158 // `roles` is persisted as a comma-joined sorted string, so it is not a
18159 // trustworthy SQL predicate (a `LIKE` would match a forward-compat token that
18160 // merely CONTAINS a role spelling). Answer through the typed registry and the
18161 // ONE predicate instead.
18162 Ok(load_projection_registry(conn)?.values().any(StoredProjection::wants_vector))
18163}
18164
18165/// 0.8.20 Slice 20c (R-20-DR remainder) — enrol `kind` in the vector pipeline.
18166///
18167/// `INSERT OR IGNORE`, so it is idempotent and never disturbs an existing
18168/// registration's `profile`/`created_at`. Same statement shape the G11 edge path
18169/// uses for `'edge_fact'` ([`project_canonical_edge_row`]).
18170fn register_vector_kind(tx: &Connection, kind: &str) -> rusqlite::Result<()> {
18171 tx.execute(
18172 "INSERT OR IGNORE INTO _fathomdb_vector_kinds(kind, profile, created_at)
18173 VALUES(?1, ?2, 0)",
18174 params![kind, DEFAULT_VECTOR_PROFILE],
18175 )?;
18176 Ok(())
18177}
18178
18179/// 0.8.20 Slice 20c (R-20-DR remainder) — **the flush barrier's enqueue half**
18180/// (`api-surface.md` **C4** rider: `drain` is a barrier, not a trigger, so
18181/// deferred/backfill rows must be enqueued on the same projection runtime `drain`
18182/// waits on).
18183///
18184/// Runs on the caller's `configure_projections` write transaction, AFTER the
18185/// registry mutations, so the enrolment + re-enqueue commit atomically with the
18186/// declaration that caused them. Returns `true` iff work was enqueued — the
18187/// caller must then `notify_new_work()` (after the commit; the dispatcher opens
18188/// its own connection).
18189///
18190/// # The defect this closes
18191///
18192/// `project_canonical_node_row` writes a PERMANENT `'up_to_date'` terminal for
18193/// any row whose kind was not vector-registered *at write time*, and before this
18194/// slice NOTHING but the `#[doc(hidden)]` test hook ever registered a node kind
18195/// (`slice-G0-design.md`: `production_vector_kind_surface=[]`). So the ordinary
18196/// "turn the dense arm on over an existing corpus" flow — write rows, then
18197/// declare `searchable→vector` — left every row terminally marked done with no
18198/// vector and no way to get one short of an operator `rebuild`. Both
18199/// `drain`/`wait_for_idle` and `derive_dense_readiness` read that terminal
18200/// through [`connection_has_pending_projection_work`], so the corpus reported
18201/// `ready` while nothing would ever embed it.
18202///
18203/// # Shape (deliberately the `run_rebuild` shape, scoped)
18204///
18205/// `run_rebuild` truncates the readiness terminals and rewinds the projection
18206/// cursor so the scheduler re-walks the corpus. This does the same, but scoped to
18207/// the rows the declaration newly covers, and it does NOT truncate anything else:
18208///
18209/// 1. enrol every vector-eligible node kind present in `canonical_nodes`
18210/// (`row_kind IN ('leaf','coverage')` — the `index_targets_for_row_kind`
18211/// vector-eligibility predicate; `graph` rows are lexically searchable but
18212/// never embedded, so enrolling on them would silently start embedding
18213/// structural rows) **that the vector writer can commit**
18214/// ([`kind_is_vector_committable`], fix-2 / codex §9 [P1]);
18215/// 2. (and 3.) un-strand the rows that enrolment now covers, via
18216/// [`reenqueue_stranded_vector_rows`] — shared verbatim with the write path's
18217/// late enrolment.
18218///
18219/// # Why it is IDEMPOTENT (R-20-PR: "re-registration is a no-op")
18220///
18221/// Every step keys off *state*, not off "was this declaration new": step 1 is
18222/// `INSERT OR IGNORE`; steps 2-3 act only on rows that are stranded RIGHT NOW.
18223/// Once the backfill has been drained those rows carry vectors, so a re-apply
18224/// finds an empty stranded set, returns `false`, and touches neither the
18225/// terminals nor the cursor. No rewind, no re-embed, no spurious `embedding`
18226/// window.
18227///
18228/// # Not a data migration
18229///
18230/// This re-enqueues embed work inside ONE live database at the caller's request.
18231/// It converts no rows across a version step, and `SCHEMA_VERSION` stays 24
18232/// (HITL 2026-07-21; cf. TC-46's in-place vec0 reshape).
18233/// 0.8.20 Slice 22 (R-20-VC / **TC-67**) — the ONE scan of "which node kinds in
18234/// this corpus are candidates for the dense arm?".
18235///
18236/// `row_kind IN ('leaf', 'coverage')` is the `index_targets_for_row_kind` vector
18237/// -eligibility predicate: `graph` rows are lexically searchable but NEVER
18238/// embedded, so they are excluded here on a ROW-KIND axis that has nothing to do
18239/// with the `kind` vocabulary — including them would make TC-67 report structural
18240/// rows as "unsupported kinds", which is a different (and false) statement.
18241///
18242/// Extracted so [`enqueue_declared_vector_backfill`] (which enrols the
18243/// commit-able half) and [`unsupported_vector_kinds`] (which reports the other
18244/// half) partition ONE list rather than running two hand-copied queries that
18245/// could drift — the same TC-56 anti-drift discipline that made
18246/// [`kind_is_vector_committable`] delegate to [`resolve_source_type`].
18247///
18248/// `SELECT DISTINCT … ORDER BY kind` gives the caller a sorted, de-duplicated
18249/// list for free, which is the reported ordering.
18250fn vector_eligible_node_kinds(tx: &Connection) -> rusqlite::Result<Vec<String>> {
18251 let mut stmt = tx.prepare(
18252 "SELECT DISTINCT kind FROM canonical_nodes
18253 WHERE row_kind IN ('leaf', 'coverage')
18254 ORDER BY kind",
18255 )?;
18256 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
18257 rows.collect::<rusqlite::Result<Vec<String>>>()
18258}
18259
18260/// 0.8.20 Slice 22 (R-20-VC / **TC-67**) — **the report that replaces the
18261/// silence.** The vector-eligible node kinds present in the corpus that
18262/// [`kind_is_vector_committable`] excludes, i.e. the exact complement of the set
18263/// [`enqueue_declared_vector_backfill`] enrols.
18264///
18265/// Populates [`ProjectionDelta::vector_unsupported_kinds`]. Read that field's
18266/// doc-comment for the naming, the state-not-diff semantics and the residual;
18267/// what belongs HERE is the one thing the call SITE decides:
18268///
18269/// **It is deliberately NOT gated on `dense_arm_live`.** The enrolment it mirrors
18270/// is (`apply_projection_config` only calls `enqueue_declared_vector_backfill`
18271/// with a live embedder, the Q6a graceful-absent path), but this answer does not
18272/// depend on the session: [`resolve_source_type`]'s vocabulary is a compile-time
18273/// constant, so "this kind can never be embedded" is equally true with no
18274/// embedder attached. Gating it would hide the permanent fact behind the
18275/// transient one, which is the very conflation TC-67 exists to end — a
18276/// no-embedder caller is exactly the caller who most needs to know that
18277/// attaching an embedder later will still not embed these kinds.
18278fn unsupported_vector_kinds(tx: &Connection) -> rusqlite::Result<Vec<String>> {
18279 Ok(vector_eligible_node_kinds(tx)?
18280 .into_iter()
18281 .filter(|kind| !kind_is_vector_committable(kind))
18282 .collect())
18283}
18284
18285fn enqueue_declared_vector_backfill(tx: &Connection) -> rusqlite::Result<bool> {
18286 if !vector_projection_declared(tx)? {
18287 return Ok(false);
18288 }
18289
18290 // (1) Enrol the vector-eligible kinds the live corpus actually contains —
18291 // RESTRICTED to the ones the vector writer can actually commit
18292 // ([`kind_is_vector_committable`], fix-2 / codex §9 [P1]). Enrolling a kind
18293 // outside `resolve_source_type`'s locked vocabulary wedges the projection
18294 // worker forever and starves every other kind with it.
18295 //
18296 // 0.8.20 Slice 22 (TC-67) — the kinds this filter DROPS are what
18297 // [`unsupported_vector_kinds`] reports; both read the same scan through
18298 // [`vector_eligible_node_kinds`] so the report can never describe a
18299 // different set from the one actually excluded.
18300 let kinds = vector_eligible_node_kinds(tx)?;
18301 for kind in kinds.iter().filter(|kind| kind_is_vector_committable(kind)) {
18302 register_vector_kind(tx, kind)?;
18303 }
18304
18305 // (2)+(3) Un-strand the rows the new enrolment now covers.
18306 reenqueue_stranded_vector_rows(tx)
18307}
18308
18309/// 0.8.20 Slice 20c — steps (2) and (3) of the declared-backfill above, as their
18310/// own function because **both** enrolment doors owe this treatment.
18311///
18312/// fix-2 (codex §9 [P2]): [`enqueue_declared_vector_backfill`] is the DECLARE-time
18313/// door; [`Engine::enrol_batch_vector_kinds`] is the WRITE-time one, and it used to
18314/// enrol a kind while enqueueing only the row in its own batch. A database that
18315/// persisted a `searchable→vector` declaration while opened WITHOUT an embedder
18316/// (Q6a graceful-absent: it defers, enrolling nothing), then reopened WITH one and
18317/// wrote the same kind BEFORE re-applying the projection, therefore drained the new
18318/// row and reported `ready` while every row from the no-embedder session kept its
18319/// permanent `'up_to_date'` terminal and no vector. That is a FALSE READY — the
18320/// exact defect class R-20-DR exists to eliminate — so the two doors share ONE
18321/// implementation rather than one of them carrying a partial copy.
18322///
18323/// Returns `true` iff work was re-enqueued; the caller must then `notify_new_work()`
18324/// (after its commit — the dispatcher opens its own connection).
18325///
18326/// 2. find the STRANDED rows — vector-eligible, now vector-kind-registered,
18327/// carrying an `'up_to_date'` terminal, and carrying NO `_fathomdb_vector_rows`
18328/// row — and delete their terminals so the scheduler's `terminal IS NULL`
18329/// predicate sees them again;
18330/// 3. rewind the readiness watermark to just below the lowest stranded cursor, so
18331/// the scheduler's `write_cursor > cursor` filter reaches them.
18332///
18333/// The `_fathomdb_vector_kinds` join is what scopes this to the dense arm: a kind
18334/// that is not enrolled (including one that is not commit-able, per
18335/// [`kind_is_vector_committable`]) is not stranded — it has no dense arm to be
18336/// behind on.
18337///
18338/// Idempotent by construction: it acts only on rows that are stranded RIGHT NOW, so
18339/// once drained the set is empty, it returns `false`, and neither the terminals nor
18340/// the cursor are touched. A `'failed'` terminal is deliberately NOT re-enqueued
18341/// (the filter is `'up_to_date'`): re-enqueueing it would loop a permanently-failing
18342/// row forever, and the documented failure boundary is that a terminally-failed
18343/// embed stops being outstanding work (see [`derive_dense_readiness`]).
18344fn reenqueue_stranded_vector_rows(tx: &Connection) -> rusqlite::Result<bool> {
18345 // (2) The stranded set: covered by the dense arm, terminally marked done, no
18346 // vector. `MIN` first so a no-op apply costs one indexed probe and stops.
18347 let lowest_stranded: Option<u64> = tx.query_row(
18348 "SELECT MIN(n.write_cursor)
18349 FROM canonical_nodes n
18350 JOIN _fathomdb_vector_kinds k ON k.kind = n.kind
18351 JOIN _fathomdb_projection_terminal t ON t.write_cursor = n.write_cursor
18352 LEFT JOIN _fathomdb_vector_rows v ON v.write_cursor = n.write_cursor
18353 WHERE n.row_kind IN ('leaf', 'coverage')
18354 AND t.state = 'up_to_date'
18355 AND v.write_cursor IS NULL",
18356 [],
18357 |row| row.get::<_, Option<u64>>(0),
18358 )?;
18359 let Some(lowest_stranded) = lowest_stranded else {
18360 return Ok(false);
18361 };
18362
18363 tx.execute(
18364 "DELETE FROM _fathomdb_projection_terminal
18365 WHERE write_cursor IN (
18366 SELECT n.write_cursor
18367 FROM canonical_nodes n
18368 JOIN _fathomdb_vector_kinds k ON k.kind = n.kind
18369 JOIN _fathomdb_projection_terminal t ON t.write_cursor = n.write_cursor
18370 LEFT JOIN _fathomdb_vector_rows v ON v.write_cursor = n.write_cursor
18371 WHERE n.row_kind IN ('leaf', 'coverage')
18372 AND t.state = 'up_to_date'
18373 AND v.write_cursor IS NULL
18374 )",
18375 [],
18376 )?;
18377
18378 // (3) Rewind the readiness watermark just below the lowest stranded row so the
18379 // scheduler's `write_cursor > cursor` filter reaches it. Never move it
18380 // FORWARD: rows above the watermark that still hold their terminals are
18381 // skipped by the scheduler's `terminal IS NULL` predicate, and
18382 // `advance_projection_cursor` walks the watermark back up over them.
18383 let rewind_to = lowest_stranded.saturating_sub(1);
18384 if load_projection_cursor(tx)? > rewind_to {
18385 store_projection_cursor(tx, rewind_to)?;
18386 }
18387 Ok(true)
18388}
18389
18390/// 0.8.20 Slice 15d (R-20-PR, Q5) — BOOT re-derive: the engine `ProjectionSpec`
18391/// is a DERIVED cache, re-driven idempotently on boot. For every persisted
18392/// registry declaration, clear + backfill its EAV / property-FTS rows from the
18393/// canonical nodes — so a DB whose registry row survives but whose projection
18394/// rows are missing/partial (a crash window, a restored registry) CONVERGES on
18395/// the next open. A no-op (single empty-table read) when no projections are
18396/// declared — which is every pre-`configure_projections` DB. Runs on the writer
18397/// connection, single-threaded, before readers spawn.
18398fn rederive_projections_on_boot(conn: &Connection) -> rusqlite::Result<()> {
18399 let registry = load_projection_registry(conn)?;
18400 if registry.is_empty() {
18401 return Ok(());
18402 }
18403 conn.execute_batch("BEGIN IMMEDIATE")?;
18404 let result = (|| {
18405 for (name, stored) in ®istry {
18406 clear_attribute_projection(conn, name)?;
18407 backfill_attribute(conn, name, stored)?;
18408 }
18409 Ok(())
18410 })();
18411 match result {
18412 Ok(()) => conn.execute_batch("COMMIT"),
18413 Err(err) => {
18414 let _ = conn.execute_batch("ROLLBACK");
18415 Err(err)
18416 }
18417 }
18418}
18419
18420/// EXP-S (0.8.14 Slice 5) — the `row_kind -> index-target set` dispatch
18421/// (ADR-0.8.14 §D2), and the OPP-12 forward-compat seam (ADR-0.8.14 §D5(a) /
18422/// ledger `TC-1`).
18423///
18424/// This is deliberately a per-kind LOOKUP rather than branching inlined at each
18425/// write call-site: it is the single seam a later declarative OPP-12 projection
18426/// registry (`dev/design/projection-registry-and-async-embed.md`) would wrap to
18427/// populate `row_kind -> {filterable, searchable->FTS (same-txn), searchable->
18428/// vector (async)}` without reshaping the substrate. Per D5, EXP-S implements
18429/// NO OPP-12 surface here (OPP-12 lands >=0.9.x; re-check at its scheduling) —
18430/// this function only records the index-target intent so the async-vs-sync split
18431/// (D5(b)) and the per-kind-extensible terminal-cursor readiness (D5(c)) stay
18432/// wrappable.
18433///
18434/// `Leaf` MUST preserve today's behavior exactly: FTS (sync) + vector (async,
18435/// gated by `kind_is_vector_indexed`).
18436fn index_targets_for_row_kind(row_kind: RowKind) -> IndexTargetSet {
18437 match row_kind {
18438 // Normal record — identical to pre-EXP-S behavior.
18439 RowKind::Leaf => IndexTargetSet { fts: true, vector: true },
18440 // Coverage/summary rows — searchable and embeddable.
18441 RowKind::Coverage => IndexTargetSet { fts: true, vector: true },
18442 // Graph structural rows — lexically searchable, not embedded.
18443 RowKind::Graph => IndexTargetSet { fts: true, vector: false },
18444 }
18445}
18446
18447/// EXP-S (0.8.14 Slice 5, D2/D5) — apply the per-`row_kind` index-target
18448/// dispatch for one just-inserted canonical node row (write_cursor `cursor`).
18449///
18450/// Preserves the OPP-12-shaped split (D5(b)): FTS is written in THIS
18451/// transaction (same-txn `searchable->FTS`); vector work is only *enqueued*
18452/// here into `_fathomdb_projection_state` and embedded later, asynchronously,
18453/// by the projection worker pool (`searchable->vector`). When the row projects
18454/// into no async vector index, its readiness is terminated up-front (D5(c),
18455/// per-kind-extensible) so `advance_projection_cursor` can walk past it.
18456///
18457/// Returns `true` iff async vector work was enqueued (the caller must then
18458/// `notify_new_work`). For `RowKind::Leaf` this is behavior-identical to the
18459/// pre-EXP-S inline node path.
18460fn project_canonical_node_row(
18461 tx: &Connection,
18462 cursor: u64,
18463 kind: &str,
18464 body: &str,
18465 row_kind: RowKind,
18466 pass: ProjectionPass,
18467 node_active: bool,
18468) -> rusqlite::Result<bool> {
18469 let targets = index_targets_for_row_kind(row_kind);
18470 if targets.fts && pass.writes_fts() {
18471 tx.execute(
18472 "INSERT INTO search_index(body, kind, write_cursor) VALUES(?1, ?2, ?3)",
18473 params![body, kind, cursor],
18474 )?;
18475 // F5 (0.8.14 Slice 10) — same coexisting `searchable->FTS` target also
18476 // populates the multi-column `search_index_v2` (kind/body/status) so a
18477 // BM25F query can field-weight the lexical arm. Written SYNCHRONOUSLY in
18478 // THIS transaction, exactly like `search_index` (rowid==write_cursor
18479 // identity preserved). The `status` field mirrors the migration-17
18480 // O(N) re-index: `$.status` from a JSON body, guarded by `json_valid` so
18481 // non-JSON bodies index an empty status. NOTE (codex fix-1 finding 2):
18482 // this is F5's OWN `$.status`-derived field for the BM25F `status`
18483 // column — it is NOT (yet) the value the shipped G10 SearchFilter reads.
18484 // G10 filtering reads the vec0 `status` column, which is still hardwired
18485 // to the empty-string sentinel; wiring G10 onto this field is out of
18486 // scope for F5. Determinism (R-SUB-2) is preserved: the derivation is
18487 // a pure function of `body`, evaluated in-SQL identically on every run.
18488 tx.execute(
18489 "INSERT INTO search_index_v2(kind, body, status, write_cursor)
18490 VALUES(
18491 ?1,
18492 ?2,
18493 CASE WHEN json_valid(?2)
18494 THEN COALESCE(json_extract(?2, '$.status'), '')
18495 ELSE '' END,
18496 ?3
18497 )",
18498 params![kind, body, cursor],
18499 )?;
18500 }
18501 // 0.8.20 Slice 15d (R-20-EAV) — same-transaction attribute projection. Only
18502 // the full `Write` pass re-derives attributes (see `writes_attributes`): the
18503 // FtsOnly tokenizer reproject predates step 24 and must not touch the
18504 // registry/attribute tables; VectorOnly rebuilds only vector shadows. A full
18505 // operator FTS rebuild uses `Write`, so it re-derives attributes after the
18506 // truncate.
18507 //
18508 // fix-2 [P2]: gated on `node_active`. The at-rest attribute projection tracks
18509 // EXACTLY the backfill's row set — `state = 'active' AND superseded_at IS NULL`
18510 // (see `backfill_attribute`). Unlike node-FTS / vector shadows (whose stale
18511 // versions are excluded by the canonical read path's `superseded_at IS NULL`
18512 // / `state = 'active'` join), the property tables carry NO read-side lifecycle
18513 // filter (`property_search_index` is an FTS5 table that cannot), so a pending
18514 // or superseded node's attribute values would otherwise LEAK into a
18515 // same-session property filter / property-FTS. The write path passes
18516 // `state == Active`; a projector-replay rebuild passes `active ∧ non-superseded`
18517 // per row. Lifecycle transitions maintain the store directly (see
18518 // `Engine::transition`). Passes where `writes_attributes()` is false ignore the
18519 // flag entirely.
18520 if pass.writes_attributes() && node_active {
18521 project_node_attributes(tx, cursor as i64, body)?;
18522 }
18523 // 0.8.20 Slice 20c (R-20-DR remainder) — UNCHANGED, deliberately. Late
18524 // enrolment of a kind first written AFTER a `searchable→vector` declaration
18525 // happens in [`Engine::enrol_vector_kind_if_declared`], upstream of this
18526 // transaction, NOT here: the decision needs the engine's LIVE embedder, which
18527 // a free function holding only a `Connection` cannot see. Enrolling without
18528 // one would queue embeds that can only retry-then-fail.
18529 let enqueue_vector = targets.vector && kind_is_vector_indexed(tx, kind).unwrap_or(false);
18530 if pass.writes_vector_state() {
18531 if enqueue_vector {
18532 tx.execute(
18533 "INSERT INTO _fathomdb_projection_state(kind, last_enqueued_cursor, updated_at)
18534 VALUES(?1, ?2, 0)
18535 ON CONFLICT(kind) DO UPDATE SET last_enqueued_cursor = excluded.last_enqueued_cursor",
18536 params![kind, cursor],
18537 )?;
18538 } else {
18539 // Never-vector-projected rows terminate the cursor up-front so
18540 // `advance_projection_cursor` can advance the readiness watermark.
18541 record_projection_terminal(tx, cursor, "up_to_date")?;
18542 }
18543 }
18544 Ok(enqueue_vector)
18545}
18546
18547/// 0.8.20 Slice 5a (R-20-E1, work item 1) — the EDGE half of the total
18548/// projector, extracted verbatim from the inlined `commit_batch` edge arm.
18549///
18550/// Before this extraction there was NO edge projector function: `commit_batch`
18551/// inlined the edge FTS insert + the edge vector enqueue, and
18552/// `rebuild_shadow_state` re-implemented a SUBSET of it (edge FTS only, and only
18553/// for body-carrying edges), so a projector-replay rebuild silently dropped the
18554/// rest — notably the `up_to_date` readiness terminal that the write path
18555/// records for a body-less structural edge. With both sites now calling this one
18556/// function, the write path and the rebuild path produce identical edge
18557/// projections by construction.
18558///
18559/// Mirrors [`project_canonical_node_row`]'s split (ADR-0.8.14 §D5(b)): FTS in
18560/// THIS transaction; vector work only ENQUEUED, embedded later by the worker
18561/// pool. Edge bodies enqueue under the fixed kind `"edge_fact"` so
18562/// `resolve_source_type` maps them to `source_type = "edge_fact"` in
18563/// `vector_default` (partition correctness); that kind is auto-registered in
18564/// `_fathomdb_vector_kinds` (idempotent).
18565///
18566/// Returns `true` iff async vector work was enqueued.
18567fn project_canonical_edge_row(
18568 tx: &Connection,
18569 cursor: u64,
18570 kind: &str,
18571 body: Option<&str>,
18572 pass: ProjectionPass,
18573) -> rusqlite::Result<bool> {
18574 // G11 — edge FTS projection into `search_index_edges` (separate table from
18575 // node-body `search_index` — Option B partition). Body-less structural
18576 // edges carry no lexical content and project no FTS row.
18577 if pass.writes_fts() {
18578 if let Some(edge_body) = body {
18579 tx.execute(
18580 "INSERT INTO search_index_edges(body, kind, write_cursor)
18581 VALUES(?1, ?2, ?3)",
18582 params![edge_body, kind, cursor],
18583 )?;
18584 }
18585 }
18586 let enqueue_vector = body.is_some();
18587 if pass.writes_vector_state() {
18588 if enqueue_vector {
18589 let now_unix =
18590 SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
18591 tx.execute(
18592 "INSERT OR IGNORE INTO _fathomdb_vector_kinds(kind, profile, created_at)
18593 VALUES('edge_fact', 'default', ?1)",
18594 params![now_unix],
18595 )?;
18596 tx.execute(
18597 "INSERT INTO _fathomdb_projection_state(
18598 kind, last_enqueued_cursor, updated_at
18599 ) VALUES('edge_fact', ?1, 0)
18600 ON CONFLICT(kind) DO UPDATE
18601 SET last_enqueued_cursor = excluded.last_enqueued_cursor",
18602 params![cursor],
18603 )?;
18604 // Do NOT call record_projection_terminal — let the scheduler embed
18605 // the body and mark it terminal after projection.
18606 } else {
18607 record_projection_terminal(tx, cursor, "up_to_date")?;
18608 }
18609 }
18610 Ok(enqueue_vector)
18611}
18612
18613/// F5 (0.8.14 Slice 10, fix-1) — tokenizer for the in-engine BM25F scorer.
18614///
18615/// Tokenizes `text` through the SAME FTS5 tokenizer that `search_index_v2` uses
18616/// for candidate recall (`porter unicode61 remove_diacritics 2`), so the scorer
18617/// measures term-frequency, document-frequency, field length, and average field
18618/// length under the index's own tokenization — porter stemming + unicode61
18619/// case-fold + diacritic folding. The previous implementation hand-rolled a
18620/// second lowercase-alnum splitter; a stemmed/diacritic variant recalled by
18621/// `MATCH` (e.g. query `run` vs indexed `running`, or `cafe` vs `café`) was then
18622/// scored as if the term were absent, so ranking was wrong for exactly those
18623/// variants (codex §9 fix-1 finding 1). Reusing FTS5 itself makes scoring
18624/// tokenization-faithful without re-implementing porter/unicode61 in Rust.
18625///
18626/// Mechanism: round-trip `text` through a temp single-column FTS5 table with the
18627/// identical tokenizer, then read the emitted token instances back via the
18628/// `fts5vocab(..., 'instance')` companion. The token multiset is returned in
18629/// index order (duplicates kept) so callers count tf and field length directly.
18630/// Query terms and every candidate field go through this one path, so all four
18631/// statistics are consistent with each other and with the FTS5 index the scorer
18632/// ranks.
18633fn fts5_tokenize(connection: &Connection, text: &str) -> rusqlite::Result<Vec<String>> {
18634 connection.execute_batch(
18635 "CREATE VIRTUAL TABLE IF NOT EXISTS temp.bm25f_tok
18636 USING fts5(t, tokenize = 'porter unicode61 remove_diacritics 2');
18637 CREATE VIRTUAL TABLE IF NOT EXISTS temp.bm25f_tok_vocab
18638 USING fts5vocab('bm25f_tok', 'instance');
18639 DELETE FROM temp.bm25f_tok;",
18640 )?;
18641 connection.execute("INSERT INTO temp.bm25f_tok(t) VALUES(?1)", params![text])?;
18642 let mut stmt =
18643 connection.prepare("SELECT term FROM temp.bm25f_tok_vocab ORDER BY \"offset\"")?;
18644 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
18645 rows.collect()
18646}
18647
18648/// F5 (0.8.14 Slice 10) — build the FTS5 `MATCH` expression for candidate
18649/// recall from the query's tokens: each token is a double-quoted FTS5 string
18650/// (tokens are FTS5-emitted stems — unicode61 alnum, no embedded quotes),
18651/// OR-joined.
18652fn bm25f_match_expression(terms: &[String]) -> String {
18653 terms.iter().map(|t| format!("\"{t}\"")).collect::<Vec<_>>().join(" OR ")
18654}
18655
18656/// F5 (0.8.14 Slice 10) — the BM25F score for one candidate document.
18657///
18658/// Standard BM25F: per query term, accumulate a length-normalized,
18659/// field-weighted pseudo term-frequency across the fields, then apply the BM25
18660/// saturation once. `norm_f = 1 - b + b*(len_f/avglen_f)` is the per-field
18661/// length normalization (this is where tunable `b` bites); `weight_f` is the
18662/// field boost (this is where the R-F5-1 field weighting bites).
18663fn bm25f_score_doc(
18664 plan: &Bm25fQueryPlan,
18665 query_terms: &[String],
18666 // (weight, doc field length, corpus avg field length, per-term tf in field)
18667 fields: &[(f64, f64, f64, &HashMap<String, u32>)],
18668 doc_count: usize,
18669 df: &HashMap<String, usize>,
18670) -> f64 {
18671 let mut score = 0.0_f64;
18672 for term in query_terms {
18673 let mut weighted_tf = 0.0_f64;
18674 for (weight, len_f, avglen_f, tf_map) in fields {
18675 if *weight == 0.0 || *avglen_f <= 0.0 {
18676 continue;
18677 }
18678 let tf = *tf_map.get(term).unwrap_or(&0) as f64;
18679 if tf == 0.0 {
18680 continue;
18681 }
18682 let norm = 1.0 - plan.b + plan.b * (len_f / avglen_f);
18683 if norm <= 0.0 {
18684 continue;
18685 }
18686 weighted_tf += weight * tf / norm;
18687 }
18688 if weighted_tf <= 0.0 {
18689 continue;
18690 }
18691 let dfq = *df.get(term).unwrap_or(&0);
18692 if dfq == 0 {
18693 continue;
18694 }
18695 let n = doc_count as f64;
18696 let idf = ((n - dfq as f64 + 0.5) / (dfq as f64 + 0.5) + 1.0).ln();
18697 score += idf * (weighted_tf * (plan.k1 + 1.0)) / (plan.k1 + weighted_tf);
18698 }
18699 score
18700}
18701
18702/// F5 (0.8.14 Slice 10) — connection-level implementation of the BM25F lexical
18703/// arm. See [`Engine::bm25f_search`].
18704fn bm25f_search_inner(
18705 connection: &Connection,
18706 query: &str,
18707 plan: &Bm25fQueryPlan,
18708) -> rusqlite::Result<Vec<(u64, f64)>> {
18709 let query_terms: Vec<String> = {
18710 let mut seen = BTreeSet::new();
18711 fts5_tokenize(connection, query)?.into_iter().filter(|t| seen.insert(t.clone())).collect()
18712 };
18713 if query_terms.is_empty() {
18714 return Ok(Vec::new());
18715 }
18716
18717 // Corpus pass over ACTIVE rows (superseded versions excluded): accumulate
18718 // N, total field length per field (for avg field length), and per-term
18719 // document frequency — all under the SAME FTS5 tokenization the index uses.
18720 let mut doc_count: usize = 0;
18721 let mut total_len = [0.0_f64; 3]; // kind, body, status
18722 let mut df: HashMap<String, usize> = HashMap::new();
18723 {
18724 let mut stmt = connection.prepare(
18725 "SELECT v.kind, v.body, v.status
18726 FROM search_index_v2 v
18727 JOIN canonical_nodes cn ON cn.write_cursor = v.write_cursor
18728 WHERE cn.superseded_at IS NULL AND cn.state = 'active'",
18729 )?;
18730 let mut rows = stmt.query([])?;
18731 while let Some(row) = rows.next()? {
18732 let fields =
18733 [row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?];
18734 doc_count += 1;
18735 let mut present: BTreeSet<String> = BTreeSet::new();
18736 for (i, field) in fields.iter().enumerate() {
18737 let toks = fts5_tokenize(connection, field)?;
18738 total_len[i] += toks.len() as f64;
18739 for tok in toks {
18740 if query_terms.contains(&tok) {
18741 present.insert(tok);
18742 }
18743 }
18744 }
18745 for term in present {
18746 *df.entry(term).or_insert(0) += 1;
18747 }
18748 }
18749 }
18750 if doc_count == 0 {
18751 return Ok(Vec::new());
18752 }
18753 let avglen = [
18754 total_len[0] / doc_count as f64,
18755 total_len[1] / doc_count as f64,
18756 total_len[2] / doc_count as f64,
18757 ];
18758
18759 // Active write_cursor set, to filter FTS5 MATCH candidates (search_index_v2
18760 // retains superseded versions, exactly like search_index).
18761 let active: BTreeSet<i64> = {
18762 let mut stmt = connection
18763 .prepare("SELECT write_cursor FROM canonical_nodes WHERE superseded_at IS NULL AND state = 'active'")?;
18764 let rows = stmt.query_map([], |r| r.get::<_, i64>(0))?;
18765 rows.collect::<rusqlite::Result<BTreeSet<i64>>>()?
18766 };
18767
18768 // Candidate recall through the FTS5 index (this is what makes the v2 index
18769 // load-bearing), then score each candidate with the in-engine BM25F.
18770 let match_expr = bm25f_match_expression(&query_terms);
18771 let mut scored: Vec<(u64, f64)> = Vec::new();
18772 {
18773 let mut stmt = connection.prepare(
18774 "SELECT write_cursor, kind, body, status
18775 FROM search_index_v2
18776 WHERE search_index_v2 MATCH ?1",
18777 )?;
18778 let mut rows = stmt.query([match_expr.as_str()])?;
18779 while let Some(row) = rows.next()? {
18780 let wc = row.get::<_, i64>(0)?;
18781 if !active.contains(&wc) {
18782 continue;
18783 }
18784 let kind = row.get::<_, String>(1)?;
18785 let body = row.get::<_, String>(2)?;
18786 let status = row.get::<_, String>(3)?;
18787
18788 let mut tf_kind: HashMap<String, u32> = HashMap::new();
18789 let mut len_kind = 0.0_f64;
18790 for tok in fts5_tokenize(connection, &kind)? {
18791 len_kind += 1.0;
18792 *tf_kind.entry(tok).or_insert(0) += 1;
18793 }
18794 let mut tf_body: HashMap<String, u32> = HashMap::new();
18795 let mut len_body = 0.0_f64;
18796 for tok in fts5_tokenize(connection, &body)? {
18797 len_body += 1.0;
18798 *tf_body.entry(tok).or_insert(0) += 1;
18799 }
18800 let mut tf_status: HashMap<String, u32> = HashMap::new();
18801 let mut len_status = 0.0_f64;
18802 for tok in fts5_tokenize(connection, &status)? {
18803 len_status += 1.0;
18804 *tf_status.entry(tok).or_insert(0) += 1;
18805 }
18806
18807 let fields = [
18808 (plan.weights.kind, len_kind, avglen[0], &tf_kind),
18809 (plan.weights.body, len_body, avglen[1], &tf_body),
18810 (plan.weights.status, len_status, avglen[2], &tf_status),
18811 ];
18812 let score = bm25f_score_doc(plan, &query_terms, &fields, doc_count, &df);
18813 scored.push((wc as u64, score));
18814 }
18815 }
18816
18817 // Descending score; write_cursor ascending as the deterministic tiebreak.
18818 scored.sort_by(|a, b| {
18819 b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal).then(a.0.cmp(&b.0))
18820 });
18821 Ok(scored)
18822}
18823
18824fn commit_batch(
18825 connection: &mut Connection,
18826 batch: &[PreparedWrite],
18827 plans: &[WritePlan],
18828 base_cursor: u64,
18829 provenance_row_cap: u64,
18830) -> rusqlite::Result<u64> {
18831 // 0.8.20 Slice 21a-2 (TC-57) — `BEGIN IMMEDIATE`, not rusqlite's `BEGIN
18832 // DEFERRED` default. Take the WAL write lock AT `BEGIN`, before the
18833 // supersession SELECT below, so this transaction never has to PROMOTE a read
18834 // lock to a write lock.
18835 //
18836 // The defect this closes (characterized in
18837 // `dev/design/0.8.20-tc57-write-race-characterization.md`, repro 10/10 at
18838 // baseline `41a81c17`): for a GOVERNED write (`logical_id: Some`) the first
18839 // statement in this transaction is a read —
18840 // `prior_node_cursors_by_logical_id` — and the second is the supersession
18841 // UPDATE. When the async projection worker holds the write lock on its own
18842 // connection at that instant, SQLite refuses the promotion with plain
18843 // `SQLITE_BUSY` (5) and SKIPS the busy handler entirely, for deadlock
18844 // avoidance (`sqlite3_busy_handler`: "if SQLite determines that invoking the
18845 // busy handler could result in a deadlock, it will go ahead and return
18846 // SQLITE_BUSY"). MEASURED: handler invoked ZERO times, error returned in 0 ms
18847 // against rusqlite's 5 000 ms default timeout. So NO `busy_timeout` value
18848 // could ever have absorbed it, and the caller saw an opaque, un-retryable
18849 // `EngineError::Storage` mid-ingest. The same shape also has a second,
18850 // narrower exit — `SQLITE_BUSY_SNAPSHOT` (517) when the WAL advances past the
18851 // read snapshot — which this closes too, by construction.
18852 //
18853 // UNCONDITIONAL rather than gated on `logical_id`, deliberately: an anonymous
18854 // batch's first statement is already the INSERT below, so it takes the write
18855 // lock essentially immediately anyway and the delta is microseconds, whereas a
18856 // content-dependent transaction behaviour would be a NEW correctness surface
18857 // (mixed batches, edge arms, future write kinds) with a place to be wrong in
18858 // each. MEASURED cost on the anonymous arm: none detectable
18859 // (`tc57_worker_commit_pressure.rs`).
18860 //
18861 // `BEGIN IMMEDIATE` can itself return `SQLITE_BUSY` — but WITH the busy
18862 // handler consulted, i.e. absorbed by the existing 5 s default instead of
18863 // surfaced (pinned by `tc57_mechanism_control_write_first_is_retryable`).
18864 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
18865
18866 for (i, (write, plan)) in batch.iter().zip(plans).enumerate() {
18867 // Per-row cursor: row i gets `base_cursor + i + 1`. See the
18868 // comment in `Engine::write_inner`.
18869 let cursor = base_cursor.saturating_add((i as u64).saturating_add(1));
18870 match (write, plan) {
18871 (
18872 PreparedWrite::Node {
18873 kind,
18874 body,
18875 source_id,
18876 logical_id,
18877 state,
18878 reason,
18879 valid_from,
18880 valid_until,
18881 },
18882 WritePlan::Node,
18883 ) => {
18884 // G0 — supersession is tombstone-then-insert in this same txn:
18885 // mark the prior active version superseded BEFORE inserting the
18886 // new active row, so the partial-unique-active index never sees
18887 // two active rows for one logical_id. Scoped to logical_id ALONE
18888 // (Decision 5, HITL-SIGNED 2026-06-05): a kind-change re-ingest of
18889 // the same logical_id SUPERSEDES, never forks. No-op when logical_id
18890 // is None (legacy/own-identity insert, behavior-identical to 0.7.x).
18891 if let Some(logical_id) = logical_id {
18892 // fix-1 finding 2 [P2]: collect the prior active cursor(s)
18893 // BEFORE tombstoning so we can purge the superseded row's
18894 // row-owned attribute projections and keep the at-rest EAV /
18895 // property-FTS store ACTIVE-ONLY. Without this, a same-session
18896 // property filter / property-FTS saw BOTH the stale and the
18897 // current value until a boot re-derive/reconfigure cleared the
18898 // table — a stale read that violates the active-only invariant.
18899 let prior_g0 = prior_node_cursors_by_logical_id(&tx, logical_id)?;
18900 tx.execute(
18901 "UPDATE canonical_nodes SET superseded_at = ?1
18902 WHERE logical_id = ?2 AND superseded_at IS NULL",
18903 params![cursor, logical_id],
18904 )?;
18905 // Purge only the Attribute + PropertyFts classes: those tables
18906 // have NO `superseded_at IS NULL` read-side filter (the FTS5
18907 // `property_search_index` cannot carry one), so their stale rows
18908 // MUST be deleted at rest. The NodeFts (`search_index` /
18909 // `search_index_v2`) + Vector shadows are left intact — the node
18910 // read path already excludes their superseded rows via the
18911 // `canonical_nodes WHERE superseded_at IS NULL` join, so purging
18912 // them here would be a behaviour change outside this fix's scope.
18913 for sc in &prior_g0 {
18914 purge_row_projections_for_cursor_in(
18915 &tx,
18916 *sc,
18917 &[ProjectionClass::Attribute, ProjectionClass::PropertyFts],
18918 )?;
18919 }
18920 }
18921 // EXP-S (0.8.14 Slice 5, D1) — a `PreparedWrite::Node` is the
18922 // `leaf` structural row_kind (a normal record). coverage/graph
18923 // rows are written via internal paths (row_kind is a SEPARATE
18924 // axis from the doc-type `kind`, and there is no public SDK
18925 // surface for it this release). Writing `leaf` explicitly is
18926 // value-identical to the column DEFAULT.
18927 // OPP-12 Phase-1 (0.8.19 Slice 5) — persist the create-time
18928 // existence state + advisory reason. `InitialState::Active`
18929 // (the default) writes `state = 'active'`, value-identical to the
18930 // migration step-20 column DEFAULT; `Pending` quarantines the node
18931 // out of default retrieval (the `state = 'active'` read exclusion).
18932 // 0.8.20 Slice 15b (TC-34) — persist the world-time validity
18933 // window. A `None` binds SQL NULL, which is what the migration
18934 // step-22 columns already hold for every pre-existing row and what
18935 // `ReadView::validity_sql` reads as UNBOUNDED on that side. So a
18936 // write that omits the window is byte-identical on disk to a
18937 // pre-slice write, and default-view visibility cannot drift.
18938 tx.execute(
18939 "INSERT INTO canonical_nodes(write_cursor, kind, body, source_id, logical_id, row_kind, state, reason, valid_from, valid_until)
18940 VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
18941 params![cursor, kind, body, source_id.as_str(), logical_id, RowKind::Leaf.as_str(), state.as_str(), reason, valid_from, valid_until],
18942 )?;
18943 // EXP-S (D2/D5) — per-row_kind index-target dispatch. For `leaf`
18944 // this is behavior-identical to the pre-EXP-S inline path: FTS
18945 // (sync, in-tx) + vector (async, gated by kind_is_vector_indexed);
18946 // else the cursor is terminated up-front.
18947 // fix-2 [P2]: gate the attribute projection on the create-time
18948 // state. A fresh insert is always non-superseded, so the backfill
18949 // predicate (`state = 'active' AND superseded_at IS NULL`) reduces
18950 // to `state == Active` here. A `Pending` node is quarantined out of
18951 // the canonical read model — its declared attributes must NOT reach
18952 // the property store until a `transition(pending → active)` promotes
18953 // it (which projects them then). Node-FTS / vector shadows are left
18954 // to their read-side lifecycle filter, exactly as for supersession.
18955 project_canonical_node_row(
18956 &tx,
18957 cursor,
18958 kind,
18959 body,
18960 RowKind::Leaf,
18961 ProjectionPass::Write,
18962 matches!(state, InitialState::Active),
18963 )?;
18964 }
18965 (
18966 PreparedWrite::Edge {
18967 kind,
18968 from,
18969 to,
18970 source_id,
18971 logical_id,
18972 body,
18973 t_valid,
18974 t_invalid,
18975 confidence,
18976 extractor_model_id,
18977 temporal_fallback,
18978 },
18979 WritePlan::Edge,
18980 ) => {
18981 // G0 — identical tombstone-then-insert supersession on edges,
18982 // keyed by logical_id ALONE (Decision 5, HITL-SIGNED 2026-06-05;
18983 // edge `kind` is relationship-type, not identity — a kind-change
18984 // re-ingest of the same edge logical_id SUPERSEDES, never forks).
18985 // No-op when logical_id is None.
18986 if let Some(logical_id) = logical_id {
18987 // fix-30 [P2]: collect prior active cursors BEFORE tombstoning
18988 // so stale vector_default rows can be pruned.
18989 let prior_g0 = prior_edge_cursors_by_logical_id(&tx, logical_id)?;
18990 tx.execute(
18991 "UPDATE canonical_edges SET superseded_at = ?1
18992 WHERE logical_id = ?2 AND superseded_at IS NULL",
18993 params![cursor, logical_id],
18994 )?;
18995 for sc in &prior_g0 {
18996 delete_vector_partition_row(&tx, *sc)?;
18997 tx.execute(
18998 "DELETE FROM _fathomdb_vector_rows WHERE write_cursor = ?1",
18999 [sc],
19000 )?;
19001 // fix-32 [P2]: record terminal so advance_projection_cursor
19002 // can walk past this now-superseded cursor.
19003 // TC-45: the token MUST be 'up_to_date', NOT 'superseded'.
19004 // The terminal table (schema step 7) carries
19005 // CHECK(state IN ('failed','up_to_date')) and the writer is
19006 // INSERT OR IGNORE, which SILENTLY SKIPS a CHECK-violating
19007 // row — so 'superseded' was dropped without error and this
19008 // cursor stalled forever (nothing backfills it: the job
19009 // query and the pending-work probe both exclude superseded
19010 // edges). 'up_to_date' is the CHECK-valid, non-'failed'
19011 // terminal and is semantically exact here: the row is
19012 // tombstoned and its vector shadow just deleted, so there is
19013 // no further projection work for this cursor. Same reasoning
19014 // and same token as the step-23 backfill (fix-4, TC-33).
19015 record_projection_terminal(&tx, *sc as u64, "up_to_date")?;
19016 }
19017 }
19018 // G11 — invalidate-not-accumulate: for fact-edges (body IS NOT NULL),
19019 // tombstone any prior active edge on the same (from_id, to_id, kind)
19020 // BEFORE inserting the new row. This is DIFFERENT from the G0
19021 // logical_id tombstone: it is keyed on the triple, not the identity.
19022 // Regular edges (body=None) skip this path — they retain G0 semantics.
19023 if body.is_some() {
19024 // fix-30 [P2]: collect and prune vector shadow for the superseded edge.
19025 let prior_g11 = prior_edge_cursors_by_triple(&tx, from, to, kind)?;
19026 tx.execute(
19027 "UPDATE canonical_edges SET superseded_at = ?1
19028 WHERE from_id = ?2 AND to_id = ?3 AND kind = ?4 AND superseded_at IS NULL",
19029 params![cursor, from, to, kind],
19030 )?;
19031 for sc in &prior_g11 {
19032 delete_vector_partition_row(&tx, *sc)?;
19033 tx.execute(
19034 "DELETE FROM _fathomdb_vector_rows WHERE write_cursor = ?1",
19035 [sc],
19036 )?;
19037 // fix-32 [P2]: mark terminal so projection cursor can advance.
19038 // TC-45: 'up_to_date', NOT 'superseded' — see the identical
19039 // note on the G0 prune loop above. The step-7 CHECK admits
19040 // only ('failed','up_to_date') and INSERT OR IGNORE swallows
19041 // a violating row, so 'superseded' never landed and wedged
19042 // the shared readiness watermark.
19043 record_projection_terminal(&tx, *sc as u64, "up_to_date")?;
19044 }
19045 }
19046 let temporal_fallback_i: Option<i64> =
19047 temporal_fallback.and_then(|f| if f { Some(1) } else { None });
19048 tx.execute(
19049 "INSERT INTO canonical_edges(
19050 write_cursor, kind, from_id, to_id, source_id, logical_id,
19051 body, t_valid, t_invalid, confidence, extractor_model_id,
19052 temporal_fallback
19053 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
19054 params![
19055 cursor,
19056 kind,
19057 from,
19058 to,
19059 source_id.as_str(),
19060 logical_id,
19061 body,
19062 t_valid,
19063 t_invalid,
19064 confidence,
19065 extractor_model_id,
19066 temporal_fallback_i
19067 ],
19068 )?;
19069 // 0.8.20 Slice 5a (R-20-E1, work item 1) — edge projection is no
19070 // longer inlined here: the write path and the rebuild replay
19071 // share ONE projector, so they cannot drift.
19072 project_canonical_edge_row(
19073 &tx,
19074 cursor,
19075 kind,
19076 body.as_deref(),
19077 ProjectionPass::Write,
19078 )?;
19079 }
19080 (
19081 PreparedWrite::AdminSchema { name, kind, schema_json, retention_json },
19082 WritePlan::AdminSchema,
19083 ) => {
19084 tx.execute(
19085 "INSERT INTO operational_collections(
19086 name, kind, schema_json, retention_json, format_version, created_at
19087 ) VALUES(?1, ?2, ?3, ?4, 1, 0)
19088 ON CONFLICT(name) DO UPDATE SET
19089 schema_json = excluded.schema_json,
19090 retention_json = excluded.retention_json",
19091 params![name, kind, schema_json, retention_json],
19092 )?;
19093 record_projection_terminal(&tx, cursor, "up_to_date")?;
19094 }
19095 (
19096 PreparedWrite::OpStore { collection, record_key, schema_id, body },
19097 WritePlan::AppendOnlyLog,
19098 ) => {
19099 tx.execute(
19100 "INSERT INTO operational_mutations(
19101 collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
19102 ) VALUES(?1, ?2, 'append', ?3, ?4, ?5)",
19103 params![collection, record_key, body, schema_id, cursor],
19104 )?;
19105 record_projection_terminal(&tx, cursor, "up_to_date")?;
19106 }
19107 (
19108 PreparedWrite::OpStore { collection, record_key, schema_id, body },
19109 WritePlan::LatestState,
19110 ) => {
19111 tx.execute(
19112 "INSERT INTO operational_state(
19113 collection_name, record_key, payload_json, schema_id, write_cursor
19114 ) VALUES(?1, ?2, ?3, ?4, ?5)
19115 ON CONFLICT(collection_name, record_key) DO UPDATE SET
19116 payload_json = excluded.payload_json,
19117 schema_id = excluded.schema_id,
19118 write_cursor = excluded.write_cursor",
19119 params![collection, record_key, body, schema_id, cursor],
19120 )?;
19121 record_projection_terminal(&tx, cursor, "up_to_date")?;
19122 }
19123 _ => return Err(rusqlite::Error::InvalidQuery),
19124 }
19125 }
19126
19127 // G8 (Slice 20 / F10) — cross-row dangling-edge flag-and-count. This runs
19128 // AFTER the batch loop (so every same-batch node is already on disk in `tx`
19129 // and a same-batch later-inserted endpoint is visible) and BEFORE retention /
19130 // projection-cursor / commit. It is the cross-row reason this lives here and
19131 // not in single-row pre-insert `validate_write`. Default is FLAG-AND-COUNT:
19132 // we only COUNT, never roll back (strict-mode rollback is deferred to
19133 // reserved-gap band 22 — adding a write-options surface is out of scope).
19134 //
19135 // Probe is `logical_id`-alone against the step-12 partial index
19136 // `canonical_nodes_logical_active_idx ON canonical_nodes(logical_id)
19137 // WHERE superseded_at IS NULL` (its leading column + partial predicate), so
19138 // it SEARCHes the index with no SCAN (see `tests/pr_g8_dangling_edges.rs`
19139 // case (f)). There is no node-kind to match: `canonical_edges` stores only
19140 // the edge's own kind, not the endpoint node's kind.
19141 let dangling_edge_endpoints = {
19142 // O(N) pre-pass: record, per `logical_id`, the LAST (highest) index at
19143 // which an `Edge { logical_id: Some(_), .. }` with that id appears. Keyed
19144 // by `logical_id` ALONE (Decision 5, HITL-SIGNED 2026-06-05) to match the
19145 // supersession UPDATE, which keys by logical_id alone: a kind-change
19146 // re-ingest of the same edge logical_id SUPERSEDES the earlier one.
19147 // Iterating front-to-back and overwriting means the stored value ends up
19148 // as the final index for each id. An edge at index `i` with that id is
19149 // then in-batch-superseded iff `last_index[lid] > i`. This is
19150 // behavior-identical to the prior per-edge `batch[i+1..]` `.any(..)` scan
19151 // (which was O(N²) under the single-writer txn) — same skip-set, same count.
19152 let mut last_index: HashMap<&str, usize> = HashMap::new();
19153 for (i, write) in batch.iter().enumerate() {
19154 if let PreparedWrite::Edge { logical_id: Some(lid), .. } = write {
19155 last_index.insert(lid.as_str(), i);
19156 }
19157 }
19158
19159 let mut probe = tx.prepare(
19160 "SELECT 1 FROM canonical_nodes WHERE logical_id = ?1 AND superseded_at IS NULL LIMIT 1",
19161 )?;
19162 let mut count: u64 = 0;
19163 for (i, write) in batch.iter().enumerate() {
19164 if let PreparedWrite::Edge { from, to, logical_id, .. } = write {
19165 // Honor `edge.superseded_at IS NULL`: an edge inserted in this
19166 // batch is active unless a LATER same-batch edge with the same
19167 // `Some(logical_id)` tombstoned it (the loop's supersession
19168 // UPDATE). Skip such an in-batch-superseded edge. Edges with
19169 // `logical_id: None` are never superseded-in-batch.
19170 if let Some(lid) = logical_id {
19171 let superseded_in_batch =
19172 last_index.get(lid.as_str()).is_some_and(|&last| last > i);
19173 if superseded_in_batch {
19174 continue;
19175 }
19176 }
19177 // Probe `from_id` and `to_id` independently (0, 1, or 2 per edge).
19178 for endpoint in [from, to] {
19179 if !probe.exists(params![endpoint])? {
19180 count = count.saturating_add(1);
19181 }
19182 }
19183 }
19184 }
19185 count
19186 };
19187
19188 enforce_provenance_retention(&tx, provenance_row_cap)?;
19189 advance_projection_cursor(&tx)?;
19190
19191 tx.commit()?;
19192 Ok(dangling_edge_endpoints)
19193}
19194
19195fn load_next_cursor(connection: &Connection) -> u64 {
19196 let nodes = max_cursor(connection, "canonical_nodes").unwrap_or(0);
19197 let edges = max_cursor(connection, "canonical_edges").unwrap_or(0);
19198 let mutations = max_cursor(connection, "operational_mutations").unwrap_or(0);
19199 let state = max_cursor(connection, "operational_state").unwrap_or(0);
19200 // TC-33: schema step 23 RECREATES `canonical_edges` (no data migration), so
19201 // the edge rows that used to hold the high-water mark are gone. Without this
19202 // term the allocator can hand out a cursor a PREVIOUS edge already used —
19203 // and stale `_fathomdb_projection_terminal` / `_fathomdb_vector_rows` / vec0
19204 // rows still key on it, so a brand-new row would be treated as
19205 // already-projected and never get indexed. Step 23 stashes the pre-drop
19206 // maximum here; folding it in keeps cursors monotonic across the migration.
19207 let reserved = reserved_write_cursor(connection);
19208 nodes.max(edges).max(mutations).max(state).max(reserved)
19209}
19210
19211/// The write-cursor high-water mark reserved by schema step 23, or 0 when the
19212/// key is absent (fresh DB, or a DB that never had edges). Never fails the
19213/// caller: a missing/unparseable value degrades to 0, which is the pre-TC-33
19214/// behaviour.
19215fn reserved_write_cursor(connection: &Connection) -> u64 {
19216 connection
19217 .query_row(
19218 "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
19219 params![fathomdb_schema::RESERVED_WRITE_CURSOR_KEY],
19220 |row| row.get::<_, String>(0),
19221 )
19222 .ok()
19223 .and_then(|raw| raw.parse::<u64>().ok())
19224 .unwrap_or(0)
19225}
19226
19227fn max_cursor(connection: &Connection, table: &str) -> rusqlite::Result<u64> {
19228 let sql = format!("SELECT COALESCE(MAX(write_cursor), 0) FROM {table}");
19229 connection.query_row(&sql, [], |row| row.get::<_, u64>(0))
19230}
19231
19232/// Map a rusqlite error to its stable SQLite extended-code name.
19233///
19234/// Returns `None` for non-`SqliteFailure` variants (e.g. JSON conversion
19235/// failures, type mismatches at the rusqlite layer) — those are not
19236/// SQLite-internal events and should not be surfaced under
19237/// `EventSource::SqliteInternal`. The names returned here are the
19238/// canonical `SQLITE_*` symbol names from `sqlite3.h` and are stable
19239/// dispatch keys for AC-021 / AC-006 binding adapters.
19240///
19241/// Only the subset of codes the engine can reach in 0.6.0 is enumerated
19242/// — bare-extended-code matching covers the rest with a stable
19243/// `"SQLITE_UNKNOWN"` fallback so subscribers always see a typed code.
19244///
19245/// Diagnostic completeness for unmapped codes — **corrected 0.8.20 Slice 21a-2
19246/// (TC-57)**. This comment used to claim that when the helper returns
19247/// `"SQLITE_UNKNOWN"` the numeric extended code "is not lost — it remains on the
19248/// underlying `rusqlite::Error::SqliteFailure` carried in the engine error chain
19249/// that subscribers can inspect via `EngineError`'s `source()`". **That is
19250/// false.** There is no such chain: `EngineError::Storage` is a UNIT variant with
19251/// no payload and no `source()`, and `write_inner` drops the `rusqlite::Error`
19252/// immediately after emitting the lifecycle event. So for an unmapped code the
19253/// numeric value IS lost, and the only signal a host receives is the string
19254/// `"SQLITE_UNKNOWN"`.
19255///
19256/// Concretely: `SQLITE_BUSY_SNAPSHOT` (517) matches none of the PRIMARY constants
19257/// below — the match is on the EXTENDED value — so it reaches subscribers as
19258/// `"SQLITE_UNKNOWN"` and is unrecoverable from the public API. Restructuring the
19259/// error path so busy codes are distinguishable (and surfacing the numeric code as
19260/// a typed payload field) is candidate R2 of
19261/// `dev/design/0.8.20-tc57-write-race-characterization.md` §7, explicitly OUT of
19262/// scope for the 21a-2 fix and recorded here rather than silently carried.
19263fn sqlite_extended_code_name(err: &rusqlite::Error) -> Option<&'static str> {
19264 let sqlite_error = err.sqlite_error()?;
19265 let extended = sqlite_error.extended_code;
19266 Some(match extended {
19267 rusqlite::ffi::SQLITE_SCHEMA => "SQLITE_SCHEMA",
19268 rusqlite::ffi::SQLITE_BUSY => "SQLITE_BUSY",
19269 rusqlite::ffi::SQLITE_LOCKED => "SQLITE_LOCKED",
19270 rusqlite::ffi::SQLITE_CORRUPT => "SQLITE_CORRUPT",
19271 rusqlite::ffi::SQLITE_NOTADB => "SQLITE_NOTADB",
19272 rusqlite::ffi::SQLITE_IOERR => "SQLITE_IOERR",
19273 rusqlite::ffi::SQLITE_FULL => "SQLITE_FULL",
19274 rusqlite::ffi::SQLITE_READONLY => "SQLITE_READONLY",
19275 rusqlite::ffi::SQLITE_CONSTRAINT => "SQLITE_CONSTRAINT",
19276 rusqlite::ffi::SQLITE_MISUSE => "SQLITE_MISUSE",
19277 rusqlite::ffi::SQLITE_INTERRUPT => "SQLITE_INTERRUPT",
19278 rusqlite::ffi::SQLITE_NOMEM => "SQLITE_NOMEM",
19279 rusqlite::ffi::SQLITE_PERM => "SQLITE_PERM",
19280 rusqlite::ffi::SQLITE_ABORT => "SQLITE_ABORT",
19281 rusqlite::ffi::SQLITE_PROTOCOL => "SQLITE_PROTOCOL",
19282 rusqlite::ffi::SQLITE_RANGE => "SQLITE_RANGE",
19283 rusqlite::ffi::SQLITE_TOOBIG => "SQLITE_TOOBIG",
19284 rusqlite::ffi::SQLITE_MISMATCH => "SQLITE_MISMATCH",
19285 rusqlite::ffi::SQLITE_AUTH => "SQLITE_AUTH",
19286 rusqlite::ffi::SQLITE_NOTFOUND => "SQLITE_NOTFOUND",
19287 rusqlite::ffi::SQLITE_CANTOPEN => "SQLITE_CANTOPEN",
19288 _ => "SQLITE_UNKNOWN",
19289 })
19290}
19291
19292fn sqlite_extended_code_name_from_int(extended: i32) -> &'static str {
19293 match extended {
19294 rusqlite::ffi::SQLITE_SCHEMA => "SQLITE_SCHEMA",
19295 rusqlite::ffi::SQLITE_BUSY => "SQLITE_BUSY",
19296 rusqlite::ffi::SQLITE_LOCKED => "SQLITE_LOCKED",
19297 rusqlite::ffi::SQLITE_CORRUPT => "SQLITE_CORRUPT",
19298 rusqlite::ffi::SQLITE_NOTADB => "SQLITE_NOTADB",
19299 rusqlite::ffi::SQLITE_IOERR => "SQLITE_IOERR",
19300 rusqlite::ffi::SQLITE_FULL => "SQLITE_FULL",
19301 rusqlite::ffi::SQLITE_READONLY => "SQLITE_READONLY",
19302 rusqlite::ffi::SQLITE_CONSTRAINT => "SQLITE_CONSTRAINT",
19303 rusqlite::ffi::SQLITE_MISUSE => "SQLITE_MISUSE",
19304 rusqlite::ffi::SQLITE_INTERRUPT => "SQLITE_INTERRUPT",
19305 rusqlite::ffi::SQLITE_NOMEM => "SQLITE_NOMEM",
19306 rusqlite::ffi::SQLITE_PERM => "SQLITE_PERM",
19307 rusqlite::ffi::SQLITE_ABORT => "SQLITE_ABORT",
19308 rusqlite::ffi::SQLITE_PROTOCOL => "SQLITE_PROTOCOL",
19309 rusqlite::ffi::SQLITE_RANGE => "SQLITE_RANGE",
19310 rusqlite::ffi::SQLITE_TOOBIG => "SQLITE_TOOBIG",
19311 rusqlite::ffi::SQLITE_MISMATCH => "SQLITE_MISMATCH",
19312 rusqlite::ffi::SQLITE_AUTH => "SQLITE_AUTH",
19313 rusqlite::ffi::SQLITE_NOTFOUND => "SQLITE_NOTFOUND",
19314 rusqlite::ffi::SQLITE_CANTOPEN => "SQLITE_CANTOPEN",
19315 _ => "SQLITE_UNKNOWN",
19316 }
19317}
19318
19319fn map_open_sqlite_error(err: rusqlite::Error, stage: OpenStage) -> EngineOpenError {
19320 let Some(sqlite_error) = err.sqlite_error() else {
19321 return EngineOpenError::Io { message: "could not open database".to_string() };
19322 };
19323 match sqlite_error.extended_code {
19324 rusqlite::ffi::SQLITE_CORRUPT | rusqlite::ffi::SQLITE_NOTADB => {
19325 EngineOpenError::Corruption(CorruptionDetail {
19326 kind: match stage {
19327 OpenStage::WalReplay => CorruptionKind::WalReplayFailure,
19328 OpenStage::HeaderProbe => CorruptionKind::HeaderMalformed,
19329 OpenStage::SchemaProbe => CorruptionKind::SchemaInconsistent,
19330 OpenStage::EmbedderIdentity => CorruptionKind::EmbedderIdentityDrift,
19331 },
19332 stage,
19333 locator: CorruptionLocator::OpaqueSqliteError {
19334 sqlite_extended_code: sqlite_error.extended_code,
19335 },
19336 recovery_hint: RecoveryHint {
19337 code: match stage {
19338 OpenStage::WalReplay => "E_CORRUPT_WAL_REPLAY",
19339 OpenStage::HeaderProbe => "E_CORRUPT_HEADER",
19340 OpenStage::SchemaProbe => "E_CORRUPT_SCHEMA",
19341 OpenStage::EmbedderIdentity => "E_CORRUPT_EMBEDDER_IDENTITY",
19342 },
19343 doc_anchor: match stage {
19344 OpenStage::WalReplay => "design/recovery.md#wal-replay-failures",
19345 OpenStage::HeaderProbe => "design/recovery.md#header-malformed",
19346 OpenStage::SchemaProbe => "design/recovery.md#schema-inconsistent",
19347 OpenStage::EmbedderIdentity => "design/recovery.md#embedder-identity-drift",
19348 },
19349 },
19350 })
19351 }
19352 _ => EngineOpenError::Io { message: "could not open database".to_string() },
19353 }
19354}
19355
19356fn emit_open_error_event(subscriber: &Arc<dyn lifecycle::Subscriber>, err: &EngineOpenError) {
19357 if let EngineOpenError::Corruption(detail) = err {
19358 let code = match detail.locator {
19359 CorruptionLocator::OpaqueSqliteError { sqlite_extended_code } => {
19360 Some(sqlite_extended_code_name_from_int(sqlite_extended_code))
19361 }
19362 _ => None,
19363 };
19364 let event = lifecycle::Event {
19365 phase: lifecycle::Phase::Failed,
19366 source: lifecycle::EventSource::SqliteInternal,
19367 category: lifecycle::EventCategory::Corruption,
19368 code,
19369 };
19370 subscriber.on_event(&event);
19371 }
19372}
19373
19374/// Install a `sqlite3_profile` callback on `connection` that dispatches
19375/// per-statement profile records and slow-statement signals to the
19376/// engine's subscriber registry.
19377///
19378/// Why FFI rather than `rusqlite::Connection::profile`: the safe API
19379/// (rusqlite 0.31) accepts only a `fn(&str, Duration)` with no
19380/// environment, so it cannot carry a per-engine subscriber-registry
19381/// pointer. We use `sqlite3_profile` directly with a leaked-into-`Box`
19382/// context whose pointer is tied to the engine's lifetime via
19383/// `Engine::profile_contexts`.
19384///
19385/// `sqlite3_profile` is documented as deprecated in favor of
19386/// `sqlite3_trace_v2`, but it remains supported and is sufficient for
19387/// the wall-clock + SQL-text payload required by AC-005a/b.
19388#[allow(clippy::vec_box)]
19389fn install_profile_callback(
19390 connection: &Connection,
19391 subscribers: &Arc<lifecycle::SubscriberRegistry>,
19392 profiling_enabled: &Arc<AtomicBool>,
19393 slow_threshold_ms: &Arc<AtomicU64>,
19394 contexts: &mut Vec<Box<ProfileContext>>,
19395) {
19396 let mut ctx = Box::new(ProfileContext {
19397 subscribers: Arc::clone(subscribers),
19398 profiling_enabled: Arc::clone(profiling_enabled),
19399 slow_threshold_ms: Arc::clone(slow_threshold_ms),
19400 });
19401 let ctx_ptr: *mut ProfileContext = &mut *ctx;
19402
19403 // SAFETY: the Box outlives the connection. Rust drops struct fields
19404 // in declaration order. `connection` and `reader_pool` are declared
19405 // before `profile_contexts`. `ReaderWorkerPool::Drop` joins every
19406 // reader worker, and each worker uninstalls and drops its owned
19407 // connection inside `reader_worker_loop` before the worker thread
19408 // returns. Therefore all connections — and SQLite's internal
19409 // profile-callback state with them — are torn down before the
19410 // `Box<ProfileContext>` allocations are freed. `Engine::close`
19411 // additionally clears the callback via
19412 // `sqlite3_profile(handle, None, NULL)` before connection close to
19413 // drain any in-flight callback dispatch.
19414 unsafe {
19415 rusqlite::ffi::sqlite3_profile(
19416 connection.handle(),
19417 Some(profile_callback_trampoline),
19418 ctx_ptr.cast::<std::ffi::c_void>(),
19419 );
19420 }
19421 contexts.push(ctx);
19422}
19423
19424/// Uninstall the profile callback so SQLite stops calling into our
19425/// freed `Box<ProfileContext>` pointer once a connection is being torn
19426/// down. Call before dropping `profile_contexts`.
19427fn uninstall_profile_callback(connection: &Connection) {
19428 // SAFETY: passing `None` as the callback unregisters the previous
19429 // callback; SQLite documents this as legal and idempotent.
19430 unsafe {
19431 rusqlite::ffi::sqlite3_profile(connection.handle(), None, std::ptr::null_mut());
19432 }
19433}
19434
19435/// Pack 6.G G.1 — configure SQLite per-connection lookaside on a reader
19436/// worker connection. Must be called BEFORE any statement is prepared
19437/// or any PRAGMA is run on `connection`; per the SQLite docs
19438/// (https://www.sqlite.org/malloc.html §3) lookaside is silently
19439/// ignored if reconfigured after the first allocation on the
19440/// connection. Passing `NULL` for the buffer pointer lets SQLite
19441/// allocate the lookaside backing memory itself.
19442///
19443/// rusqlite 0.31's `set_db_config` only handles the boolean
19444/// `DbConfig::*` variants; `SQLITE_DBCONFIG_LOOKASIDE` is not surfaced
19445/// (it is commented out in `rusqlite/src/config.rs`), so we call the
19446/// raw FFI directly.
19447///
19448/// Returns the rc of `sqlite3_db_config` so callers can debug-assert
19449/// `SQLITE_OK` and surface configuration failure under
19450/// `debug_assertions` test builds without expanding the public surface.
19451/// 0.7.0 perf-experiments hook: apply caller-supplied reader PRAGMAs
19452/// from the `FATHOMDB_PERF_READER_PRAGMAS` env var. Format:
19453/// comma-separated `name=value` pairs (e.g.
19454/// `cache_size=-262144,mmap_size=268435456,temp_store=MEMORY`).
19455///
19456/// **Gated on `FATHOMDB_PERF_EXPERIMENTS=1`.** No-op if the gate env
19457/// var is unset, so production paths are never affected. Failures to
19458/// apply individual PRAGMAs are logged to stderr (via `eprintln!`) but
19459/// do not error the connection open — experiments are best-effort,
19460/// not contract.
19461///
19462/// Scope: 0.7.0 perf-experiment campaign per
19463/// `dev/plans/0.7.0-perf-experiments.md`. Once Wave 5 picks the
19464/// landing combination, the chosen PRAGMAs are hardcoded as the new
19465/// reader-open default and this hook is removed.
19466/// 0.7.0 perf-experiments hook: apply writer-side PRAGMAs from
19467/// `FATHOMDB_PERF_WRITER_PRAGMAS` (same format as reader hook).
19468/// **Runs BEFORE migrations** so PRAGMAs like `page_size` that must
19469/// precede any table creation take effect on a fresh DB.
19470///
19471/// Gated on `FATHOMDB_PERF_EXPERIMENTS=1`. No-op otherwise.
19472fn apply_perf_experiment_writer_pragmas(connection: &Connection) {
19473 if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
19474 return;
19475 }
19476 let raw = match std::env::var("FATHOMDB_PERF_WRITER_PRAGMAS") {
19477 Ok(s) if !s.is_empty() => s,
19478 _ => return,
19479 };
19480 for entry in raw.split(',') {
19481 let entry = entry.trim();
19482 if entry.is_empty() {
19483 continue;
19484 }
19485 let (name, value) = match entry.split_once('=') {
19486 Some((n, v)) => (n.trim(), v.trim()),
19487 None => {
19488 eprintln!("perf-experiment: bad writer pragma entry (expect name=value): {entry}");
19489 continue;
19490 }
19491 };
19492 if name.is_empty() {
19493 eprintln!("perf-experiment: empty pragma name in writer entry: {entry}");
19494 continue;
19495 }
19496 match connection.pragma_update(None, name, value) {
19497 Ok(()) => {
19498 eprintln!(
19499 "perf-experiment: applied PRAGMA {name}={value} on writer (pre-migration)"
19500 );
19501 }
19502 Err(err) => {
19503 eprintln!("perf-experiment: writer PRAGMA {name}={value} failed: {err}");
19504 }
19505 }
19506 }
19507}
19508
19509fn apply_perf_experiment_reader_pragmas(connection: &Connection) {
19510 if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
19511 return;
19512 }
19513 let raw = match std::env::var("FATHOMDB_PERF_READER_PRAGMAS") {
19514 Ok(s) if !s.is_empty() => s,
19515 _ => return,
19516 };
19517 for entry in raw.split(',') {
19518 let entry = entry.trim();
19519 if entry.is_empty() {
19520 continue;
19521 }
19522 let (name, value) = match entry.split_once('=') {
19523 Some((n, v)) => (n.trim(), v.trim()),
19524 None => {
19525 eprintln!("perf-experiment: bad pragma entry (expect name=value): {entry}");
19526 continue;
19527 }
19528 };
19529 if name.is_empty() {
19530 eprintln!("perf-experiment: empty pragma name in entry: {entry}");
19531 continue;
19532 }
19533 match connection.pragma_update(None, name, value) {
19534 Ok(()) => {
19535 eprintln!("perf-experiment: applied PRAGMA {name}={value} on reader");
19536 }
19537 Err(err) => {
19538 eprintln!("perf-experiment: PRAGMA {name}={value} failed: {err}");
19539 }
19540 }
19541 }
19542}
19543
19544fn configure_reader_lookaside(connection: &Connection) -> std::os::raw::c_int {
19545 // SAFETY: `connection.handle()` returns a valid `*mut sqlite3` for
19546 // the lifetime of `connection`. The variadic
19547 // `sqlite3_db_config(LOOKASIDE)` call expects three trailing
19548 // arguments of types `void*`, `int`, `int` — the prototype shape
19549 // documented in `sqlite3.h`. We pass a null buffer so SQLite owns
19550 // the lookaside backing allocation, and the slot size / count from
19551 // the G.1 constants. No allocations happen on the connection
19552 // before this call (reader open path is `Connection::open` ->
19553 // `configure_reader_lookaside` -> first PRAGMA).
19554 unsafe {
19555 rusqlite::ffi::sqlite3_db_config(
19556 connection.handle(),
19557 rusqlite::ffi::SQLITE_DBCONFIG_LOOKASIDE,
19558 std::ptr::null_mut::<std::ffi::c_void>(),
19559 READER_LOOKASIDE_SLOT_SIZE,
19560 READER_LOOKASIDE_SLOT_COUNT,
19561 )
19562 }
19563}
19564
19565/// Read the high-water-mark for `SQLITE_DBSTATUS_LOOKASIDE_USED` on
19566/// `connection`. The `current` out-param is the live checked-out slot
19567/// count and decays as transactions finalize, so it is unreliable as
19568/// post-warmup evidence. The `hiwtr` out-param latches the largest
19569/// observed `current` value since the last reset and is the right
19570/// signal that lookaside was honored at any point on this connection.
19571/// Reset flag is `0` so reading does not clear the high-water mark.
19572#[cfg(debug_assertions)]
19573fn read_lookaside_used_hiwtr(connection: &Connection) -> std::os::raw::c_int {
19574 let mut current: std::os::raw::c_int = 0;
19575 let mut hiwtr: std::os::raw::c_int = 0;
19576 // SAFETY: handle is valid; both out pointers are to local stack
19577 // ints; reset flag 0 is documented as legal.
19578 unsafe {
19579 rusqlite::ffi::sqlite3_db_status(
19580 connection.handle(),
19581 rusqlite::ffi::SQLITE_DBSTATUS_LOOKASIDE_USED,
19582 &mut current,
19583 &mut hiwtr,
19584 0,
19585 );
19586 }
19587 hiwtr
19588}
19589
19590/// Pack 6.G G.3.5 — read the three page-cache pressure counters on
19591/// `connection`: `SQLITE_DBSTATUS_CACHE_HIT`, `_CACHE_MISS`, and
19592/// `_CACHE_USED`. Returns `(hit, miss, used_bytes)`. Hit/miss are
19593/// monotonic counters (reset flag = 0 here); used_bytes is the live
19594/// page-cache memory footprint at call time. The caller is expected to
19595/// take pre/post snapshots and do delta arithmetic explicitly.
19596#[cfg(debug_assertions)]
19597fn read_cache_status(
19598 connection: &Connection,
19599) -> (std::os::raw::c_int, std::os::raw::c_int, std::os::raw::c_int) {
19600 let mut hit_current: std::os::raw::c_int = 0;
19601 let mut hit_hiwtr: std::os::raw::c_int = 0;
19602 let mut miss_current: std::os::raw::c_int = 0;
19603 let mut miss_hiwtr: std::os::raw::c_int = 0;
19604 let mut used_current: std::os::raw::c_int = 0;
19605 let mut used_hiwtr: std::os::raw::c_int = 0;
19606 // SAFETY: `connection.handle()` returns a valid `*mut sqlite3` for
19607 // the lifetime of `connection`. All out-pointers are to local stack
19608 // ints. Reset flag 0 is documented as legal (no counter is reset).
19609 unsafe {
19610 rusqlite::ffi::sqlite3_db_status(
19611 connection.handle(),
19612 rusqlite::ffi::SQLITE_DBSTATUS_CACHE_HIT,
19613 &mut hit_current,
19614 &mut hit_hiwtr,
19615 0,
19616 );
19617 rusqlite::ffi::sqlite3_db_status(
19618 connection.handle(),
19619 rusqlite::ffi::SQLITE_DBSTATUS_CACHE_MISS,
19620 &mut miss_current,
19621 &mut miss_hiwtr,
19622 0,
19623 );
19624 rusqlite::ffi::sqlite3_db_status(
19625 connection.handle(),
19626 rusqlite::ffi::SQLITE_DBSTATUS_CACHE_USED,
19627 &mut used_current,
19628 &mut used_hiwtr,
19629 0,
19630 );
19631 }
19632 // CACHE_HIT / CACHE_MISS are monotonic counters reported in the
19633 // `current` out-param; CACHE_USED is the live byte count, also in
19634 // `current`. The hiwtr values are unused for this telemetry.
19635 (hit_current, miss_current, used_current)
19636}
19637
19638/// FFI trampoline for `sqlite3_profile`.
19639///
19640/// Invoked by SQLite at statement-finish with the SQL text and the
19641/// statement's wall-clock cost in nanoseconds. We dispatch a
19642/// `ProfileRecord` (when profiling is enabled) and a `SlowStatement`
19643/// signal (when `wall_clock_ms` exceeds the configured slow threshold).
19644///
19645/// Per `dev/design/lifecycle.md` § Public record shape, the public
19646/// payload exposes `wall_clock_ms`, `step_count`, and `cache_delta`.
19647/// `sqlite3_profile` does not surface per-statement step counts or
19648/// cache-hit deltas in its callback; we emit `0` for those fields and
19649/// document the hazard. AC-005b requires the fields be typed numeric,
19650/// not that they carry non-zero values for every backend.
19651unsafe extern "C" fn profile_callback_trampoline(
19652 user_data: *mut std::ffi::c_void,
19653 sql: *const std::os::raw::c_char,
19654 nanoseconds: u64,
19655) {
19656 if user_data.is_null() || sql.is_null() {
19657 return;
19658 }
19659 let ctx = unsafe { &*(user_data.cast::<ProfileContext>()) };
19660 let sql_text = match unsafe { std::ffi::CStr::from_ptr(sql) }.to_str() {
19661 Ok(s) => s,
19662 Err(_) => return,
19663 };
19664
19665 let wall_clock_ms = nanoseconds / 1_000_000;
19666
19667 if ctx.profiling_enabled.load(Ordering::Relaxed) {
19668 let record = lifecycle::ProfileRecord {
19669 wall_clock_ms,
19670 // step_count / cache_delta are not surfaced by
19671 // sqlite3_profile; placeholder 0 satisfies AC-005b's
19672 // "typed numeric" contract. A future profiling refactor
19673 // around sqlite3_stmt_status + sqlite3_db_status would
19674 // populate them with non-zero deltas.
19675 step_count: 0,
19676 cache_delta: 0,
19677 };
19678 ctx.subscribers.dispatch_profile(&record);
19679 }
19680
19681 let threshold = ctx.slow_threshold_ms.load(Ordering::Relaxed);
19682 if wall_clock_ms > threshold {
19683 let signal = lifecycle::SlowStatement { statement: sql_text.to_string(), wall_clock_ms };
19684 ctx.subscribers.dispatch_slow_statement(&signal);
19685 }
19686}
19687
19688#[cfg(test)]
19689mod tests {
19690 use super::{
19691 derive_stable_id, resolve_source_type, Engine, IdSpace, IdSpaceKind, PreparedWrite,
19692 KIND_TO_SOURCE_TYPE_CASE_SQL, ROW_OWNED_PROJECTIONS,
19693 };
19694 use rusqlite::Connection;
19695 use tempfile::TempDir;
19696
19697 /// 0.8.20 Slice 5a (R-20-E1, work item 2) — the registry GUARD.
19698 ///
19699 /// Introspects `sqlite_master` on a freshly migrated database and asserts
19700 /// that EVERY `write_cursor`-keyed table is accounted for: either it is a
19701 /// registered row-owned projection, or it is one of the explicitly named
19702 /// canonical / operational tables that are sources of truth, not shadows.
19703 /// A future projection table therefore cannot be added without either
19704 /// registering it in [`ROW_OWNED_PROJECTIONS`] (making it erasable at every
19705 /// maintenance site at once) or consciously failing this test.
19706 ///
19707 /// **`_fathomdb_projection_state` is allowlisted as KIND-owned** (design v5
19708 /// §1.1): it is keyed by `kind`, not by `write_cursor`, and holds a per-kind
19709 /// enqueue watermark. Erasing one row must not rewind a whole kind's
19710 /// watermark, so it must NEVER be deleted per-cursor. The test asserts both
19711 /// halves of that claim — that it carries no `write_cursor` column, and that
19712 /// it is absent from the row-owned registry.
19713 #[test]
19714 fn guard_row_owned_registry() {
19715 /// Canonical + operational tables: `write_cursor`-carrying SOURCES OF
19716 /// TRUTH, never row-owned projections of another row.
19717 const NON_PROJECTION_CURSOR_TABLES: &[&str] =
19718 &["canonical_nodes", "canonical_edges", "operational_mutations", "operational_state"];
19719
19720 let dir = TempDir::new().unwrap();
19721 let path = dir.path().join("registry_guard.fathomdb");
19722 Engine::open(&path).expect("open").engine.close().expect("close");
19723 let conn = Connection::open(&path).expect("open sqlite");
19724
19725 let table_names: Vec<String> = conn
19726 .prepare(
19727 "SELECT name FROM sqlite_master
19728 WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
19729 )
19730 .expect("prepare")
19731 .query_map([], |row| row.get::<_, String>(0))
19732 .expect("query")
19733 .collect::<rusqlite::Result<Vec<_>>>()
19734 .expect("collect");
19735 assert!(table_names.len() > 5, "sqlite_master introspection returned nothing useful");
19736
19737 let has_write_cursor = |table: &str| -> bool {
19738 conn.prepare(&format!("PRAGMA table_info({table})"))
19739 .and_then(|mut stmt| {
19740 let names = stmt
19741 .query_map([], |row| row.get::<_, String>(1))?
19742 .collect::<rusqlite::Result<Vec<_>>>()?;
19743 Ok(names.iter().any(|n| n == "write_cursor"))
19744 })
19745 .unwrap_or(false)
19746 };
19747
19748 let registered: Vec<&str> = ROW_OWNED_PROJECTIONS.iter().map(|p| p.table).collect();
19749
19750 // (1) Every write_cursor-keyed table is registered or explicitly excused.
19751 for table in &table_names {
19752 if !has_write_cursor(table) {
19753 continue;
19754 }
19755 assert!(
19756 registered.contains(&table.as_str())
19757 || NON_PROJECTION_CURSOR_TABLES.contains(&table.as_str()),
19758 "table `{table}` is keyed by write_cursor but is neither registered in \
19759 ROW_OWNED_PROJECTIONS nor listed as a non-projection source of truth. \
19760 If it is a projection, register it — otherwise erasure will leave its \
19761 rows on disk (the `search_index_v2` defect)."
19762 );
19763 }
19764
19765 // (2) Every registered projection actually exists and is erasable by its
19766 // declared cursor column (vec0's `rowid` included).
19767 for projection in ROW_OWNED_PROJECTIONS {
19768 assert!(
19769 table_names.iter().any(|t| t == projection.table),
19770 "registered projection `{}` does not exist in the schema",
19771 projection.table
19772 );
19773 conn.query_row(
19774 &format!(
19775 "SELECT COUNT(*) FROM {} WHERE {} = 0",
19776 projection.table, projection.cursor_column
19777 ),
19778 [],
19779 |row| row.get::<_, u64>(0),
19780 )
19781 .unwrap_or_else(|err| {
19782 panic!(
19783 "registered projection `{}` is not erasable by `{}`: {err}",
19784 projection.table, projection.cursor_column
19785 )
19786 });
19787 }
19788
19789 // (3) `_fathomdb_projection_state` is KIND-owned, not row-owned.
19790 assert!(
19791 !has_write_cursor("_fathomdb_projection_state"),
19792 "_fathomdb_projection_state gained a write_cursor column — re-decide its ownership \
19793 class before treating it as kind-owned"
19794 );
19795 assert!(
19796 !registered.contains(&"_fathomdb_projection_state"),
19797 "_fathomdb_projection_state is KIND-owned (per-kind enqueue watermark) and must \
19798 never be deleted per-cursor: erasing one row would rewind a whole kind's watermark"
19799 );
19800 }
19801
19802 /// 0.8.20 Slice 15d (R-20-EAV) — PROVE THE GUARD BITES. The two net-new
19803 /// content-storing projection tables (`canonical_attributes`,
19804 /// `property_search_index`) are `write_cursor`-keyed and hold attribute
19805 /// values at rest. This test asserts (1) they ARE registered in
19806 /// `ROW_OWNED_PROJECTIONS` (so `erase_row_projections` reaches them), and (2)
19807 /// the guard's core predicate — "registered OR a named source of truth" —
19808 /// FAILS for either table if it is (hypothetically) removed from the
19809 /// registry. This is what makes forgetting to register a future
19810 /// content-storing projection a red test, not a silent erasure leak.
19811 #[test]
19812 fn slice15d_attribute_projections_registered_and_guard_bites() {
19813 const NON_PROJECTION_CURSOR_TABLES: &[&str] =
19814 &["canonical_nodes", "canonical_edges", "operational_mutations", "operational_state"];
19815
19816 let registered: Vec<&str> = ROW_OWNED_PROJECTIONS.iter().map(|p| p.table).collect();
19817
19818 // (1) Both new content-storing projections are registered as row-owned.
19819 for table in ["canonical_attributes", "property_search_index"] {
19820 assert!(
19821 registered.contains(&table),
19822 "{table} holds attribute values at rest and MUST be in ROW_OWNED_PROJECTIONS \
19823 so purge/excise_source reach it"
19824 );
19825 }
19826
19827 // (2) The guard predicate BITES: pretend one of them was never
19828 // registered — the guard's "registered OR source-of-truth" check must
19829 // reject it (the exact assertion `guard_row_owned_registry` runs).
19830 for hidden in ["canonical_attributes", "property_search_index"] {
19831 let as_if_unregistered: Vec<&str> =
19832 registered.iter().copied().filter(|t| *t != hidden).collect();
19833 let accepted = as_if_unregistered.contains(&hidden)
19834 || NON_PROJECTION_CURSOR_TABLES.contains(&hidden);
19835 assert!(
19836 !accepted,
19837 "if {hidden} were unregistered the guard would still (incorrectly) accept it — \
19838 the guard does not actually bite"
19839 );
19840 }
19841 }
19842
19843 /// Cause-A (0.8.11.2) / C-2 (0.8.19) — `derive_stable_id` id-space contract:
19844 /// a present `logical_id` yields a `Logical` (`"l:"`) [`IdSpace`]; a NULL or
19845 /// empty `logical_id` falls back to a deterministic `Content` (`"h:"`) sha256
19846 /// content-hash of the body. The typed spaces are prefix-distinguishable and
19847 /// the value is behaviour-neutral (never used in ranking). Post-C-2 the helper
19848 /// returns a typed [`IdSpace`] whose `to_prefixed()` reproduces the pre-swap
19849 /// string byte-for-byte (eu7 no-op basis).
19850 #[test]
19851 fn derive_stable_id_id_space_contract() {
19852 // logical_id present → Logical space, body-independent.
19853 assert_eq!(derive_stable_id(Some("alice-1"), "any body"), IdSpace::logical("alice-1"));
19854 assert_eq!(
19855 derive_stable_id(Some("alice-1"), "a different body"),
19856 IdSpace::logical("alice-1")
19857 );
19858 // Byte-identical prefixed form to the pre-C-2 `stable_id` string.
19859 assert_eq!(derive_stable_id(Some("alice-1"), "any body").to_prefixed(), "l:alice-1");
19860
19861 // NULL logical_id → Content space, deterministic on body.
19862 let h1 = derive_stable_id(None, "stable body text");
19863 let h2 = derive_stable_id(None, "stable body text");
19864 assert_eq!(h1, h2, "content-hash is deterministic");
19865 assert_eq!(h1.space, IdSpaceKind::Content);
19866 let h1s = h1.to_prefixed();
19867 assert!(h1s.starts_with("h:"));
19868 assert_eq!(h1s.len(), 2 + 64, "h: + sha256 hex");
19869 assert!(h1s["h:".len()..].chars().all(|c| c.is_ascii_hexdigit()));
19870
19871 // Empty logical_id is treated as absent (falls back to content-hash).
19872 assert_eq!(derive_stable_id(Some(""), "stable body text"), h1);
19873
19874 // Distinct bodies → distinct content-hashes (no collision).
19875 assert_ne!(derive_stable_id(None, "body A"), derive_stable_id(None, "body B"));
19876 }
19877
19878 /// C-2 (0.8.19 / TC-8) — [`IdSpace`] parse/format round-trip is stable across
19879 /// all three spaces, including a value that itself contains `":"`.
19880 #[test]
19881 fn id_space_parse_format_round_trip() {
19882 let cases = [
19883 IdSpace::logical("alice-1"),
19884 IdSpace::content("a".repeat(64)),
19885 IdSpace::passage("7"),
19886 IdSpace::logical("l:weird:value"), // value contains the delimiter
19887 ];
19888 for id in cases {
19889 assert_eq!(IdSpace::parse(&id.to_prefixed()), Some(id.clone()), "round-trip {id:?}");
19890 }
19891 assert_eq!(IdSpace::logical("x").to_prefixed(), "l:x");
19892 assert_eq!(IdSpace::content("y").to_prefixed(), "h:y");
19893 assert_eq!(IdSpace::passage("3").to_prefixed(), "p:3");
19894 assert_eq!(IdSpace::parse("untagged"), None);
19895 }
19896
19897 // Pack 1 drift-detection: the Rust helper used by the two writer
19898 // sites must agree with the CASE WHEN used by the Pack 1 reshape
19899 // migration in `migrate_vector_partition_to_pack1`. The CASE SQL
19900 // is exported as `KIND_TO_SOURCE_TYPE_CASE_SQL`; this test
19901 // exercises it against an in-memory SQLite (no sqlite-vec extension
19902 // required — only the CASE) and asserts byte-equal output with the
19903 // Rust helper for every kind in the locked Pack 1 vocabulary
19904 // (incl. the synthetic `doc` -> `article` coercion). See
19905 // `dev/design/0.7.0-vector-quant-pack1.md` D3 / D4.
19906 #[test]
19907 fn resolve_source_type_drift_check() {
19908 let kinds = ["email", "article", "paper", "meeting", "note", "todo", "doc"];
19909
19910 // 1. Rust helper return values (table is the contract: changes
19911 // here must be reflected in the SQL CASE or this test fails).
19912 let want: &[(&str, &str)] = &[
19913 ("email", "email"),
19914 ("article", "article"),
19915 ("paper", "paper"),
19916 ("meeting", "meeting"),
19917 ("note", "note"),
19918 ("todo", "todo"),
19919 ("doc", "article"),
19920 ];
19921 for (kind, expected) in want {
19922 let got = resolve_source_type(kind).unwrap_or_else(|_| {
19923 panic!("resolve_source_type({kind}) returned Err; want Ok({expected})")
19924 });
19925 assert_eq!(got, *expected, "Rust helper drift for kind={kind}");
19926 }
19927 assert!(
19928 resolve_source_type("banana").is_err(),
19929 "unknown kind must surface as writer error"
19930 );
19931
19932 // 2. SQL CASE evaluated against the same kinds. Build a
19933 // one-row staging row per kind and SELECT through
19934 // KIND_TO_SOURCE_TYPE_CASE_SQL; assert each row equals the
19935 // Rust helper's output. Drift in either direction fails.
19936 let conn = Connection::open_in_memory().expect("in-memory sqlite");
19937 conn.execute_batch("CREATE TABLE s(kind TEXT NOT NULL)").expect("create s");
19938 for kind in &kinds {
19939 conn.execute("INSERT INTO s(kind) VALUES (?1)", [kind]).expect("insert kind");
19940 }
19941 let sql = format!("SELECT s.kind, {KIND_TO_SOURCE_TYPE_CASE_SQL} FROM s");
19942 let mut stmt = conn.prepare(&sql).expect("prepare CASE");
19943 let rows: Vec<(String, String)> = stmt
19944 .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
19945 .expect("query")
19946 .map(|r| r.expect("row"))
19947 .collect();
19948 assert_eq!(rows.len(), kinds.len(), "row count drift");
19949 for (kind, sql_result) in &rows {
19950 let rust_result = resolve_source_type(kind).expect("known kind");
19951 assert_eq!(
19952 sql_result, rust_result,
19953 "SQL CASE vs Rust helper drift for kind={kind}: SQL={sql_result}, Rust={rust_result}"
19954 );
19955 }
19956 }
19957
19958 #[test]
19959 fn write_advances_cursor() {
19960 let dir = TempDir::new().unwrap();
19961 let opened = Engine::open(dir.path().join("rewrite.sqlite")).expect("engine should open");
19962 let receipt = opened
19963 .engine
19964 .write(&[PreparedWrite::Node {
19965 kind: "doc".to_string(),
19966 body: "hello".to_string(),
19967 source_id: crate::SourceId::new("test:fixture").expect("test source id"),
19968 logical_id: None,
19969 state: crate::InitialState::Active,
19970 reason: None,
19971 valid_from: None,
19972 valid_until: None,
19973 }])
19974 .expect("write should succeed");
19975
19976 assert_eq!(receipt.cursor, 1);
19977 }
19978}