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 /// Test-only vector-candidate fanout override for the search hot path.
548 /// It defaults to `SEARCH_RERANK_LIMIT` (10); the test seam may raise it
549 /// so recall tests can inspect deeper vector candidates. It never changes
550 /// the caller-requested final result limit or visible result cardinality.
551 search_limit_override: AtomicUsize,
552 /// Slice 10 / G12-recency — dedicated recency-reweight flag, **off by
553 /// default** (NOT `fusion_mode`). When set, fused hits are reweighted toward
554 /// the more recent `write_cursor` AFTER bit-KNN. Flipped by the
555 /// `set_recency_reweight_enabled_for_test` seam; no production toggle yet.
556 recency_reweight_enabled: AtomicBool,
557 /// 0.8.16 Slice 5 / F9 — dedicated importance/confidence reweight flag,
558 /// **off by default** (mirrors `recency_reweight_enabled`; NOT `fusion_mode`).
559 /// When set, fused hits are multiplicatively reweighted by node `importance`
560 /// (`canonical_nodes.importance`) and edge `confidence`
561 /// (`canonical_edges.confidence`) AFTER bit-KNN + RRF fusion — `NULL ⇒ neutral
562 /// (1.0)`. Flipped by `set_importance_reweight_enabled_for_test`; no production
563 /// toggle yet (F9 ships OFF-by-default as a MECHANISM, no eval-quality claim).
564 importance_reweight_enabled: AtomicBool,
565 /// GA-2 / Slice-40 (◆ B-1) measurement seam, **off by default**. When set,
566 /// `read_search_in_tx` returns the pre-fusion VECTOR-branch ranking
567 /// (bit-KNN K=192 + f32 rerank) verbatim — the ANN-quantization fidelity
568 /// signal — INSTEAD of the unconditional RRF-fused result. This changes
569 /// nothing for any production caller (the flag is never set outside the
570 /// `eu7` recall harness via `set_vector_stage_only_for_test`); it does NOT
571 /// reintroduce a `fusion_mode` knob (RRF stays unconditional) and does NOT
572 /// alter `fuse_rrf` / `rerank_fused` / recency. It only lets the AC-075
573 /// recall gate measure ANN+ vector top-10 vs the exact-f32 VECTOR top-10
574 /// ground truth in isolation (the quantization-FIDELITY axis the 0.90 floor
575 /// is defined to measure), not the hybrid `search()` output.
576 vector_stage_only_for_test: AtomicBool,
577 /// 0.7.2 PR-2b — debug-only fault injection: when set, `recompute_mean_in_tx`
578 /// errors AFTER writing `mean_vec` but BEFORE finishing the re-quantize
579 /// pass, so the crash-atomicity test can prove the whole recompute rolls
580 /// back (no half-recentered corpus). One-shot (cleared on consume).
581 #[cfg(debug_assertions)]
582 force_recompute_failure: AtomicBool,
583 /// TC-91 — one-shot worker-commit fault seam. `0` is disabled, `1`
584 /// requests a synthetic SQLite busy error, and `2` a rusqlite-layer
585 /// storage error immediately before commit.
586 /// Kept entirely in the runtime and compiled only for tests.
587 #[cfg(debug_assertions)]
588 force_projection_commit_failure: AtomicUsize,
589 /// TC-91 test-only rendezvous after error reporting and before worker
590 /// cleanup. It proves a stop in that window leaves canonical pending work
591 /// for the next open rather than relying on an in-memory retry queue.
592 #[cfg(debug_assertions)]
593 projection_commit_failure_pause: Mutex<Option<(Arc<Barrier>, Arc<Barrier>)>>,
594 /// TC-91 test-only acknowledgement after `stopping` is set and before a
595 /// close joins workers, used with `projection_commit_failure_pause`.
596 #[cfg(debug_assertions)]
597 projection_stop_ack: Mutex<Option<Arc<Barrier>>>,
598}
599
600impl std::fmt::Debug for ProjectionRuntimeShared {
601 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
602 f.debug_struct("ProjectionRuntimeShared")
603 .field("path", &self.path)
604 .field("embedder_identity", &self.embedder_identity)
605 .finish_non_exhaustive()
606 }
607}
608
609#[derive(Debug)]
610struct ProjectionRuntime {
611 shared: Arc<ProjectionRuntimeShared>,
612 dispatcher: Mutex<Option<JoinHandle<()>>>,
613 workers: Mutex<Vec<JoinHandle<()>>>,
614}
615
616impl std::fmt::Debug for Engine {
617 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
618 f.debug_struct("Engine")
619 .field("path", &self.path)
620 .field("closed", &self.closed.load(Ordering::SeqCst))
621 .field("runtime_embedder_identity", &self.runtime_embedder_identity)
622 .finish_non_exhaustive()
623 }
624}
625
626/// Per-connection profile-callback context.
627///
628/// Holds the registry handle the callback dispatches to, plus shared
629/// references to the engine's profiling toggle and slow-statement
630/// threshold. The `Arc` clones here mirror the same atomics held by
631/// `Engine`, so `set_profiling` / `set_slow_threshold_ms` mutations are
632/// visible inside the callback without restart (REQ-006a / AC-005a /
633/// AC-007b runtime-toggle contract).
634#[derive(Debug)]
635struct ProfileContext {
636 subscribers: Arc<lifecycle::SubscriberRegistry>,
637 profiling_enabled: Arc<AtomicBool>,
638 slow_threshold_ms: Arc<AtomicU64>,
639}
640
641/// Thread-affine reader worker pool (Pack 6 F.0).
642///
643/// Per `dev/design/engine.md` § Writer / reader split, reader connections
644/// must not serialize behind a single mutex. Each worker thread owns
645/// exactly one read-only `Connection` for its lifetime; `Connection`
646/// objects never cross thread boundaries after startup. `Engine::search`
647/// dispatches a request via a per-worker bounded channel using a
648/// lock-free round-robin counter on the hot path.
649struct ReaderWorkerPool {
650 senders: Vec<SyncSender<ReaderRequest>>,
651 handles: Mutex<Option<Vec<JoinHandle<()>>>>,
652 next: AtomicUsize,
653 shutdown: AtomicBool,
654 live_workers: Arc<AtomicUsize>,
655}
656
657impl std::fmt::Debug for ReaderWorkerPool {
658 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
659 f.debug_struct("ReaderWorkerPool")
660 .field("worker_count", &self.senders.len())
661 .field("live_workers", &self.live_workers.load(Ordering::Relaxed))
662 .field("shutdown", &self.shutdown.load(Ordering::Relaxed))
663 .finish()
664 }
665}
666
667/// One request handled by exactly one reader worker. The response is
668/// returned through a fresh oneshot channel so requests cannot be
669/// routed to or duplicated across workers.
670enum ReaderRequest {
671 /// Slice 60 — property-FTS search stays on a reader-owned connection, never
672 /// the writer connection. It shares the snapshot-local filter validation of
673 /// the hybrid search path.
674 SearchProjectedText {
675 query: String,
676 name: String,
677 filter: Option<Box<SearchFilter>>,
678 limit: usize,
679 view: ReadView,
680 respond: SyncSender<ProjectedTextReaderResponse>,
681 },
682 Search {
683 compiled: fathomdb_query::CompiledQuery,
684 /// Un-centered f32 query vector serialized for `vec_f32`. Phase 2
685 /// f32 rerank uses this verbatim.
686 query_vector: Option<String>,
687 /// EU-5a2 — (possibly centered) f32 query vector for the phase 1
688 /// `vec_quantize_binary` sign-quant. Equal to `query_vector` for
689 /// non-MC-required identities (the EU-5a2 default).
690 query_vector_bin: Option<String>,
691 /// Public limit applied after ranking and filtering.
692 result_limit: usize,
693 /// Vector candidate fanout. The private test seam may raise this above
694 /// `result_limit`, but never changes caller-visible cardinality.
695 candidate_limit: usize,
696 /// `Some` only for the explicit direct text-only public API. Its fixed
697 /// bound keeps the node FTS input independent of the caller's final
698 /// result limit before node/edge body deduplication and ranking.
699 direct_text_candidate_limit: Option<usize>,
700 /// G10 — optional closed metadata filter (`None` = unfiltered, the
701 /// byte-identical-to-0.7.2 path). Applied in the phase-1 candidates
702 /// statement (vector branch) and as a Rust post-filter (text branch).
703 /// Boxed so the `ReaderRequest::Search` variant stays small (the request
704 /// rides a `Result<(), ReaderRequest>` retry channel).
705 filter: Option<Box<SearchFilter>>,
706 /// G12-recency — whether the dedicated recency reweight is enabled for
707 /// this request (read from `recency_reweight_enabled`, off by default).
708 recency_enabled: bool,
709 /// F9 (0.8.16 Slice 5) — whether the dedicated importance/confidence
710 /// reweight is enabled for this request (read from
711 /// `importance_reweight_enabled`, off by default).
712 importance_enabled: bool,
713 /// GA-2 / Slice-40 (◆ B-1) measurement seam — when true the worker
714 /// returns the pre-fusion vector-branch ranking instead of the fused
715 /// result (read from `vector_stage_only_for_test`, off by default).
716 vector_stage_only: bool,
717 /// 0.8.1 Slice 10 (R1) — raw query text for the CE reranker. Passed
718 /// from `search_inner` to `read_search_in_tx` → `rerank_fused`.
719 /// FIX-4: `Box<str>` (16 bytes) instead of `String` (24 bytes) to keep
720 /// the Search variant smaller (mirroring the boxed `filter` field).
721 raw_query: Box<str>,
722 /// 0.8.1 Slice 10 (R1) — per-request rerank depth (snapshot of
723 /// `ProjectionRuntimeShared::rerank_depth`). `0` = identity path.
724 rerank_depth: usize,
725 /// 0.8.1 Slice 30 (R3) — when `true`, run the graph-BFS arm (seeded
726 /// from top-10 fused hits, depth ≤ 3, cap 50, temporal filter) and
727 /// fuse its candidates into the final ranking via `fuse_three_arms`.
728 /// When `false` (the default), the graph arm pool is `vec![]` and
729 /// results are byte-identical to the pre-Slice-30 two-arm pipeline.
730 use_graph_arm: bool,
731 /// 0.8.5 (EXP-0) — CE-blend weight (clamped to `[0,1]` in `ce_rerank`).
732 /// `0.3` is the byte-identical default; `1.0` is the measured-parity config.
733 alpha: f64,
734 /// 0.8.5 (EXP-0) — reranked-pool size (clamped to the hit count). The
735 /// binding resolves `pool_n.unwrap_or(rerank_depth)` before dispatch.
736 pool_n: usize,
737 /// 0.8.8 EXP-OBS (Slice 5) — when `true`, capture per-arm ranks + the
738 /// fused/CE score breakdown + query trace into a `SearchResult`
739 /// `Explanation` sidecar. `false` (the default for `search`/`search_filtered`/
740 /// `search_reranked`) does ZERO extra work and returns `explanation = None`
741 /// (R-OBS-2 zero-cost; byte-identical `results`).
742 explain: bool,
743 /// 0.8.20 Slice 15b fix-2 (R-20-NV / R-20-RV) — the VALIDITY view the
744 /// node-hydration SELECTs filter by. `ReadView::default()` reproduces
745 /// the pre-fix predicate on any corpus that never authored a window
746 /// (step 22 back-filled NULL/NULL with no DEFAULT, and `validity_sql`
747 /// treats NULL as unbounded ⇒ the conjunct is a provable no-op there).
748 /// The existence axis is refused upstream, never carried here.
749 view: ReadView,
750 respond: SyncSender<ReaderResponse>,
751 },
752 /// Slice 30 (G2) — active-only point lookup by `logical_id`. Returns one
753 /// slot per requested id, in request order, `None` where no active row
754 /// carries that id. Its own typed `respond` channel keeps the `Search`
755 /// `ReaderResponse` byte-identical (no Search regression).
756 GetById {
757 logical_ids: Vec<String>,
758 /// R-20-RV — the read view this lookup runs under. `ReadView::default()`
759 /// is the strict (pre-slice) view.
760 view: ReadView,
761 respond: SyncSender<rusqlite::Result<Vec<Option<NodeRecord>>>>,
762 },
763 /// Slice 30 (G3) — paginated op-store read-back over `operational_mutations`
764 /// for a `collection`, `ORDER BY id`, with a MANDATORY (already-clamped)
765 /// limit + optional after-id cursor.
766 ReadCollection {
767 collection: String,
768 after_id: Option<i64>,
769 limit: usize,
770 respond: SyncSender<rusqlite::Result<Vec<OpStoreRow>>>,
771 },
772 /// Slice 35 (G4) — list active canonical nodes of a `kind`, filtered by
773 /// zero or more `Predicate`s (AND-combined), up to `limit` rows.
774 /// Path validation already happened at `Predicate` construction time;
775 /// the worker only compiles + executes parameterized SQL.
776 ReadList {
777 kind: String,
778 predicates: Vec<Predicate>,
779 limit: usize,
780 /// R-20-RV — the read view this listing runs under.
781 view: ReadView,
782 respond: SyncSender<rusqlite::Result<Vec<NodeRecord>>>,
783 },
784 /// Slice 20 (G5) — bounded BFS from a single root node over
785 /// `canonical_edges`. Returns the set of reachable nodes (excluding the
786 /// root) within `depth` hops, limited to the hard cap 50.
787 GraphNeighbors {
788 root_logical_id: String,
789 depth: u32,
790 direction: TraversalDirection,
791 /// R-20-RV — the read view applied at EVERY node position of the BFS
792 /// CTE (anchor, recursive join, final projection), for every direction.
793 view: ReadView,
794 respond: SyncSender<rusqlite::Result<Vec<NodeRecord>>>,
795 },
796 /// 0.8.20 Slice 10b (R-20-NV) — nodes that crossed a validity boundary in
797 /// `(since, view-instant]`.
798 CrossedBoundarySince {
799 since: i64,
800 view: ReadView,
801 respond: SyncSender<rusqlite::Result<Vec<BoundaryCrossing>>>,
802 },
803 /// Slice 20 (G6) — compose the previous search result with BFS expansion.
804 /// Resolves search hit `write_cursor`s to `logical_id`s, runs G5 traversal
805 /// for each root, deduplicates, and returns a `SearchExpandResult`.
806 SearchExpand {
807 search_hits: Vec<SearchHit>,
808 depth: u32,
809 respond: SyncSender<rusqlite::Result<SearchExpandResult>>,
810 },
811 /// Slice 20 test seam — run `EXPLAIN QUERY PLAN` on the BFS CTE SQL for
812 /// the given root/depth/direction and return the plan detail lines.
813 #[doc(hidden)]
814 ExplainGraphNeighbors {
815 root_logical_id: String,
816 depth: u32,
817 direction: TraversalDirection,
818 respond: SyncSender<rusqlite::Result<Vec<String>>>,
819 },
820 Shutdown,
821 /// Pack 6.G G.1 — debug-only request that asks a worker to read its
822 /// own connection's `SQLITE_DBSTATUS_LOOKASIDE_USED` and return the
823 /// high-water mark (`hiwtr` out-param). Used solely by the integration
824 /// test that asserts post-warmup lookaside slots were consumed; not
825 /// on any production path.
826 #[cfg(debug_assertions)]
827 LookasideStatus {
828 respond: SyncSender<i32>,
829 },
830 /// Pack 6.G G.3.5 — debug-only request that asks a worker to read
831 /// `SQLITE_DBSTATUS_CACHE_HIT`, `_CACHE_MISS`, and `_CACHE_USED`
832 /// off its own connection and return them as `(hit, miss, used_bytes)`.
833 /// `snapshot_label` is opaque to the worker; the caller uses it to
834 /// distinguish pre/post snapshots in its own bookkeeping.
835 #[cfg(debug_assertions)]
836 CacheStatus {
837 snapshot_label: String,
838 respond: SyncSender<(String, i32, i32, i32)>,
839 },
840 /// OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — debug-only request
841 /// that asks a worker to read its own connection's `PRAGMA secure_delete`
842 /// and return it (`0`/`1`). Used solely by the gap-4 test that asserts the
843 /// standing secure_delete flag is ON at EVERY open, not just the writer.
844 #[cfg(debug_assertions)]
845 SecureDeleteStatus {
846 respond: SyncSender<i64>,
847 },
848}
849
850// G0 Phase-2: the Search response carries a 4th element — the graph-arm frontier
851// meter (`GraphFrontierStats`). It rides the internal channel but is dropped before
852// `SearchResult` is built (kept OFF the governed surface); the
853// `_graph_frontier_stats_for_test` seam captures it. Default (all-zero) on non-graph paths.
854// 0.8.8 EXP-OBS (Slice 5): the Search response carries a 5th element — the opt-in
855// retrieval `Explanation` (`None` on every default `explain=false` path; `Some`
856// only on the `search_explained` path). Like the `GraphFrontierStats` 4th element
857// it rides the internal channel as a side-channel; unlike it, the explanation IS
858// surfaced (onto `SearchResult.explanation`) when requested.
859type ReaderResponse = Result<
860 (u64, Option<SoftFallback>, Vec<SearchHit>, GraphFrontierStats, Option<Explanation>),
861 SearchReaderError,
862>;
863
864type ProjectedTextReaderResponse = Result<SearchResult, SearchReaderError>;
865
866/// 0.8.20 keystone closeout fix-3 (codex §9 [P2], TOCTOU) — the error a Search
867/// reader worker can return. Two arms:
868/// * `Sqlite` — a backend/storage failure (the pre-fix-3 `rusqlite::Result`
869/// behaviour verbatim; the caller emits the internal-error event and maps to
870/// `EngineError::Storage`);
871/// * `InvalidFilter` — a filter naming an UNDECLARED `filterable` attribute,
872/// detected on the reader's OWN transaction snapshot (see
873/// [`validate_filter_attributes_on_snapshot`]). The caller re-raises it as the
874/// EXISTING `EngineError::InvalidFilter { reason }` typed variant.
875///
876/// Why a channel-carried variant and not a pre-dispatch check on the writer
877/// connection: fix-2 validated on `self.connection` BEFORE dispatch, then the
878/// reader prepared the vec0 query on a DIFFERENT connection/snapshot. A
879/// `configure_projections` DROP landing in that window let the vec0 `attr_<hex>`
880/// column vanish AFTER validation passed → an opaque `no such column` `Storage`
881/// error (the exact untyped failure fix-2 meant to prevent). Validating INSIDE
882/// the reader transaction that also compiles+executes the search binds the check
883/// and the query to ONE snapshot, closing the race; carrying the typed reason
884/// back through this variant keeps the outcome `InvalidFilter`, never `Storage`.
885enum SearchReaderError {
886 Sqlite(rusqlite::Error),
887 InvalidFilter(String),
888}
889
890impl From<rusqlite::Error> for SearchReaderError {
891 fn from(err: rusqlite::Error) -> Self {
892 SearchReaderError::Sqlite(err)
893 }
894}
895
896/// Pack 6.G G.3.5 — per-worker cache-pressure snapshot. Carried only on
897/// the debug-only `CacheStatus` broadcast path and the test accessor;
898/// not part of the public 0.6.0 surface.
899#[cfg(debug_assertions)]
900#[doc(hidden)]
901#[derive(Clone, Debug)]
902pub struct CacheStatusReply {
903 pub worker_idx: usize,
904 pub snapshot_label: String,
905 pub cache_hit: i32,
906 pub cache_miss: i32,
907 pub cache_used_bytes: i32,
908}
909
910/// Per-worker outbound channel capacity. Round-robin dispatch keeps
911/// queue depth at ~0 on hot paths; the small slack absorbs jitter
912/// without a runtime mutex.
913const READER_WORKER_CHANNEL_CAPACITY: usize = 4;
914
915impl ReaderWorkerPool {
916 fn new(connections: Vec<Connection>) -> Self {
917 let live_workers = Arc::new(AtomicUsize::new(0));
918 let mut senders = Vec::with_capacity(connections.len());
919 let mut handles = Vec::with_capacity(connections.len());
920 for (idx, connection) in connections.into_iter().enumerate() {
921 let (tx, rx) = mpsc::sync_channel::<ReaderRequest>(READER_WORKER_CHANNEL_CAPACITY);
922 let live = Arc::clone(&live_workers);
923 let handle = thread::Builder::new()
924 .name(format!("fathomdb-reader-{idx}"))
925 .spawn(move || reader_worker_loop(connection, rx, live))
926 .expect("spawn reader worker");
927 senders.push(tx);
928 handles.push(handle);
929 }
930 Self {
931 senders,
932 handles: Mutex::new(Some(handles)),
933 next: AtomicUsize::new(0),
934 shutdown: AtomicBool::new(false),
935 live_workers,
936 }
937 }
938
939 fn worker_count(&self) -> usize {
940 self.senders.len()
941 }
942
943 fn live_count(&self) -> usize {
944 self.live_workers.load(Ordering::SeqCst)
945 }
946
947 /// Pack 6.G G.1 — broadcast a `LookasideStatus` request to every
948 /// worker (not round-robin) and collect each worker's
949 /// `SQLITE_DBSTATUS_LOOKASIDE_USED`. Used only by the debug
950 /// integration test for post-warmup lookaside-slot consumption.
951 #[cfg(debug_assertions)]
952 fn lookaside_used_per_worker(&self) -> Vec<i32> {
953 let mut results = Vec::with_capacity(self.senders.len());
954 for sender in &self.senders {
955 let (tx, rx) = mpsc::sync_channel::<i32>(1);
956 if sender.send(ReaderRequest::LookasideStatus { respond: tx }).is_ok() {
957 results.push(rx.recv().unwrap_or(-1));
958 } else {
959 results.push(-1);
960 }
961 }
962 results
963 }
964
965 /// Pack 6.G G.3.5 — broadcast a `CacheStatus` request to every
966 /// worker and collect each worker's `(cache_hit, cache_miss,
967 /// cache_used_bytes)` triple. Same broadcast pattern as G.1's
968 /// `lookaside_used_per_worker`. Returns one `CacheStatusReply` per
969 /// worker in worker-index order.
970 #[cfg(debug_assertions)]
971 fn cache_status_per_worker(&self, snapshot_label: &str) -> Vec<CacheStatusReply> {
972 let mut results = Vec::with_capacity(self.senders.len());
973 for (idx, sender) in self.senders.iter().enumerate() {
974 let (tx, rx) = mpsc::sync_channel::<(String, i32, i32, i32)>(1);
975 let request = ReaderRequest::CacheStatus {
976 snapshot_label: snapshot_label.to_string(),
977 respond: tx,
978 };
979 if sender.send(request).is_ok() {
980 if let Ok((label, hit, miss, used)) = rx.recv() {
981 results.push(CacheStatusReply {
982 worker_idx: idx,
983 snapshot_label: label,
984 cache_hit: hit,
985 cache_miss: miss,
986 cache_used_bytes: used,
987 });
988 continue;
989 }
990 }
991 results.push(CacheStatusReply {
992 worker_idx: idx,
993 snapshot_label: snapshot_label.to_string(),
994 cache_hit: -1,
995 cache_miss: -1,
996 cache_used_bytes: -1,
997 });
998 }
999 results
1000 }
1001
1002 /// OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — broadcast a
1003 /// `SecureDeleteStatus` request to every worker and collect each worker's
1004 /// `PRAGMA secure_delete` value. Same broadcast pattern as G.1's
1005 /// `lookaside_used_per_worker`. Proves the standing secure_delete flag is ON
1006 /// on the reader-pool connections, not just the writer.
1007 #[cfg(debug_assertions)]
1008 fn secure_delete_per_worker(&self) -> Vec<i64> {
1009 let mut results = Vec::with_capacity(self.senders.len());
1010 for sender in &self.senders {
1011 let (tx, rx) = mpsc::sync_channel::<i64>(1);
1012 if sender.send(ReaderRequest::SecureDeleteStatus { respond: tx }).is_ok() {
1013 results.push(rx.recv().unwrap_or(-1));
1014 } else {
1015 results.push(-1);
1016 }
1017 }
1018 results
1019 }
1020
1021 /// Hot path. Lock-free dispatch: `AtomicUsize::fetch_add` selects
1022 /// the worker, then a single `SyncSender::send` enqueues the
1023 /// request. No global mutex is taken on the request path.
1024 // The `Search` variant contains a SyncSender and boxed fields (filter, raw_query);
1025 // even after FIX-4 (raw_query: Box<str>), the variant remains large due to the
1026 // SyncSender channel ownership. The Err return is only ever a no-worker/shutdown
1027 // signal, never heap-allocated repeatedly, so the allow is justified by the
1028 // channel ownership model.
1029 #[allow(clippy::result_large_err)]
1030 fn dispatch(&self, request: ReaderRequest) -> Result<(), ReaderRequest> {
1031 if self.shutdown.load(Ordering::Relaxed) {
1032 return Err(request);
1033 }
1034 let n = self.senders.len();
1035 if n == 0 {
1036 return Err(request);
1037 }
1038 let idx = self.next.fetch_add(1, Ordering::Relaxed) % n;
1039 self.senders[idx].send(request).map_err(|err| err.0)
1040 }
1041
1042 /// Signal every worker to exit and join its thread. Idempotent —
1043 /// safe to call from `Engine::close` and again from
1044 /// `ReaderWorkerPool::Drop`.
1045 fn shutdown(&self) {
1046 if self.shutdown.swap(true, Ordering::SeqCst) {
1047 return;
1048 }
1049 for sender in &self.senders {
1050 let _ = sender.send(ReaderRequest::Shutdown);
1051 }
1052 if let Ok(mut slot) = self.handles.lock() {
1053 if let Some(handles) = slot.take() {
1054 for handle in handles {
1055 let _ = handle.join();
1056 }
1057 }
1058 }
1059 }
1060}
1061
1062impl Drop for ReaderWorkerPool {
1063 fn drop(&mut self) {
1064 self.shutdown();
1065 }
1066}
1067
1068fn reader_worker_loop(
1069 mut connection: Connection,
1070 rx: Receiver<ReaderRequest>,
1071 live_workers: Arc<AtomicUsize>,
1072) {
1073 live_workers.fetch_add(1, Ordering::SeqCst);
1074 // Drop guard so the live counter decrements even on panic.
1075 struct LiveGuard(Arc<AtomicUsize>);
1076 impl Drop for LiveGuard {
1077 fn drop(&mut self) {
1078 self.0.fetch_sub(1, Ordering::SeqCst);
1079 }
1080 }
1081 let _guard = LiveGuard(live_workers);
1082
1083 while let Ok(request) = rx.recv() {
1084 match request {
1085 ReaderRequest::Shutdown => break,
1086 ReaderRequest::SearchProjectedText { query, name, filter, limit, view, respond } => {
1087 let result = read_projected_text_in_tx(
1088 &mut connection,
1089 &query,
1090 &name,
1091 filter.as_deref(),
1092 limit,
1093 view,
1094 );
1095 let _ = respond.send(result);
1096 }
1097 ReaderRequest::Search {
1098 compiled,
1099 query_vector,
1100 query_vector_bin,
1101 result_limit,
1102 candidate_limit,
1103 direct_text_candidate_limit,
1104 filter,
1105 recency_enabled,
1106 importance_enabled,
1107 vector_stage_only,
1108 raw_query,
1109 rerank_depth,
1110 use_graph_arm,
1111 alpha,
1112 pool_n,
1113 explain,
1114 view,
1115 respond,
1116 } => {
1117 let result = read_search_in_tx(
1118 &mut connection,
1119 &compiled,
1120 query_vector.as_deref(),
1121 query_vector_bin.as_deref(),
1122 result_limit,
1123 candidate_limit,
1124 direct_text_candidate_limit,
1125 filter.as_deref(),
1126 recency_enabled,
1127 importance_enabled,
1128 vector_stage_only,
1129 &raw_query,
1130 rerank_depth,
1131 use_graph_arm,
1132 alpha,
1133 pool_n,
1134 explain,
1135 view,
1136 );
1137 // Receiver may have been dropped if the caller went
1138 // away; nothing to do in that case.
1139 let _ = respond.send(result);
1140 }
1141 ReaderRequest::GetById { logical_ids, view, respond } => {
1142 let result = read_get_by_id_in_tx(&mut connection, &logical_ids, &view);
1143 let _ = respond.send(result);
1144 }
1145 ReaderRequest::ReadCollection { collection, after_id, limit, respond } => {
1146 let result = read_collection_in_tx(&mut connection, &collection, after_id, limit);
1147 let _ = respond.send(result);
1148 }
1149 ReaderRequest::ReadList { kind, predicates, limit, view, respond } => {
1150 let result = read_list_in_tx(&mut connection, &kind, &predicates, limit, &view);
1151 let _ = respond.send(result);
1152 }
1153 ReaderRequest::GraphNeighbors { root_logical_id, depth, direction, view, respond } => {
1154 let result = graph_neighbors_in_tx(
1155 &mut connection,
1156 &root_logical_id,
1157 depth,
1158 direction,
1159 &view,
1160 );
1161 let _ = respond.send(result);
1162 }
1163 ReaderRequest::CrossedBoundarySince { since, view, respond } => {
1164 let result = crossed_boundary_since_in_tx(&mut connection, since, &view);
1165 let _ = respond.send(result);
1166 }
1167 ReaderRequest::SearchExpand { search_hits, depth, respond } => {
1168 let result = search_expand_in_tx(&mut connection, &search_hits, depth);
1169 let _ = respond.send(result);
1170 }
1171 ReaderRequest::ExplainGraphNeighbors { root_logical_id, depth, direction, respond } => {
1172 let result = explain_graph_neighbors_in_tx(
1173 &mut connection,
1174 &root_logical_id,
1175 depth,
1176 direction,
1177 );
1178 let _ = respond.send(result);
1179 }
1180 #[cfg(debug_assertions)]
1181 ReaderRequest::LookasideStatus { respond } => {
1182 let _ = respond.send(read_lookaside_used_hiwtr(&connection));
1183 }
1184 #[cfg(debug_assertions)]
1185 ReaderRequest::CacheStatus { snapshot_label, respond } => {
1186 let (hit, miss, used) = read_cache_status(&connection);
1187 let _ = respond.send((snapshot_label, hit, miss, used));
1188 }
1189 #[cfg(debug_assertions)]
1190 ReaderRequest::SecureDeleteStatus { respond } => {
1191 let value: i64 =
1192 connection.query_row("PRAGMA secure_delete", [], |r| r.get(0)).unwrap_or(-1);
1193 let _ = respond.send(value);
1194 }
1195 }
1196 }
1197
1198 // Per `dev/design/engine.md` § Close path, uninstall the profile
1199 // callback before dropping the connection so SQLite cannot fire
1200 // one last callback against a `ProfileContext` whose Box is about
1201 // to free.
1202 uninstall_profile_callback(&connection);
1203 drop(connection);
1204}
1205
1206/// 0.8.20 keystone closeout fix-3 — a test-only rendezvous hook fired at the TOP
1207/// of [`read_search_in_tx`], BEFORE the reader opens its deferred transaction.
1208///
1209/// It exists ONLY to make the validate/execute TOCTOU race deterministic: a test
1210/// arms a closure that parks the reader worker here (after the caller-side search
1211/// setup, before the reader pins its snapshot), performs a concurrent
1212/// `configure_projections` DROP of a `filterable` attribute on the writer
1213/// connection, then releases the reader. The reader then pins a snapshot that
1214/// INCLUDES the drop — exactly the window that used to yield an opaque `no such
1215/// column` `Storage` error and now yields a typed `InvalidFilter`. Kept OFF the
1216/// governed surface (`_for_test`), mirroring the sanctioned
1217/// `set_vector_stage_only_for_test` seam pattern. Disarmed by default: a single
1218/// `Relaxed` atomic load per search (same class as the four hot-path atomics
1219/// already read here), fires at most once (the closure is `take`n), and is a
1220/// no-op in production because nothing ever arms it.
1221mod reader_search_hook {
1222 use std::sync::atomic::{AtomicBool, Ordering};
1223 use std::sync::Mutex;
1224
1225 static ARMED: AtomicBool = AtomicBool::new(false);
1226 #[allow(clippy::type_complexity)]
1227 static HOOK: Mutex<Option<Box<dyn Fn() + Send>>> = Mutex::new(None);
1228
1229 pub(crate) fn arm(hook: Box<dyn Fn() + Send>) {
1230 *HOOK.lock().expect("reader-search hook mutex") = Some(hook);
1231 ARMED.store(true, Ordering::SeqCst);
1232 }
1233
1234 pub(crate) fn clear() {
1235 ARMED.store(false, Ordering::SeqCst);
1236 *HOOK.lock().expect("reader-search hook mutex") = None;
1237 }
1238
1239 /// Fire the armed hook exactly ONCE, then disarm. Cheap early-out when
1240 /// disarmed (the production and common-test path).
1241 pub(crate) fn fire() {
1242 if !ARMED.load(Ordering::SeqCst) {
1243 return;
1244 }
1245 // Disarm first so a re-entrant / second reader never re-fires.
1246 ARMED.store(false, Ordering::SeqCst);
1247 let hook = HOOK.lock().expect("reader-search hook mutex").take();
1248 if let Some(hook) = hook {
1249 hook();
1250 }
1251 }
1252}
1253
1254/// 0.8.20 keystone closeout fix-3 — arm the [`reader_search_hook`] (test-only).
1255/// See that module's docs. `#[doc(hidden)]`, `_for_test`; never re-exported from
1256/// the `fathomdb` facade.
1257#[doc(hidden)]
1258pub fn arm_reader_search_hook_for_test(hook: Box<dyn Fn() + Send>) {
1259 reader_search_hook::arm(hook);
1260}
1261
1262/// 0.8.20 keystone closeout fix-3 — disarm the [`reader_search_hook`] (test-only).
1263#[doc(hidden)]
1264pub fn clear_reader_search_hook_for_test() {
1265 reader_search_hook::clear();
1266}
1267
1268impl ProjectionRuntime {
1269 fn new(
1270 path: PathBuf,
1271 embedder: Option<Arc<dyn Embedder>>,
1272 embedder_identity: EmbedderIdentity,
1273 mean_already_pinned: bool,
1274 subscribers: Arc<lifecycle::SubscriberRegistry>,
1275 ) -> Self {
1276 // EU-5b/EU-5f — only allocate the streaming accumulator when the
1277 // workspace's identity is MC-required AND no mean has been pinned
1278 // yet on disk. Allocating it for an already-pinned workspace would
1279 // let a later 256-doc run RE-pin and overwrite the compute-once
1280 // mean (violating `dev/design/embedder.md` §0.3). Other identities
1281 // pay no memory cost (`Option::None`).
1282 let mc_required = identity_requires_mean_centering(&embedder_identity);
1283 let mean_accumulator = if mc_required && !mean_already_pinned {
1284 Some(MeanAccumulator::new(embedder_identity.dimension as usize))
1285 } else {
1286 None
1287 };
1288 let shared = Arc::new(ProjectionRuntimeShared {
1289 path,
1290 embedder,
1291 embedder_identity,
1292 subscribers,
1293 state: Mutex::new(ProjectionRuntimeState::default()),
1294 state_cvar: Condvar::new(),
1295 queue: Mutex::new(VecDeque::new()),
1296 queue_cvar: Condvar::new(),
1297 retry_delays_ms: Mutex::new(DEFAULT_PROJECTION_RETRY_DELAYS_MS.to_vec()),
1298 embed_timeout_ms: AtomicU64::new(DEFAULT_EMBED_TIMEOUT_MS),
1299 embed_serialize: Mutex::new(()),
1300 live_embed_threads: Arc::new(AtomicU64::new(0)),
1301 embed_circuit_open: AtomicBool::new(false),
1302 embed_circuit_threshold: AtomicU64::new(DEFAULT_EMBED_CIRCUIT_THRESHOLD),
1303 mean_accumulator: Mutex::new(mean_accumulator),
1304 pending_events: Mutex::new(Vec::new()),
1305 commit_gate: Mutex::new(()),
1306 search_limit_override: AtomicUsize::new(SEARCH_RERANK_LIMIT),
1307 recency_reweight_enabled: AtomicBool::new(false),
1308 importance_reweight_enabled: AtomicBool::new(false),
1309 vector_stage_only_for_test: AtomicBool::new(false),
1310 #[cfg(debug_assertions)]
1311 force_recompute_failure: AtomicBool::new(false),
1312 #[cfg(debug_assertions)]
1313 force_projection_commit_failure: AtomicUsize::new(0),
1314 #[cfg(debug_assertions)]
1315 projection_commit_failure_pause: Mutex::new(None),
1316 #[cfg(debug_assertions)]
1317 projection_stop_ack: Mutex::new(None),
1318 });
1319
1320 let dispatcher_shared = Arc::clone(&shared);
1321 let dispatcher = thread::spawn(move || projection_dispatcher_loop(dispatcher_shared));
1322
1323 let mut workers = Vec::with_capacity(PROJECTION_WORKERS);
1324 for _ in 0..PROJECTION_WORKERS {
1325 let worker_shared = Arc::clone(&shared);
1326 workers.push(thread::spawn(move || projection_worker_loop(worker_shared)));
1327 }
1328
1329 Self { shared, dispatcher: Mutex::new(Some(dispatcher)), workers: Mutex::new(workers) }
1330 }
1331
1332 fn notify_new_work(&self) {
1333 if let Ok(mut state) = self.shared.state.lock() {
1334 state.pending_scan = true;
1335 self.shared.state_cvar.notify_all();
1336 }
1337 }
1338
1339 fn set_frozen(&self, frozen: bool) {
1340 if let Ok(mut state) = self.shared.state.lock() {
1341 state.frozen = frozen;
1342 if !frozen {
1343 state.pending_scan = true;
1344 }
1345 self.shared.state_cvar.notify_all();
1346 }
1347 }
1348
1349 fn pending_scan_for_test(&self) -> bool {
1350 self.shared.state.lock().map(|state| state.pending_scan).unwrap_or(true)
1351 }
1352
1353 fn wait_for_idle(&self, timeout_ms: u64) -> bool {
1354 let deadline = Instant::now() + Duration::from_millis(timeout_ms);
1355 let mut state = match self.shared.state.lock() {
1356 Ok(state) => state,
1357 Err(_) => return false,
1358 };
1359 loop {
1360 if state.active_jobs == 0 && state.queued_jobs == 0 {
1361 drop(state);
1362 if !database_has_pending_projection_work(&self.shared.path).unwrap_or(true) {
1363 return true;
1364 }
1365 state = match self.shared.state.lock() {
1366 Ok(state) => state,
1367 Err(_) => return false,
1368 };
1369 }
1370 let now = Instant::now();
1371 if now >= deadline {
1372 return false;
1373 }
1374 let wait = deadline.saturating_duration_since(now);
1375 let Ok((next_state, _)) = self.shared.state_cvar.wait_timeout(state, wait) else {
1376 return false;
1377 };
1378 state = next_state;
1379 }
1380 }
1381
1382 fn set_retry_delays_for_test(&self, delays_ms: &[u64]) {
1383 if let Ok(mut delays) = self.shared.retry_delays_ms.lock() {
1384 *delays = delays_ms.to_vec();
1385 }
1386 }
1387
1388 #[cfg(debug_assertions)]
1389 fn force_next_projection_commit_failure_for_test(&self) {
1390 self.shared.force_projection_commit_failure.store(1, Ordering::SeqCst);
1391 }
1392
1393 #[cfg(debug_assertions)]
1394 fn force_next_projection_storage_failure_for_test(&self) {
1395 self.shared.force_projection_commit_failure.store(2, Ordering::SeqCst);
1396 }
1397
1398 #[cfg(debug_assertions)]
1399 fn pause_projection_commit_failure_cleanup_for_test(
1400 &self,
1401 reported: Arc<Barrier>,
1402 release: Arc<Barrier>,
1403 ) {
1404 *self
1405 .shared
1406 .projection_commit_failure_pause
1407 .lock()
1408 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((reported, release));
1409 }
1410
1411 #[cfg(debug_assertions)]
1412 fn acknowledge_projection_stop_for_test(&self, acknowledged: Arc<Barrier>) {
1413 *self.shared.projection_stop_ack.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) =
1414 Some(acknowledged);
1415 }
1416
1417 fn set_embed_timeout_ms_for_test(&self, timeout_ms: u64) {
1418 self.shared.embed_timeout_ms.store(timeout_ms, Ordering::Relaxed);
1419 }
1420
1421 fn set_embed_circuit_threshold_for_test(&self, threshold: u64) {
1422 self.shared.embed_circuit_threshold.store(threshold, Ordering::Relaxed);
1423 }
1424
1425 fn embed_circuit_open_for_test(&self) -> bool {
1426 self.shared.embed_circuit_open.load(Ordering::Relaxed)
1427 }
1428
1429 fn stop(&self) {
1430 if let Ok(mut state) = self.shared.state.lock() {
1431 if state.stopping {
1432 return;
1433 }
1434 state.stopping = true;
1435 state.pending_scan = false;
1436 self.shared.state_cvar.notify_all();
1437 }
1438 #[cfg(debug_assertions)]
1439 if let Some(acknowledged) = self
1440 .shared
1441 .projection_stop_ack
1442 .lock()
1443 .unwrap_or_else(|poisoned| poisoned.into_inner())
1444 .take()
1445 {
1446 acknowledged.wait();
1447 }
1448 if let Ok(mut queue) = self.shared.queue.lock() {
1449 queue.clear();
1450 self.shared.queue_cvar.notify_all();
1451 }
1452
1453 if let Ok(mut dispatcher) = self.dispatcher.lock() {
1454 if let Some(handle) = dispatcher.take() {
1455 let _ = handle.join();
1456 }
1457 }
1458 if let Ok(mut workers) = self.workers.lock() {
1459 for handle in workers.drain(..) {
1460 let _ = handle.join();
1461 }
1462 }
1463 }
1464}
1465
1466#[derive(Clone, Debug, Eq, PartialEq)]
1467pub struct OpenReport {
1468 pub schema_version_before: u32,
1469 pub schema_version_after: u32,
1470 pub migration_steps: Vec<MigrationStepReport>,
1471 pub embedder_warmup_ms: u64,
1472 pub query_backend: &'static str,
1473 pub default_embedder: EmbedderIdentity,
1474 /// Total wall time the loader spent materializing default-embedder
1475 /// weights — covers HF GETs, sha256 verification, atomic rename,
1476 /// parent-dir fsync (POSIX), and cache directory writes. This is
1477 /// the "engine open paid by the embedder" envelope, useful for SLA
1478 /// budgeting; it is intentionally wider than just the bytes-flowing
1479 /// time so callers see the full first-use cost.
1480 ///
1481 /// `Some(ms)` when network bytes flowed (`bytes_downloaded > 0`);
1482 /// `None` for caller-supplied embedders (loader bypassed) and on
1483 /// full cache hits (no bytes flowed). For pure per-file network
1484 /// analysis, use the `DefaultEmbedderDownload` events on
1485 /// [`embedder_events`](Self::embedder_events) — each event carries
1486 /// the file's bytes + sha256 + cache path.
1487 pub embedder_download_ms: Option<u64>,
1488 /// Structured loader events (`dev/design/embedder.md` §7). Empty for
1489 /// caller-supplied embedders; populated from `LoadedWeights.events`
1490 /// for the Default path.
1491 pub embedder_events: Vec<EmbedderEvent>,
1492 /// Static identity capability (`dev/design/embedder.md` §0.6). True
1493 /// iff the live embedder identity is the bge-small default, which is
1494 /// the only identity that ships with the EU-5a2 mean-centering apply
1495 /// paths. `false` for `fathomdb-noop` and for any other
1496 /// caller-supplied identity. EU-5b's identity flip makes the Default
1497 /// path return `true` here.
1498 pub embedder_mean_centering_required: bool,
1499 /// Dynamic workspace state (`dev/design/embedder.md` §0.6). True iff
1500 /// `_fathomdb_embedder_profiles.mean_vec IS NOT NULL` for the default
1501 /// profile. EU-5a2 reads from the schema column added in migration
1502 /// step 10; the value is dimension-validated (§0.2) at open time
1503 /// and fails closed via `EmbedderIdentityMismatch` on drift.
1504 pub embedder_mean_vec_pinned: bool,
1505 /// 0.8.18 Slice 5 (#5 vector-equivalence probe, R-VEQ-6) — degraded-open
1506 /// observability. `true` iff the open-time #5 self-check re-embedded the 45
1507 /// committed probes and found a divergence beyond the frozen D4 floor (a
1508 /// Phase-1 mean-centered `embedding_bin` sign flip OR a Phase-2 un-centered
1509 /// L2 over `VECTOR_EQUIVALENCE_L2_EPSILON`). When `true`, `Engine::open`
1510 /// SUCCEEDED but every vector-dependent arm refuses at query time with
1511 /// `EngineError::VectorEquivalenceMismatch`; the text-only/FTS-only path stays
1512 /// serviceable. The state is RE-DERIVED at every open (the probe re-runs), so
1513 /// a reopen with a still-divergent backend stays degraded (never silently
1514 /// re-enables dense) and a reopen with a matching backend clears it.
1515 pub dense_disabled: bool,
1516 /// R-VEQ-6 — human-readable reason for `dense_disabled` (which representation
1517 /// tripped: P1 flip count or P2 L2). `None` when `dense_disabled == false`.
1518 pub dense_disabled_reason: Option<String>,
1519}
1520
1521#[derive(Debug)]
1522pub struct OpenedEngine {
1523 pub engine: Engine,
1524 pub report: OpenReport,
1525}
1526
1527/// EU-5b — loader-supplied open-time telemetry threaded into
1528/// `OpenReport.embedder_download_ms` and `OpenReport.embedder_events`.
1529#[derive(Clone, Debug)]
1530struct LoaderInfo {
1531 download_ms: Option<u64>,
1532 events: Vec<EmbedderEvent>,
1533}
1534
1535#[derive(Clone, Debug, Eq, PartialEq)]
1536pub struct WriteReceipt {
1537 /// The batch high-water cursor — the `write_cursor` of the last row written
1538 /// (also the engine's new `next_cursor`). Unchanged from 0.7.x.
1539 pub cursor: u64,
1540 /// G0 (Slice 15) — the per-row `write_cursor` of each row in the batch, 1:1
1541 /// with input order. This is the `write_cursor`-as-row-id identity carrier
1542 /// (HITL-accepted for 0.8.0; a dedicated `row_id` is deferred). For an
1543 /// N-row batch this is `[cursor-N+1, …, cursor]`.
1544 pub row_cursors: Vec<u64>,
1545 /// G8 (Slice 20 / F10) — count of edge endpoints in this batch that point at
1546 /// a non-existent **or superseded** canonical node. An endpoint is dangling
1547 /// when no **active** node (`superseded_at IS NULL`) carries its `logical_id`;
1548 /// `from_id` and `to_id` are probed independently, so one edge contributes 0,
1549 /// 1, or 2. This is **informational** (default FLAG-AND-COUNT: the batch
1550 /// commits regardless) and `0` whenever the batch committed no active edges.
1551 pub dangling_edge_endpoints: u64,
1552}
1553
1554/// Soft-fallback signal carried on hybrid `search` results.
1555///
1556/// Per `dev/design/retrieval.md` § Soft-fallback signal, this record is
1557/// present only when one non-essential branch could not contribute. Total
1558/// request failure is not expressed via this carrier.
1559#[derive(Clone, Debug, Eq, PartialEq)]
1560pub struct SoftFallback {
1561 pub branch: SoftFallbackBranch,
1562}
1563
1564/// Which retrieval branch produced a hit (or could not contribute).
1565///
1566/// `Vector` = ANN vector branch (node bodies); `Text` = node-body FTS branch;
1567/// `TextEdge` = edge-body hit (FTS via `search_index_edges` OR vector-projected
1568/// edge facts — both produce the same kind="edge_fact" row shape and share the
1569/// same downstream handling in `search_expand_in_tx`). `Vector`/`Text` also
1570/// used as soft-fallback signal when the respective branch is empty.
1571/// `GraphArm` = R3 (Slice 30) BFS-reachable node from the temporal fact-edge
1572/// graph arm. Owned by `dev/design/retrieval.md`.
1573#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1574pub enum SoftFallbackBranch {
1575 Vector,
1576 Text,
1577 /// G11 (Slice 15) — edge-body hit from `search_index_edges` FTS or from
1578 /// `vector_default` edge-fact projection. `kind = "edge_fact"` in both cases.
1579 TextEdge,
1580 /// R3 (Slice 30) — BFS-reachable node from the temporal fact-edge graph arm.
1581 /// Only present when `use_graph_arm = true`. Nodes in the graph arm were NOT
1582 /// in the initial vector/text fused result (newly-reached nodes only).
1583 GraphArm,
1584}
1585
1586/// A single structured search hit (G1 / AC-057a-clean).
1587///
1588/// Both retrieval branches emit this shape. `id` is a typed [`IdSpace`] — the
1589/// **permanent** caller-facing identity since C-2 (0.8.19 / TC-8), NOT the
1590/// interim `write_cursor: u64` the pre-0.8.19 releases carried and NOT an
1591/// interim carrier awaiting a later swap. The positional `write_cursor` field
1592/// below survives as engine-internal book-keeping and the SDK bindings do not
1593/// surface it. See the field docs on [`SearchHit::id`] / [`IdSpace`].
1594/// `score` is the **G9 RRF-fused** relevance (`Σ 1/(RRF_K + rank)` over the
1595/// branches that surfaced this body; higher = more relevant), optionally
1596/// recency-reweighted when the dedicated recency flag is on. Raw `vec_distance_l2`
1597/// and `bm25()` are fused on **rank**, never compared raw (they are not
1598/// comparable). `branch` tags which retrieval branch produced the representative
1599/// hit (vector-first when a body is surfaced by both).
1600///
1601/// `source_id` (G0 Phase-2 / BLOCK-2; generalised by TC-31 in 0.8.20 Slice 10a)
1602/// carries the source-document provenance of a hit — the identifier
1603/// [`Engine::erase_source`] consumes. It is populated on **every** hit path:
1604/// - **Node hits** (text/BM25F, vector, and the pre-step-12 legacy text
1605/// fallback) carry the **node's own** `canonical_nodes.source_id`.
1606/// - **Edge hits** (edge-FTS from `search_index_edges`, and edge-fact hits
1607/// hydrated by the vector arm) carry the **edge's own**
1608/// `canonical_edges.source_id`.
1609/// - **GraphArm** hits carry the **traversed edge's** `source_id` (the session
1610/// the fact-edge was extracted from) — unchanged by TC-31 — enabling
1611/// `doc_id_of` to resolve a graph-reached entity back to a gold session id.
1612///
1613/// Before TC-31 only the GraphArm branch populated this, which left
1614/// `erase_source` shipping with its argument unreachable from a text or vector
1615/// hit (0.8.19 also stopped surfacing `write_cursor` to the SDKs, removing the
1616/// only fallback route). It stays `Option<String>`: a row written before 0.8.20,
1617/// or a GOVERNED row deliberately spared by the step-21 backfill under the TC-11
1618/// pin, legitimately carries NULL at rest and must read back as `None` rather
1619/// than a fabricated value.
1620///
1621/// The field never participates in ranking, so result order and scores are
1622/// unaffected.
1623///
1624/// C-2 (0.8.19 / OPP-12 record-lifecycle Phase-1, TC-8) — the **id-space** of a
1625/// [`SearchHit::id`]. A closed, typed enum (NOT a magic-prefixed string) — the
1626/// C-2 binding ratified in the OPP-12 protocol:
1627/// - [`Logical`](IdSpaceKind::Logical) — `"l:"`, a governed/canonical node keyed
1628/// by its `logical_id` (the only lifecycle-addressable space).
1629/// - [`Content`](IdSpaceKind::Content) — `"h:"`, a doc-seeded/anonymous node
1630/// keyed by a content hash of its body (the dominant corpus hit class).
1631/// - [`Passage`](IdSpaceKind::Passage) — `"p:"`, a synthetic `rerank_passages`
1632/// hit keyed by the caller-supplied passage ordinal.
1633#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1634pub enum IdSpaceKind {
1635 /// `"l:"` — governed/canonical node (its `logical_id`).
1636 Logical,
1637 /// `"h:"` — doc-seeded/anonymous node (content hash of the body).
1638 Content,
1639 /// `"p:"` — synthetic rerank passage (caller-supplied ordinal).
1640 Passage,
1641}
1642
1643impl IdSpaceKind {
1644 /// The two-char id-space prefix (`"l:"` / `"h:"` / `"p:"`) used in the
1645 /// prefixed string form. Byte-identical to the pre-swap `derive_stable_id`
1646 /// tags so real-gold keying stays a no-op.
1647 #[must_use]
1648 pub fn prefix(self) -> &'static str {
1649 match self {
1650 Self::Logical => "l:",
1651 Self::Content => "h:",
1652 Self::Passage => "p:",
1653 }
1654 }
1655
1656 /// The lowercase discriminant (`"logical"` / `"content"` / `"passage"`)
1657 /// surfaced through the SDK bindings as the `IdSpace.space` field (mirrors
1658 /// how `SoftFallbackBranch` is surfaced as a `branch` string).
1659 #[must_use]
1660 pub fn as_str(self) -> &'static str {
1661 match self {
1662 Self::Logical => "logical",
1663 Self::Content => "content",
1664 Self::Passage => "passage",
1665 }
1666 }
1667}
1668
1669/// C-2 (0.8.19 / OPP-12 Phase-1, TC-8) — the typed, non-null, id-space-**total**
1670/// carrier for [`SearchHit::id`]. Subsumes the interim `write_cursor` id AND the
1671/// additive Cause-A `stable_id` field of prior releases: the `value` is the BARE
1672/// id (prefix stripped), and [`to_prefixed`](IdSpace::to_prefixed) reproduces the
1673/// pre-swap `stable_id` string byte-for-byte (`l:`/`h:` unchanged) so
1674/// cross-session real-gold keying continues on `id` as a true no-op.
1675///
1676/// Lifecycle-addressability is a type check consumed downstream by the
1677/// `transition`/`purge` verbs: only [`Logical`](IdSpaceKind::Logical) is
1678/// lifecycle-addressable; `Content`/`Passage` are total-but-not-addressable.
1679#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1680pub struct IdSpace {
1681 /// The typed id-space (`Logical`/`Content`/`Passage`).
1682 pub space: IdSpaceKind,
1683 /// The bare id value (id-space prefix stripped).
1684 pub value: String,
1685}
1686
1687impl IdSpace {
1688 /// A `Logical` (`"l:"`) id carrying `value` (a `logical_id`).
1689 pub fn logical(value: impl Into<String>) -> Self {
1690 Self { space: IdSpaceKind::Logical, value: value.into() }
1691 }
1692
1693 /// A `Content` (`"h:"`) id carrying `value` (a content hash).
1694 pub fn content(value: impl Into<String>) -> Self {
1695 Self { space: IdSpaceKind::Content, value: value.into() }
1696 }
1697
1698 /// A `Passage` (`"p:"`) id carrying `value` (a caller-supplied ordinal).
1699 pub fn passage(value: impl Into<String>) -> Self {
1700 Self { space: IdSpaceKind::Passage, value: value.into() }
1701 }
1702
1703 /// The prefixed string form (`{prefix}{value}`) — byte-identical to the
1704 /// pre-swap `derive_stable_id` output for `l:`/`h:`.
1705 #[must_use]
1706 pub fn to_prefixed(&self) -> String {
1707 format!("{}{}", self.space.prefix(), self.value)
1708 }
1709
1710 /// Parse the prefixed string form back into a typed `IdSpace`. Round-trip
1711 /// stable: `IdSpace::parse(&x.to_prefixed()) == Some(x)`. Only the FIRST
1712 /// two-char id-space prefix is stripped, so a value that itself contains
1713 /// `":"` round-trips unchanged. Returns `None` for an untagged string.
1714 #[must_use]
1715 pub fn parse(s: &str) -> Option<Self> {
1716 if let Some(v) = s.strip_prefix("l:") {
1717 Some(Self::logical(v))
1718 } else if let Some(v) = s.strip_prefix("h:") {
1719 Some(Self::content(v))
1720 } else {
1721 s.strip_prefix("p:").map(Self::passage)
1722 }
1723 }
1724}
1725
1726impl std::fmt::Display for IdSpace {
1727 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1728 write!(f, "{}{}", self.space.prefix(), self.value)
1729 }
1730}
1731
1732/// Derives `Clone, Debug, PartialEq` but **not `Eq`** — `score: f64` forbids
1733/// total equality.
1734#[derive(Clone, Debug, PartialEq)]
1735pub struct SearchHit {
1736 /// C-2 (0.8.19 / TC-8) — the typed, non-null, id-space-total hit id
1737 /// ([`IdSpace`]). Was the interim `write_cursor: u64` in prior releases; now
1738 /// carries the cross-session-stable key: `value` is the BARE (prefix-stripped)
1739 /// id, and [`to_prefixed`](IdSpace::to_prefixed) (== `{prefix}{value}`)
1740 /// reproduces the pre-swap `stable_id` byte-for-byte (the real-gold-keying
1741 /// no-op). Governed hits are `l:`, doc-seeded hits `h:`, synthetic
1742 /// passages `p:`. This is the caller-facing identity; the positional
1743 /// `write_cursor` below is engine-internal book-keeping.
1744 pub id: IdSpace,
1745 /// Engine-internal positional cursor (the value `id` carried before the C-2
1746 /// swap). Reassigned on every re-projection/re-ingest — NOT cross-session
1747 /// stable, NOT the caller-facing id. Retained because the engine still needs
1748 /// a positional cursor for its own book-keeping (vector rowid mapping, the
1749 /// `state='active'` filter lookups, RRF recency/importance reweight keys,
1750 /// telemetry `result_ids` keying, `search_expand` re-resolution). The SDK
1751 /// bindings do NOT surface it.
1752 pub write_cursor: u64,
1753 pub kind: String,
1754 pub body: String,
1755 pub score: f64,
1756 pub branch: SoftFallbackBranch,
1757 pub source_id: Option<String>,
1758 /// 0.8.5 (EXP-0) — per-candidate cross-encoder score `ce_norm =
1759 /// sigmoid(ce_logit) ∈ [0,1]`. `Some` ONLY for hits inside the reranked pool
1760 /// (the top `pool_n` when the CE model is loaded); `None` for the unreranked
1761 /// remainder, the `rerank_depth == 0` identity path, an empty list, and the
1762 /// no-CE-model soft-fallback. Additive + nullable: it never participates in
1763 /// ranking, so default-path ordering/scores stay byte-stable.
1764 pub ce_score: Option<f64>,
1765}
1766
1767/// G0 Phase-2 (E0a / BLOCK-1) — graph-arm frontier instrumentation. A
1768/// **side-channel** meter (deliberately NOT a `SearchResult`/`SearchHit` field —
1769/// byte stability) that proves whether the graph arm seeds a non-empty frontier.
1770/// Under the current doc-seeded path the frontier is empty (doc nodes carry
1771/// `logical_id = NULL`), so `seeds_resolved == 0` and `resolved_seed_rate == 0.0`
1772/// — this meter is the measurement that proves it (and, post-C1, the 0→>0 flip).
1773///
1774/// `resolved_seed_rate = seeds_resolved / seeds_considered`, with `0/0 → 0.0`.
1775#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1776pub struct GraphFrontierStats {
1777 /// Hits inspected as seed candidates (the `take(SEED_N)` window, skipping TextEdge).
1778 pub seeds_considered: u32,
1779 /// Seed candidates that resolved to an active `logical_id` (pushed onto the frontier).
1780 pub seeds_resolved: u32,
1781 /// Whether the BFS frontier was non-empty after seeding.
1782 pub frontier_nonempty: bool,
1783 /// Number of graph-arm `SearchHit`s emitted (reachable, not already in the two-arm result).
1784 pub graph_candidates_emitted: u32,
1785}
1786
1787impl GraphFrontierStats {
1788 /// `seeds_resolved / seeds_considered`, defined as `0.0` when nothing was considered.
1789 pub fn resolved_seed_rate(&self) -> f64 {
1790 if self.seeds_considered == 0 {
1791 0.0
1792 } else {
1793 f64::from(self.seeds_resolved) / f64::from(self.seeds_considered)
1794 }
1795 }
1796}
1797
1798/// Slice 30 (G2) — an active canonical node row returned by `read.get` /
1799/// `read.get_many`.
1800///
1801/// `logical_id` is the queried stable identity (echoed). `write_cursor` is the
1802/// interim id carrier (same column `SearchHit.id` carries). Only ACTIVE rows
1803/// (`superseded_at IS NULL`) are ever materialised into this shape; a missing or
1804/// superseded `logical_id` is a normal absence (`None`), never an error.
1805#[derive(Clone, Debug, Eq, PartialEq)]
1806pub struct NodeRecord {
1807 pub logical_id: String,
1808 pub kind: String,
1809 pub body: String,
1810 pub write_cursor: u64,
1811}
1812
1813/// 0.8.20 Slice 10b (R-20-RV / R-20-NV) — the **read view**: the single knob
1814/// that decides which `canonical_nodes` rows a read verb may see.
1815///
1816/// Every field is a *relaxation*: `ReadView::default()` is the STRICT view and
1817/// compiles to exactly the predicates the five read verbs carried before this
1818/// slice (`superseded_at IS NULL AND state = 'active'`), so the default read
1819/// path is behaviourally unchanged. Flags compose INDEPENDENTLY — each one
1820/// drops exactly one conjunct and no other.
1821///
1822/// The view is applied UNIFORMLY by [`Engine::read_get`],
1823/// [`Engine::read_get_many`], [`Engine::read_list`],
1824/// [`Engine::read_list_filter`] and [`Engine::graph_neighbors`] — and, inside
1825/// `graph_neighbors`, at EVERY position of EVERY direction's recursive CTE
1826/// (anchor, recursive join, final projection), so a relaxation cannot silently
1827/// apply on one traversal position and not another.
1828///
1829/// # World-time only
1830///
1831/// `valid_as_of` selects along the **world-time** (validity) axis only.
1832/// Transaction-time / `history_as_of` is explicitly OUT OF SCOPE — this type
1833/// deliberately has no way to ask "what did the database believe at time T".
1834#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1835pub struct ReadView {
1836 /// Relax `superseded_at IS NULL` — include superseded (historical) versions
1837 /// of a row, not just the current one. `false` (default) keeps the shipped
1838 /// current-version-only behaviour.
1839 ///
1840 /// On the point-lookup verbs ([`Engine::read_get`] /
1841 /// [`Engine::read_get_many`]) a `logical_id` can now match several rows;
1842 /// the slot resolves DETERMINISTICALLY to the highest `write_cursor` (the
1843 /// most recent version). Use [`Engine::read_list`] to enumerate history.
1844 pub include_superseded: bool,
1845
1846 /// Relax `state = 'active'` — include rows in a non-`active` lifecycle
1847 /// state (`pending` / `deleted` / `purged`). `false` (default) keeps the
1848 /// shipped active-only behaviour.
1849 pub include_inactive: bool,
1850
1851 /// Relax the validity-window predicate ENTIRELY — return rows whatever
1852 /// their `[valid_from, valid_until)` window, ignoring `valid_as_of`.
1853 /// `false` (default) filters to rows valid at the selected instant.
1854 ///
1855 /// Note this is a NO-OP on any row with an unbounded (NULL/NULL) window,
1856 /// which is every row that predates schema step 22.
1857 pub include_out_of_window: bool,
1858
1859 /// The instant (INTEGER epoch SECONDS, UTC) at which validity is evaluated.
1860 /// `None` (default) resolves to *now* at query time.
1861 ///
1862 /// This is the **`:now` seam**: whichever way it resolves, the instant is
1863 /// compiled as a BOUND PARAMETER, never a `datetime('now')` SQL literal —
1864 /// which is what makes node validity deterministically testable without
1865 /// clock games. (The shipped EDGE temporal filter still inlines
1866 /// `datetime('now')`; that path is untouched by this slice.)
1867 pub valid_as_of: Option<i64>,
1868}
1869
1870impl ReadView {
1871 /// The instant to bind for the validity predicate, or `None` when the view
1872 /// relaxes validity entirely (in which case no `:now` parameter is emitted
1873 /// and none must be bound).
1874 fn now_param(&self) -> Option<i64> {
1875 if self.include_out_of_window {
1876 return None;
1877 }
1878 Some(self.valid_as_of.unwrap_or_else(current_epoch_seconds))
1879 }
1880
1881 /// The existence conjunct for node-table `alias`. Each flag drops exactly
1882 /// one conjunct; the strict view reproduces the pre-slice predicate pair
1883 /// verbatim. Always begins with ` AND ` (or is empty), so every call site
1884 /// must already have a preceding `WHERE` predicate.
1885 fn existence_sql(&self, alias: &str) -> String {
1886 let mut sql = String::new();
1887 if !self.include_superseded {
1888 sql.push_str(&format!(" AND {alias}.superseded_at IS NULL"));
1889 }
1890 if !self.include_inactive {
1891 sql.push_str(&format!(" AND {alias}.state = 'active'"));
1892 }
1893 sql
1894 }
1895
1896 /// The validity conjunct for node-table `alias`, bound to positional
1897 /// parameter `?{now_idx}`. Empty when validity is relaxed.
1898 ///
1899 /// Encodes the HALF-OPEN window `[valid_from, valid_until)` with NULL
1900 /// meaning unbounded on that side — so a NULL/NULL row is valid at every
1901 /// instant and this conjunct never changes its visibility.
1902 fn validity_sql(&self, alias: &str, now_idx: usize) -> String {
1903 if self.include_out_of_window {
1904 return String::new();
1905 }
1906 format!(
1907 " AND ({alias}.valid_from IS NULL OR {alias}.valid_from <= ?{now_idx}) \
1908 AND ({alias}.valid_until IS NULL OR {alias}.valid_until > ?{now_idx})"
1909 )
1910 }
1911
1912 /// The full node predicate (existence + validity) for `alias`. This is the
1913 /// ONE function every read site calls, so no site can drift from another.
1914 fn node_sql(&self, alias: &str, now_idx: usize) -> String {
1915 format!("{}{}", self.existence_sql(alias), self.validity_sql(alias, now_idx))
1916 }
1917
1918 /// 0.8.20 Slice 15b fix-3 (F2) — resolve this view's validity instant ONCE
1919 /// and hand back a [`FrozenView`] that carries the resolved value.
1920 ///
1921 /// This is the ONLY constructor of a `FrozenView`, and therefore the only
1922 /// point on the search path where the wall clock is read.
1923 fn freeze(self) -> FrozenView {
1924 // TC-33: resolve the instant ONCE, unconditionally, and derive both
1925 // axes from it. `valid_as_of.unwrap_or_else(current_epoch_seconds)` is
1926 // exactly what `now_param()` computes, so the clock is read the same
1927 // number of times as before on every path that reads it at all.
1928 let resolved = self.valid_as_of.unwrap_or_else(current_epoch_seconds);
1929 let now = if self.include_out_of_window { None } else { Some(resolved) };
1930 FrozenView { view: self, now, edge_now: resolved }
1931 }
1932
1933 /// 0.8.20 Slice 15b fix-2 — the `search` path honours the VALIDITY axis of a
1934 /// `ReadView` and refuses the EXISTENCE axis. See [`Engine::search_view`] for
1935 /// why refusing beats silently ignoring.
1936 fn reject_existence_relaxation_on_search(&self) -> Result<(), EngineError> {
1937 let relaxed = match (self.include_superseded, self.include_inactive) {
1938 (true, true) => "include_superseded + include_inactive",
1939 (true, false) => "include_superseded",
1940 (false, true) => "include_inactive",
1941 (false, false) => return Ok(()),
1942 };
1943 Err(EngineError::InvalidArgument {
1944 msg: format!(
1945 "ReadView.{relaxed} is not supported on the search path; search hydrates from \
1946 projection indexes that are not version-complete, so only the validity axis \
1947 (valid_as_of / include_out_of_window) is honoured. Use read_list for history."
1948 ),
1949 })
1950 }
1951}
1952
1953/// 0.8.20 Slice 10b (R-20-NV) — one node that crossed a validity boundary
1954/// inside the interrogated interval, as reported by
1955/// [`Engine::crossed_boundary_since`].
1956///
1957/// A node can cross BOTH boundaries in the same interval (a window that opened
1958/// and closed inside it), so the two fields are independent `Option`s rather
1959/// than one enum.
1960#[derive(Clone, Debug, Eq, PartialEq)]
1961pub struct BoundaryCrossing {
1962 /// The node that crossed.
1963 pub node: NodeRecord,
1964 /// `Some(valid_from)` when the node BECAME VALID inside the interval.
1965 pub became_valid_at: Option<i64>,
1966 /// `Some(valid_until)` when the node BECAME INVALID inside the interval.
1967 pub became_invalid_at: Option<i64>,
1968}
1969
1970/// 0.8.20 Slice 15b fix-3 (F2) — a [`ReadView`] whose validity instant has
1971/// ALREADY been resolved, produced only by [`ReadView::freeze`].
1972///
1973/// R-20-NV requires `:now` to bind ONCE PER QUERY — not per row, and not per
1974/// ARM. The multi-arm search path made that easy to violate: each arm held a
1975/// `ReadView` and could call `now_param()`, which for the default view
1976/// (`valid_as_of == None`) reads the wall clock. Two arms, two instants, and a
1977/// query straddling a validity boundary gets nondeterministic membership.
1978///
1979/// The fix is TYPE-LEVEL rather than a comment asking future arms to behave:
1980/// the instant is resolved once at the top of `read_search_in_tx` and every arm
1981/// receives a `FrozenView`, which stores the resolved value in `now` and has NO
1982/// path back to the clock. An arm cannot re-resolve the instant because it
1983/// never holds anything that could — the failure mode is unreachable, not
1984/// merely discouraged.
1985#[derive(Clone, Copy, Debug)]
1986struct FrozenView {
1987 /// The underlying view — consulted for SQL SHAPE only (which conjuncts to
1988 /// emit), never to re-resolve the instant.
1989 view: ReadView,
1990 /// The instant resolved at freeze time. `None` ⇔ the view relaxes validity
1991 /// entirely, in which case no conjunct is emitted and nothing is bound.
1992 now: Option<i64>,
1993 /// TC-33 — the instant EDGE validity is evaluated at. Always present.
1994 ///
1995 /// The EXISTENCE-relaxation flag `include_out_of_window` belongs to the NODE
1996 /// validity axis and does NOT relax edge recency: an edge invalidated in the
1997 /// past stays excluded regardless. So this is the resolved instant even when
1998 /// `now` is `None`, and it is resolved from the SAME clock read.
1999 edge_now: i64,
2000}
2001
2002impl FrozenView {
2003 /// The instant to bind, resolved at freeze time. Unlike
2004 /// [`ReadView::now_param`] this is a stored value: calling it a second time
2005 /// cannot yield a different answer, and it never touches the clock.
2006 fn now_param(&self) -> Option<i64> {
2007 self.now
2008 }
2009
2010 /// TC-33 — the instant to bind for the EDGE-validity conjunct
2011 /// ([`edge_validity_sql`]). Frozen, like [`FrozenView::now_param`].
2012 ///
2013 /// Honouring `valid_as_of` here is what finally UNIFIES the node and edge
2014 /// temporal axes: step 22 recorded "the shipped EDGE path still inlines
2015 /// `datetime('now')`" as the reason they could not be unified. For the
2016 /// DEFAULT view (`valid_as_of == None`) this is the wall clock, i.e. exactly
2017 /// the pre-TC-33 behaviour.
2018 fn edge_now(&self) -> i64 {
2019 self.edge_now
2020 }
2021
2022 /// The validity conjunct — delegated to the one generator every read site
2023 /// shares, so the search arms cannot drift from the five read verbs.
2024 fn validity_sql(&self, alias: &str, now_idx: usize) -> String {
2025 self.view.validity_sql(alias, now_idx)
2026 }
2027}
2028
2029/// 0.8.20 Slice 15b fix-3 (F2) — how many times [`current_epoch_seconds`] has
2030/// been called in this process. Test-only observation; see
2031/// [`clock_reads_for_test`].
2032static CLOCK_READS: AtomicU64 = AtomicU64::new(0);
2033
2034/// Test seam — the process-wide count of wall-clock reads on the validity path.
2035/// Kept OFF the governed surface (`#[doc(hidden)]`, `_for_test`), mirroring the
2036/// sanctioned `set_vector_stage_only_for_test` / `vector_phase1_sql_for_test`
2037/// pattern; it is never re-exported from the `fathomdb` facade.
2038///
2039/// The counter is PROCESS-WIDE, so a test asserting on a delta must hold a
2040/// lock that excludes every other clock-reading test in its binary (test
2041/// binaries are separate processes, so only intra-binary contention matters).
2042/// `slice15b_search_validity_recall.rs` does this with a file-local mutex.
2043#[doc(hidden)]
2044#[must_use]
2045pub fn clock_reads_for_test() -> u64 {
2046 CLOCK_READS.load(Ordering::Relaxed)
2047}
2048
2049/// Wall-clock now as INTEGER epoch SECONDS (UTC), saturating at 0 before the
2050/// Unix epoch. The single place the node-validity path reads the clock — and it
2051/// is read in RUST, then BOUND, never inlined into SQL as `datetime('now')`.
2052fn current_epoch_seconds() -> i64 {
2053 // 0.8.20 Slice 15b fix-3 (F2) — meter every wall-clock read on the validity
2054 // path. R-20-NV requires `:now` to bind ONCE PER QUERY (not per row, not per
2055 // ARM): if two arms of one query each resolve *now*, a query that straddles
2056 // a validity boundary can have its arms disagree about which side they are
2057 // on. That is invisible to a result-shape assertion and unreachable by a
2058 // deterministic test — you cannot assert on a race. Counting the reads makes
2059 // the property testable WITHOUT racing the clock, and keeps failing for any
2060 // arm added later that re-reads it. `Relaxed` is sufficient: the counter is
2061 // an observation, never a synchronization point.
2062 CLOCK_READS.fetch_add(1, Ordering::Relaxed);
2063 std::time::SystemTime::now()
2064 .duration_since(std::time::UNIX_EPOCH)
2065 .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
2066 .unwrap_or(0)
2067}
2068
2069/// TC-33 — the edge-validity conjunct, bound to positional parameter
2070/// `?{now_idx}`. THE one generator for "is this edge valid at `:now`", so no
2071/// read site can drift from another (the same discipline
2072/// [`ReadView::validity_sql`] applies to node validity).
2073///
2074/// An edge is valid at `t` iff it has no invalid-time, or its invalid-time is
2075/// strictly in the future. `t_invalid` is INTEGER epoch seconds since step 23,
2076/// so this is a direct integer comparison — no `datetime()` conversion per row.
2077///
2078/// **`:now` is a BOUND PARAMETER, never `datetime('now')`.** Before TC-33 every
2079/// edge read site inlined `datetime('now')`, which made the predicate
2080/// non-deterministic, untestable, and re-evaluated per row; step 22's comment
2081/// flagged that as the reason node and edge validity could not be unified. They
2082/// are unified now.
2083///
2084/// Always begins with ` AND `, so every call site must already have a preceding
2085/// `WHERE` predicate.
2086fn edge_validity_sql(alias: &str, now_idx: usize) -> String {
2087 format!(" AND ({alias}.t_invalid IS NULL OR {alias}.t_invalid > ?{now_idx})")
2088}
2089
2090/// TC-33 — parse one ISO-8601 timestamp to INTEGER epoch seconds using SQLite's
2091/// own date parser, via a BOUND parameter.
2092///
2093/// Returns `None` when SQLite cannot resolve the value — `strftime` yields SQL
2094/// NULL for junk (`'not a date'`, `''`, `'2020-13-45T99:99:99Z'`, a bare epoch
2095/// string, whitespace-padded input, non-ASCII digits) and a digit string for
2096/// anything it understands.
2097///
2098/// **Why SQLite and not a date crate:** there is no `chrono`/`time` dependency
2099/// anywhere in the workspace, and HITL directed this spelling rather than adding
2100/// one. The value is BOUND, never interpolated.
2101///
2102/// **This does not violate the inline-clock rule.** That rule forbids
2103/// `datetime('now')` / `strftime('%s','now')` — an inline CLOCK. Parsing a bound
2104/// user value is deterministic and reads no clock. The current instant still
2105/// comes from the bound `:now` seam ([`current_epoch_seconds`]).
2106///
2107/// The `CAST` matters: `strftime('%s', ...)` returns TEXT (a digit string), not
2108/// an integer, and the column is `typeof(...) = 'integer'`-checked.
2109///
2110/// # TC-33 fix-5 — strict ISO-8601 SHAPE gate before delegating to SQLite
2111///
2112/// `strftime('%s', ?)` alone is NOT an ISO-8601 validator: SQLite's date parser
2113/// is MORE lenient than the declared wire contract. A bare number is read as a
2114/// **Julian day** (`strftime('%s','2451545.0')` → `946728000`, i.e. year 2000)
2115/// and `strftime('%s','0')` resolves to a pre-year-0000 epoch — so non-ISO input
2116/// was ACCEPTED and stored as an unrelated instant despite the "hard-reject
2117/// ISO-8601" contract HITL ratified (2026-07-21). [`is_iso8601_shape`] runs
2118/// FIRST and returns `None` for anything that is not a strict ISO-8601
2119/// date/datetime shape, so the existing hard-reject path fires. The shape gate
2120/// does NOT replace SQLite's calendar math — a shape-valid but impossible date
2121/// (`2025-13-45T00:00:00Z`) still `None`s out via `strftime` and hard-rejects.
2122///
2123/// # TC-47 — the calendar-DATE ROUND-TRIP backstop (keystone terminal codex P2)
2124///
2125/// The shape gate checks FORMAT, not CALENDAR VALIDITY, and `strftime('%s', ?)`
2126/// does NOT fully validate the calendar: it **rolls over an impossible DAY**
2127/// rather than returning NULL — `strftime('%s','2025-02-30T00:00:00Z')` yields
2128/// the epoch for `2025-03-02`, and `2025-04-31` yields `2025-05-01`. So a
2129/// shape-valid Feb-30 would parse to a DIFFERENT instant than the provider
2130/// supplied, bypassing the hard-reject contract. (An impossible MONTH like
2131/// `2025-13-01`, and impossible TIMES like `25:00:00` / `:60` / `:61`, already
2132/// NULL out; only impossible DAYS roll over — that is the sole residue.)
2133///
2134/// The `WHERE` clause is the round-trip: the literal calendar DATE component of
2135/// the input (`substr(?1, 1, 10)` — the `YYYY-MM-DD` the shape gate guarantees is
2136/// present) must survive SQLite's own calendar math UNCHANGED. If it rolled over,
2137/// `strftime('%Y-%m-%d', substr(?1,1,10))` differs from the literal substring and
2138/// the `WHERE` yields zero rows => `query_row` -> `QueryReturnedNoRows` -> `None`
2139/// => the existing hard-reject fires. This is a superset of the shape gate: it
2140/// also rejects the TC-44 Julian string `2451545.0` (its `substr(1,10)` renders
2141/// to `2000-01-01`, not itself).
2142///
2143/// **Why the DATE component and not the raw string or the UTC-rendered instant:**
2144/// a raw-string or `unixepoch`-rendered comparison would FALSE-REJECT valid
2145/// equivalent forms. `Z` vs `+00:00`, a non-UTC offset like `+05:00`, date-only,
2146/// and fractional seconds are all valid and store the correct (offset-shifted)
2147/// epoch — but a UTC re-render shifts the wall clock, so its date can differ from
2148/// the input's literal date. Comparing ONLY the literal DATE field is
2149/// tz-INVARIANT (the offset never alters the input's own `YYYY-MM-DD` text) while
2150/// still catching every DAY rollover, because the rollover happens in the
2151/// calendar math BEFORE any offset is applied. **Pure SQL — no date crate.**
2152fn iso8601_to_epoch_seconds(connection: &Connection, raw: &str) -> Option<i64> {
2153 if !is_iso8601_shape(raw) {
2154 return None;
2155 }
2156 connection
2157 .query_row(
2158 "SELECT CAST(strftime('%s', ?1) AS INTEGER) \
2159 WHERE strftime('%Y-%m-%d', substr(?1, 1, 10)) IS substr(?1, 1, 10)",
2160 params![raw],
2161 |r| r.get::<_, Option<i64>>(0),
2162 )
2163 .ok()
2164 .flatten()
2165}
2166
2167/// TC-33 fix-5 — strict ISO-8601 date/datetime SHAPE gate. Hand-rolled on ASCII
2168/// bytes (NO new dependency: the workspace has no `chrono`/`time`, and `regex`
2169/// is only a transitive dep of `jsonschema`, not a direct one — adding either as
2170/// a direct dep would violate the "no new dependency" constraint).
2171///
2172/// Accepts EXACTLY:
2173/// - `YYYY-MM-DD` (date only); optionally followed by
2174/// - a `T` **or** a single space separator, then `HH:MM:SS`; optionally followed
2175/// by `.fff` fractional seconds (one or more digits); optionally followed by
2176/// a zone: `Z`, or `±HH:MM`, or `±HHMM`.
2177///
2178/// Rejects bare numbers (`0`, `2451545.0`), partial junk, whitespace, non-ASCII
2179/// digits, and anything with trailing characters. All digit positions require
2180/// ASCII `0..=9` (`u8::is_ascii_digit`), so non-ASCII digit look-alikes cannot
2181/// slip through. This is a SHAPE check only — calendar validity (e.g. month 13,
2182/// day 45) is still enforced by SQLite's `strftime` after this gate passes.
2183fn is_iso8601_shape(s: &str) -> bool {
2184 let b = s.as_bytes();
2185 let d = |c: u8| c.is_ascii_digit();
2186
2187 // Date: YYYY-MM-DD (exactly 10 bytes).
2188 if b.len() < 10 {
2189 return false;
2190 }
2191 if !(d(b[0])
2192 && d(b[1])
2193 && d(b[2])
2194 && d(b[3])
2195 && b[4] == b'-'
2196 && d(b[5])
2197 && d(b[6])
2198 && b[7] == b'-'
2199 && d(b[8])
2200 && d(b[9]))
2201 {
2202 return false;
2203 }
2204 if b.len() == 10 {
2205 return true; // date-only
2206 }
2207
2208 // Separator (`T` or a single space) + time HH:MM:SS (indices 10..=18).
2209 if b[10] != b'T' && b[10] != b' ' {
2210 return false;
2211 }
2212 if b.len() < 19 {
2213 return false;
2214 }
2215 if !(d(b[11])
2216 && d(b[12])
2217 && b[13] == b':'
2218 && d(b[14])
2219 && d(b[15])
2220 && b[16] == b':'
2221 && d(b[17])
2222 && d(b[18]))
2223 {
2224 return false;
2225 }
2226
2227 let mut i = 19;
2228
2229 // Optional fractional seconds `.fff` (one or more digits).
2230 if i < b.len() && b[i] == b'.' {
2231 i += 1;
2232 let start = i;
2233 while i < b.len() && d(b[i]) {
2234 i += 1;
2235 }
2236 if i == start {
2237 return false; // `.` with no digits
2238 }
2239 }
2240
2241 // Optional zone.
2242 if i == b.len() {
2243 return true; // no zone
2244 }
2245 match b[i] {
2246 b'Z' => i += 1,
2247 b'+' | b'-' => {
2248 i += 1;
2249 // `HH`
2250 if i + 2 > b.len() || !d(b[i]) || !d(b[i + 1]) {
2251 return false;
2252 }
2253 i += 2;
2254 // `:MM` or `MM`
2255 if i < b.len() && b[i] == b':' {
2256 i += 1;
2257 }
2258 if i + 2 > b.len() || !d(b[i]) || !d(b[i + 1]) {
2259 return false;
2260 }
2261 i += 2;
2262 }
2263 _ => return false,
2264 }
2265
2266 i == b.len() // no trailing junk
2267}
2268
2269/// TC-33 — render INTEGER epoch seconds back to an ISO-8601 UTC string for the
2270/// BYO-LLM wire. The exact inverse of [`iso8601_to_epoch_seconds`].
2271///
2272/// Storage and the governed SDK are epoch seconds, but the harness protocols
2273/// (`fathomdb.extract.v1` and the consolidation harness) carry ISO-8601 — LLMs
2274/// reason about dates as text, and pushing epoch integers onto them would make
2275/// the wire hostile to the very providers it exists to serve. So the boundary
2276/// converts in BOTH directions and the representation split stays a boundary
2277/// concern rather than leaking into the protocol.
2278fn epoch_seconds_to_iso8601(connection: &Connection, epoch: i64) -> Option<String> {
2279 connection
2280 .query_row("SELECT strftime('%Y-%m-%dT%H:%M:%SZ', ?1, 'unixepoch')", params![epoch], |r| {
2281 r.get::<_, Option<String>>(0)
2282 })
2283 .ok()
2284 .flatten()
2285}
2286
2287/// TC-33 fix-1 — the inclusive epoch-seconds range SQLite's
2288/// `strftime(..., 'unixepoch')` can render back to ISO-8601. SQLite's date
2289/// functions cover years 0000..=9999 ONLY, so:
2290/// - `MIN` = `0000-01-01T00:00:00Z`
2291/// - `MAX` = `9999-12-31T23:59:59Z`
2292///
2293/// TC-33 fix-5 makes these the ACTUAL rejection predicate (a numeric
2294/// `[MIN, MAX]` bounds check), not just message text. Renderability was too
2295/// weak: `strftime(..., 'unixepoch')` renders a below-`MIN` value like
2296/// `-62_167_219_201` to `-001-12-31T23:59:59Z` (NON-NULL), so a pre-year-0000
2297/// epoch slipped the renderability guard even though it is outside the declared
2298/// years 0000..=9999. Both bounds are verified against SQLite to correspond
2299/// EXACTLY to the first/last renderable instant:
2300/// `strftime('%Y-%m-%dT%H:%M:%SZ', MIN, 'unixepoch') = 0000-01-01T00:00:00Z` and
2301/// `= 9999-12-31T23:59:59Z` for `MAX` (`MAX+1` and `MIN-1` are the first
2302/// out-of-range instants).
2303const MIN_RENDERABLE_EPOCH: i64 = -62_167_219_200; // 0000-01-01T00:00:00Z
2304const MAX_RENDERABLE_EPOCH: i64 = 253_402_300_799; // 9999-12-31T23:59:59Z
2305
2306/// TC-33 fix-1 — reject an edge epoch that SQLite cannot render back to
2307/// ISO-8601, at the governed write boundary, so it is UNSTORABLE.
2308///
2309/// # Why this is the primary layer
2310///
2311/// Storage and `PreparedWrite::Edge` carry INTEGER epoch seconds and accept an
2312/// arbitrary `i64`. The consolidation path renders each candidate's
2313/// `t_valid`/`t_invalid` to ISO-8601 for the LLM via `strftime(..., 'unixepoch')`,
2314/// which only spans years 0000..=9999. An epoch outside that range renders to
2315/// NULL, and the render site would then send a silent `null` for a timestamp
2316/// that is actually stored NON-NULL — the OUTBOUND twin of the fail-open TC-33
2317/// removes. A `null` `t_invalid` reads as "still valid", and the consolidation
2318/// reference stub echoes a winner's `t_valid` straight back as the verdict's
2319/// `t_invalid`, so the `null` round-trips through the inbound normaliser as
2320/// "still valid": an invalidated edge silently resurrected.
2321///
2322/// Inbound ISO normalisation can never MINT such an epoch (a 4-digit-year ISO
2323/// string maxes at 9999), so the governed integer surface is the only ingress —
2324/// which is exactly where this guard sits. Like the inbound
2325/// [`normalize_extractor_timestamp`] hard-reject, it is a typed
2326/// [`EngineError::InvalidArgument`] naming the offending value and the bound,
2327/// never a silent coercion.
2328///
2329/// ⚠ It no longer mirrors the `validate_write` Node branch's
2330/// `valid_from >= valid_until` refusal: decision #18 (0.8.20 Slice 22) moved
2331/// THAT refusal onto the message-less [`EngineError::WriteValidation`] unit
2332/// variant, which carries no value at all. The two refusals are deliberately in
2333/// different families now — a malformed submitted write SHAPE is
2334/// `WriteValidation`; an out-of-domain scalar on this render path stays
2335/// `InvalidArgument`. Do not restate them as one pattern.
2336fn reject_unrenderable_edge_epoch(field: &str, value: Option<i64>) -> Result<(), EngineError> {
2337 // TC-33 fix-5 — an explicit numeric MIN/MAX bounds check, NOT a renderability
2338 // test. `strftime(..., 'unixepoch')` renders a below-`MIN` epoch (e.g.
2339 // `-62_167_219_201`, year -0001) to a NON-NULL string, so a renderability
2340 // guard would let pre-year-0000 values through even though they are outside
2341 // the declared years 0000..=9999. No `Connection` is needed now that the
2342 // predicate is pure integer arithmetic.
2343 if let Some(ts) = value {
2344 if !(MIN_RENDERABLE_EPOCH..=MAX_RENDERABLE_EPOCH).contains(&ts) {
2345 return Err(EngineError::InvalidArgument {
2346 msg: format!(
2347 "edge field `{field}` = {ts} is outside the epoch-seconds range SQLite can \
2348 render to ISO-8601 ([{MIN_RENDERABLE_EPOCH}, {MAX_RENDERABLE_EPOCH}], i.e. \
2349 years 0000..=9999). REJECTED rather than stored: such an epoch renders to a \
2350 silent NULL (or a nonsensical out-of-range instant) on the consolidation \
2351 wire, and a NULL `t_invalid` reads as \"still valid\" — resurrecting an \
2352 invalidated edge."
2353 ),
2354 });
2355 }
2356 }
2357 Ok(())
2358}
2359
2360/// The JSON type name of `value`, for diagnosing a mistyped extractor field.
2361fn json_type_name(value: &Value) -> &'static str {
2362 match value {
2363 Value::Null => "null",
2364 Value::Bool(_) => "boolean",
2365 Value::Number(_) => "number",
2366 Value::String(_) => "string",
2367 Value::Array(_) => "array",
2368 Value::Object(_) => "object",
2369 }
2370}
2371
2372/// TC-33 — normalise one timestamp arriving on the **BYO-LLM extractor
2373/// boundary** (`fathomdb.extract.v1`) into the INTEGER epoch seconds the storage
2374/// and governed-SDK layers use. **HARD-REJECTS** anything it cannot normalise.
2375///
2376/// This is the layering boundary HITL ratified on 2026-07-21:
2377/// - the **extractor wire format stays ISO-8601 strings** — LLMs emit text, and
2378/// this function is the one place that changes;
2379/// - **storage and the governed SDK surface are INTEGER epoch seconds.**
2380///
2381/// # Why rejection, not coercion — fail-open is the defect
2382///
2383/// A NULL `t_invalid` means **"still valid"**. So any path that turns an
2384/// unparseable timestamp into NULL silently **resurrects an invalidated edge**.
2385/// Two distinct fail-opens are closed here:
2386///
2387/// 1. **Malformed strings.** Previously NOTHING parsed or validated these; junk
2388/// went verbatim into the INSERT. Under the old TEXT column it then failed
2389/// CLOSED by accident (`datetime('junk')` → NULL ⇒ the read disjunct is
2390/// falsy ⇒ the row vanished). Under INTEGER that polarity would INVERT.
2391/// 2. **Non-string JSON — a fail-open that PREDATES TC-33.** The old site read
2392/// `edge.get("t_invalid").and_then(|v| v.as_str())`, and `as_str()` returns
2393/// `None` for a JSON number/bool/object. So `"t_invalid": 1710000000` — a
2394/// plausible mistake, and exactly the epoch form storage now uses — had its
2395/// invalidation SILENTLY DISCARDED and the edge stored as "still valid".
2396///
2397/// `None`/JSON `null`/absent is the ONLY sanctioned way to say "unknown"; it
2398/// maps to `Ok(None)` and keeps the NULL-means-still-valid semantic.
2399///
2400/// Refuses with a typed [`EngineError::InvalidArgument`] CARRYING the offending
2401/// value, so a caller can see what was rejected.
2402///
2403/// ⚠ This is NOT the same pattern as the `validate_write` `Node` branch's
2404/// `valid_from >= valid_until` check, which that comment used to cite: decision
2405/// #18 (0.8.20 Slice 22) moved that refusal onto the message-less
2406/// [`EngineError::WriteValidation`] unit variant, which carries **no value at
2407/// all**. Both the family AND the carry-the-value property differ.
2408fn normalize_extractor_timestamp(
2409 connection: &Connection,
2410 field: &str,
2411 raw: Option<&Value>,
2412) -> Result<Option<i64>, EngineError> {
2413 match raw {
2414 None | Some(Value::Null) => Ok(None),
2415 Some(Value::String(text)) => match iso8601_to_epoch_seconds(connection, text) {
2416 Some(epoch) => Ok(Some(epoch)),
2417 None => Err(EngineError::InvalidArgument {
2418 msg: format!(
2419 "extractor edge field `{field}` must be a valid, calendar-real ISO-8601 \
2420 timestamp; got {text:?}, which either `strftime('%s', ?)` resolves to NULL \
2421 or fails the calendar round-trip (a shape-valid but impossible DAY like \
2422 `2025-02-30` that SQLite would silently ROLL OVER to a different instant). \
2423 REJECTED rather than stored: a NULL `t_invalid` reads as \"still valid\" and \
2424 a rolled-over date stores the WRONG instant — both breach the hard-reject \
2425 contract. Use JSON null for \"unknown\"."
2426 ),
2427 }),
2428 },
2429 Some(other) => Err(EngineError::InvalidArgument {
2430 msg: format!(
2431 "extractor edge field `{field}` must be an ISO-8601 string or JSON null; got a \
2432 JSON {kind} ({other}). The `fathomdb.extract.v1` wire format carries ISO-8601 at \
2433 this boundary — INTEGER epoch seconds are the STORAGE representation, not the \
2434 wire one. REJECTED rather than coerced to NULL, which reads as \"still valid\".",
2435 kind = json_type_name(other)
2436 ),
2437 }),
2438 }
2439}
2440
2441/// Slice 30 (G3) — one `operational_mutations` row returned by `read.collection`
2442/// / `read.mutations`. `id` is the autoincrement PK (the after-id cursor key).
2443#[derive(Clone, Debug, Eq, PartialEq)]
2444pub struct OpStoreRow {
2445 pub id: i64,
2446 pub collection: String,
2447 pub record_key: String,
2448 pub op_kind: String,
2449 pub payload: String,
2450 pub schema_id: Option<String>,
2451 pub write_cursor: u64,
2452}
2453
2454/// Hybrid `search` result. `results` carries structured [`SearchHit`]s in
2455/// vector-first, dedup-on-body order. Derives `Clone, Debug, PartialEq` but
2456/// **not `Eq`** — each hit carries a `score: f64`.
2457#[derive(Clone, Debug, PartialEq)]
2458// 0.8.8 EXP-OBS (field-set ratification): non_exhaustive so future additive fields
2459// (e.g. the deferred QueryTrace.timings_ms, Q3) are non-breaking. All construction
2460// is in-crate (engine + tests); external crates read fields only.
2461#[non_exhaustive]
2462pub struct SearchResult {
2463 pub projection_cursor: u64,
2464 pub soft_fallback: Option<SoftFallback>,
2465 pub results: Vec<SearchHit>,
2466 /// 0.8.8 EXP-OBS (Slice 5) — opt-in retrieval explanation **sidecar**.
2467 /// `Some` ONLY on the `search_explained` path; `None` for every default
2468 /// (`explain=false`) search, so `results` + `projection_cursor` stay
2469 /// byte-identical to the pre-0.8.8 shape (R-OBS-2 zero-cost contract,
2470 /// HITL-ratified sidecar carrier — see
2471 /// `dev/design/0.8.8-explain-and-telemetry-adr.md` §A.2). Field-set is
2472 /// PROPOSED/ratification-pending; additive inside `Explanation` so later
2473 /// amendments do not reshape `SearchResult`/`SearchHit`.
2474 pub explanation: Option<Explanation>,
2475}
2476
2477/// 0.8.8 EXP-OBS (Slice 5) — the opt-in retrieval explanation payload returned
2478/// behind `search_explained` (the `explain=true` surface). Built from the
2479/// engine's OWN fusion/rerank machinery (`fuse_three_arms` per-arm ranks,
2480/// `ce_rerank` blend components) — no parallel machinery (R-OBS-3). Carries a
2481/// query-level [`QueryTrace`] plus a per-hit breakdown parallel to (and in the
2482/// same order as) `SearchResult.results`.
2483///
2484/// Derives `Clone, Debug, PartialEq` but **not `Eq`** — scores are `f64`.
2485#[derive(Clone, Debug, PartialEq)]
2486#[non_exhaustive] // 0.8.8 field-set ratification — additive-safe sidecar
2487pub struct Explanation {
2488 pub trace: QueryTrace,
2489 pub per_hit: Vec<PerHitExplain>,
2490}
2491
2492/// 0.8.8 EXP-OBS (Slice 5) — query-level retrieval trace. Reuses the existing
2493/// `search_reranked` knobs + the active embedder identity; timings are coarse
2494/// per-stage wall-clock (monotonic) captured only on the explain path.
2495#[derive(Clone, Debug, PartialEq)]
2496// 0.8.8 field-set ratification — HARD: leaf absorbs the deferred `timings_ms` (Q3)
2497// and any future trace field without a contract break.
2498#[non_exhaustive]
2499pub struct QueryTrace {
2500 /// Query LENGTH only (chars) — never the query text (privacy; ADR §C).
2501 pub query_chars: u32,
2502 /// Caller-requested final result limit (`final_limit`).
2503 pub k: u32,
2504 pub rerank_depth: u32,
2505 pub pool_n: u32,
2506 pub alpha: f64,
2507 pub use_graph_arm: bool,
2508 /// Recency reweight (the dedicated G12 flag) was applied.
2509 pub recency: bool,
2510 /// Active embedder identity `name@revision` (+ dim), or empty when none.
2511 pub embedder_id: String,
2512 /// The CE cross-encoder actually reranked the pool (model loaded + depth>0).
2513 pub ce_active: bool,
2514 /// Per-arm input hit counts (pre-fusion).
2515 pub vector_hits: u32,
2516 pub text_hits: u32,
2517 pub graph_hits: u32,
2518 /// Edge-FTS candidates rejected only because an attribute predicate is
2519 /// node-scoped. Present on the opt-in explanation so this deliberate
2520 /// filtering never looks like an absent corpus.
2521 pub dropped_edge_hits: u32,
2522}
2523
2524/// 0.8.8 EXP-OBS (Slice 5) — per-hit provenance + score breakdown. One entry per
2525/// returned `SearchHit`, same order. `*_rank` is the 0-based rank the hit's body
2526/// held in that arm's pre-fusion list (`None` = absent from that arm).
2527///
2528/// Derives `Clone, Debug, PartialEq` but **not `Eq`** — scores are `f64`.
2529#[derive(Clone, Debug, PartialEq)]
2530// 0.8.8 field-set ratification — HARD: leaf absorbs future arms / score components.
2531#[non_exhaustive]
2532pub struct PerHitExplain {
2533 /// The hit's engine-internal positional `write_cursor` (the pre-C-2
2534 /// `SearchHit.id`). Post-0.8.19 the caller-facing `SearchHit.id` is a typed
2535 /// [`IdSpace`]; this field keeps carrying the positional cursor so the explain
2536 /// sidecar cross-references the telemetry `result_ids` space. Correlate a
2537 /// `PerHitExplain` to its `SearchHit` by position (both lists are 1:1, same
2538 /// order).
2539 pub id: u64,
2540 /// Winning arm after RRF dedup (vector-first), == `SearchHit.branch`.
2541 pub arm: SoftFallbackBranch,
2542 pub vector_rank: Option<u32>,
2543 pub text_rank: Option<u32>,
2544 pub graph_rank: Option<u32>,
2545 /// Raw RRF fused score AFTER recency reweight, BEFORE CE blend (the value
2546 /// `ce_rerank` normalizes). Faithful to the engine computation — downstream
2547 /// may normalize. (ADR §A.4 Q1: raw exposed; normalization deferred.)
2548 pub fused_score: f64,
2549 /// In-pool cross-encoder score `sigmoid(ce_logit) ∈ [0,1]`, == the returned
2550 /// `SearchHit.ce_score`; `None` outside the reranked pool / no-CE path.
2551 pub ce_score: Option<f64>,
2552 /// Final blended score, == the returned `SearchHit.score`.
2553 pub blended: f64,
2554 /// 0.8.16 Slice 5 / F9 — the node `importance` scalar applied to this hit's
2555 /// fused contribution when the importance reweight is ON, else the raw stored
2556 /// value. `None` = never assigned (graceful-absent, ranks NEUTRAL). Additive
2557 /// (`#[non_exhaustive]` leaf absorbs the new score component).
2558 pub importance: Option<f64>,
2559 /// 0.8.16 Slice 5 / F9 — the edge `confidence` scalar applied to this hit's
2560 /// graph-arm contribution when the importance reweight is ON, else the raw
2561 /// stored value. `None` for node hits / edges without a confidence
2562 /// (graceful-absent, ranks NEUTRAL).
2563 pub confidence: Option<f64>,
2564}
2565
2566// ===== G4 filter grammar types (Slice 35) ===============================
2567
2568/// G4 (Slice 35) — scalar value for [`Predicate`] comparisons.
2569///
2570/// Shared vocabulary with G10 — defined once at the `fathomdb-engine` crate
2571/// root so reserved-gap 37 (full G4↔G10 unification) can import it without a
2572/// path change. Derives `Clone, Debug, PartialEq` per the ADR contract
2573/// (D-F1 exhaustiveness: exactly `{Text, Integer, Bool}`).
2574#[derive(Clone, Debug, PartialEq)]
2575pub enum ScalarValue {
2576 Text(String),
2577 Integer(i64),
2578 Bool(bool),
2579}
2580
2581/// G4 (Slice 35) — comparison operator for [`Predicate::JsonPathCompare`].
2582///
2583/// Shared vocabulary (same crate-root export as `ScalarValue`). Closed
2584/// enum: `{Gt, Gte, Lt, Lte}` per D-F1. Derives `Clone, Debug, PartialEq`.
2585#[derive(Clone, Debug, PartialEq)]
2586pub enum ComparisonOp {
2587 Gt,
2588 Gte,
2589 Lt,
2590 Lte,
2591}
2592
2593/// Allowed JSON paths for [`Predicate`] constructors. The SQL compilation in
2594/// [`Engine::read_list`] uses the **allowlist constant** (a server-side literal),
2595/// never the caller-supplied string, so only paths in this set reach
2596/// `json_extract`. Callers receive [`EngineError::InvalidFilter`] for any
2597/// non-allowlisted path — no passthrough, no panic.
2598///
2599/// To extend: add an entry here. No API change is needed; the constructor
2600/// accepts the new path string once it appears in this array.
2601const PREDICATE_PATH_ALLOWLIST: &[&str] =
2602 &["$.status", "$.priority", "$.tags", "$.kind", "$.created_at", "$.action_kind"];
2603
2604/// G4 (Slice 35) — closed typed predicate for [`Engine::read_list`] filter.
2605///
2606/// Exactly two variants per ADR D-F1 (`{JsonPathEq, JsonPathCompare}`).
2607/// The fused variants (`JsonPathFused*`) and all `*_unchecked` builders are
2608/// explicitly EXCLUDED (ADR D-F2). Use the validated constructors
2609/// [`Predicate::json_path_eq`] / [`Predicate::json_path_compare`]; they
2610/// enforce the path allowlist at construction time.
2611///
2612/// Multiple predicates in [`Engine::read_list`] are combined by implicit AND
2613/// (D-F5). Compilation target: `json_extract(body, '$.field') <op> ?` with
2614/// a bound parameter (never interpolated — injection-safe per D-F4).
2615#[derive(Clone, Debug, PartialEq)]
2616pub enum Predicate {
2617 /// `json_extract(body, path) = ?` (equality).
2618 JsonPathEq { path: String, value: ScalarValue },
2619 /// `json_extract(body, path) <op> ?` (inequality).
2620 JsonPathCompare { path: String, op: ComparisonOp, value: ScalarValue },
2621}
2622
2623impl Predicate {
2624 /// Construct a `JsonPathEq` predicate with allowlist validation.
2625 ///
2626 /// Returns [`EngineError::InvalidFilter`] if `path` is not in
2627 /// [`PREDICATE_PATH_ALLOWLIST`]; never panics on bad input.
2628 pub fn json_path_eq(path: impl Into<String>, value: ScalarValue) -> Result<Self, EngineError> {
2629 let path = path.into();
2630 if !PREDICATE_PATH_ALLOWLIST.contains(&path.as_str()) {
2631 return Err(EngineError::InvalidFilter {
2632 reason: format!("path '{path}' is not in the predicate path allowlist"),
2633 });
2634 }
2635 Ok(Self::JsonPathEq { path, value })
2636 }
2637
2638 /// Construct a `JsonPathCompare` predicate with allowlist validation.
2639 ///
2640 /// Returns [`EngineError::InvalidFilter`] if `path` is not in
2641 /// [`PREDICATE_PATH_ALLOWLIST`]; never panics on bad input.
2642 pub fn json_path_compare(
2643 path: impl Into<String>,
2644 op: ComparisonOp,
2645 value: ScalarValue,
2646 ) -> Result<Self, EngineError> {
2647 let path = path.into();
2648 if !PREDICATE_PATH_ALLOWLIST.contains(&path.as_str()) {
2649 return Err(EngineError::InvalidFilter {
2650 reason: format!("path '{path}' is not in the predicate path allowlist"),
2651 });
2652 }
2653 Ok(Self::JsonPathCompare { path, op, value })
2654 }
2655
2656 /// Return the validated path string for use in SQL compilation.
2657 /// This always returns a path that is in `PREDICATE_PATH_ALLOWLIST`.
2658 fn path(&self) -> &str {
2659 match self {
2660 Self::JsonPathEq { path, .. } => path.as_str(),
2661 Self::JsonPathCompare { path, .. } => path.as_str(),
2662 }
2663 }
2664
2665 /// Compile this predicate to a SQL WHERE clause fragment.
2666 /// The path is validated at construction time and is always an allowlist
2667 /// constant — never the raw caller-supplied string.
2668 fn to_sql_clause(&self, param_idx: usize) -> String {
2669 // The path is already validated against the allowlist at construction.
2670 // We use the allowlist entry (the stored path) directly as a SQL literal.
2671 // The VALUE is always a bound `?` parameter (injection-safe).
2672 //
2673 // Type guards prevent cross-type matches caused by SQLite's json_extract
2674 // coercing JSON booleans to integer 1/0:
2675 // - Bool predicates: AND json_type IN ('true', 'false') — exclude integers
2676 // - Integer predicates: AND json_type = 'integer' — exclude booleans
2677 // Text predicates need no guard: json_extract returns TEXT for strings and
2678 // the coercion never conflates TEXT with integer/bool.
2679 let path = self.path();
2680 match self {
2681 Self::JsonPathEq { value, .. } => match value {
2682 ScalarValue::Bool(_) => format!(
2683 "json_extract(body, '{path}') = ?{param_idx} \
2684 AND json_type(body, '{path}') IN ('true', 'false')"
2685 ),
2686 ScalarValue::Integer(_) => format!(
2687 "json_extract(body, '{path}') = ?{param_idx} \
2688 AND json_type(body, '{path}') = 'integer'"
2689 ),
2690 ScalarValue::Text(_) => {
2691 format!("json_extract(body, '{path}') = ?{param_idx}")
2692 }
2693 },
2694 Self::JsonPathCompare { op, value, .. } => {
2695 let op_str = match op {
2696 ComparisonOp::Gt => ">",
2697 ComparisonOp::Gte => ">=",
2698 ComparisonOp::Lt => "<",
2699 ComparisonOp::Lte => "<=",
2700 };
2701 match value {
2702 ScalarValue::Bool(_) => format!(
2703 "json_extract(body, '{path}') {op_str} ?{param_idx} \
2704 AND json_type(body, '{path}') IN ('true', 'false')"
2705 ),
2706 ScalarValue::Integer(_) => format!(
2707 "json_extract(body, '{path}') {op_str} ?{param_idx} \
2708 AND json_type(body, '{path}') = 'integer'"
2709 ),
2710 ScalarValue::Text(_) => format!(
2711 "json_extract(body, '{path}') {op_str} ?{param_idx} \
2712 AND json_type(body, '{path}') = 'text'"
2713 ),
2714 }
2715 }
2716 }
2717 }
2718
2719 /// Bind the value of this predicate as a rusqlite parameter.
2720 fn bind_value(&self) -> rusqlite::types::Value {
2721 let value = match self {
2722 Self::JsonPathEq { value, .. } => value,
2723 Self::JsonPathCompare { value, .. } => value,
2724 };
2725 match value {
2726 ScalarValue::Text(s) => rusqlite::types::Value::Text(s.clone()),
2727 ScalarValue::Integer(i) => rusqlite::types::Value::Integer(*i),
2728 ScalarValue::Bool(b) => rusqlite::types::Value::Integer(i64::from(*b)),
2729 }
2730 }
2731}
2732
2733// ===== Slice 20 (G5/G6) — graph traversal types =========================
2734
2735/// Slice 20 (G5) — direction of graph traversal for
2736/// [`Engine::graph_neighbors`] / [`Engine::search_expand`].
2737///
2738/// `Outgoing` follows edges where the root is the `from_id` (source).
2739/// `Incoming` follows edges where the root is the `to_id` (target).
2740/// `Both` follows edges in either direction.
2741#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2742pub enum TraversalDirection {
2743 Outgoing,
2744 Incoming,
2745 Both,
2746}
2747
2748/// Slice 20 (G6) — result of [`Engine::search_expand`]: initial search hits
2749/// plus nodes reached by bounded BFS expansion that are not already in the
2750/// search hit set.
2751#[derive(Clone, Debug)]
2752pub struct SearchExpandResult {
2753 /// Original RRF-scored search results (G1+G9 hybrid).
2754 pub search_hits: Vec<SearchHit>,
2755 /// Nodes reached by graph traversal but NOT already in `search_hits`.
2756 /// Each entry is `(node, hop_count)` where `hop_count` is the BFS depth
2757 /// from the nearest search hit that reached this node.
2758 pub expanded: Vec<(NodeRecord, u32)>,
2759 /// Deduplicated union of all logical_ids (search hits first, then expanded).
2760 pub all_logical_ids: Vec<String>,
2761}
2762
2763/// G10 — closed metadata filter for [`Engine::search_filtered`] (Slice 10).
2764///
2765/// All fields are optional; a `None` field imposes no constraint, and an
2766/// all-`None` filter (or `None` filter) is the unfiltered path whose phase-1 SQL
2767/// is byte-identical to 0.7.2. This is a **closed struct**, not an open filter
2768/// DSL (ADR-0.8.0-agent-memory-retrieval-and-identity Q1); the filter-grammar /
2769/// `list` decision stays a later-slice concern.
2770///
2771/// `created_after` is a `created_at >= bound` lower bound in unix seconds.
2772/// `status` is wired through to the vec0 `status` metadata column. vec0 TEXT
2773/// metadata columns are **NOT NULL-able**, so the "no real population yet" state
2774/// is an **empty-string sentinel** `''` (a forced deviation from the planned
2775/// "NULL plumbing"; a real population source is reserved-gap candidate 13). A
2776/// `status = Some("open")`-style filter therefore prunes every row until that
2777/// population slice lands.
2778// 0.8.20 Slice 15e fix-2 (Finding 2) — `#[non_exhaustive]`: the `attributes`
2779// field was added additively in 0.8.20. Marking the struct non-exhaustive means
2780// EXTERNAL crates can no longer use a struct literal `SearchFilter { .. }` and
2781// must go through `..Default::default()` (or a constructor), so a FUTURE field
2782// add is not a source break for them. Internal (in-workspace) construction is
2783// unaffected — `#[non_exhaustive]` only constrains other crates — and every
2784// in-crate literal already spreads `..Default::default()`. Governed-surface
2785// status: PROPOSED / NOT SIGNED.
2786#[derive(Clone, Debug, Default, Eq, PartialEq)]
2787#[non_exhaustive]
2788pub struct SearchFilter {
2789 pub source_type: Option<String>,
2790 pub kind: Option<String>,
2791 pub created_after: Option<i64>,
2792 pub status: Option<String>,
2793 /// 0.8.20 Slice 15e (R-20-PR, ADR-0.8.11 D3) — declared-`filterable`-attribute
2794 /// equality predicates, each `(attribute_name, value)`. Lowered into the
2795 /// **indexed pre-KNN** vec0 metadata column `attr_<hex>` by
2796 /// [`vector_filter_clause`] (NOT a post-KNN `json_extract`). Empty ⇒ the
2797 /// byte-identical unfiltered path is preserved. `attribute_name` is the
2798 /// registry projection name; the encoded column is derived by
2799 /// [`attr_vec0_column`].
2800 pub attributes: Vec<(String, String)>,
2801}
2802
2803impl SearchFilter {
2804 /// True when no field constrains the search — equivalent to `None`. Used to
2805 /// keep the unfiltered code path (and its byte-identical SQL) on the
2806 /// all-`None` struct.
2807 fn is_unfiltered(&self) -> bool {
2808 self.source_type.is_none()
2809 && self.kind.is_none()
2810 && self.created_after.is_none()
2811 && self.status.is_none()
2812 && self.attributes.is_empty()
2813 }
2814}
2815
2816// ===== 0.8.11 Slice 40 (#17) — unified filter grammar (G4 + G10) =========
2817
2818/// 0.8.11 Slice 40 (#17) — a single closed `FilterTerm` of the **unified**
2819/// filter grammar (ADR-0.8.11-filter-grammar-unification, Option A; closes
2820/// reserved-gap 37). Exactly **five** variants: the four G10 shorthand metadata
2821/// fields (`SourceType`/`Kind`/`CreatedAfter`/`Status`) plus the general G4
2822/// json-path [`Predicate`] (`Json`). The shorthand fields are dedicated typed
2823/// variants — NOT `Json(Predicate)` over `$.source_type` etc. — precisely so the
2824/// vec0 search backend can lower them to the *indexed* pre-KNN metadata columns
2825/// while typed-rejecting an arbitrary `Json` term (D3: no demotion to post-KNN
2826/// `json_extract`).
2827///
2828/// The grammar stays **closed** (inherits ADR-0.8.0 D-F1/D-F2/D-F4/D-F5): no
2829/// DSL, no caller SQL, no `JsonPathFused*`, no `*_unchecked`, no OR/nesting
2830/// (implicit AND only); `Json` terms are built ONLY via the validated
2831/// [`Predicate::json_path_eq`] / [`Predicate::json_path_compare`] constructors
2832/// (path allowlist enforced at construction). The shipped `ScalarValue` /
2833/// `ComparisonOp` / `Predicate` vocabulary is reused verbatim — no new grammar.
2834#[derive(Clone, Debug, PartialEq)]
2835pub enum FilterTerm {
2836 /// vec0 partition-key metadata column `source_type` (pre-KNN). On
2837 /// `read.list` it **constant-folds** against `resolve_source_type(kind)`
2838 /// (the column does not exist in `canonical_nodes`).
2839 SourceType(String),
2840 /// `kind` — the vec0 metadata column (pre-KNN). On `read.list` it
2841 /// constant-folds against the partition `kind` argument (D1 impl decision:
2842 /// constant-fold, the simpler total option vs a redundant column clause).
2843 Kind(String),
2844 /// `created_at >= bound` (unix seconds). vec0 metadata column (pre-KNN);
2845 /// lowers to `json_extract(body,'$.created_at') >= ?` on `read.list`.
2846 CreatedAfter(i64),
2847 /// vec0 metadata column `status` (pre-KNN); lowers to
2848 /// `json_extract(body,'$.status') = ?` on `read.list`.
2849 Status(String),
2850 /// The general G4 json-path predicate (unchanged shipped grammar). Resolves
2851 /// **only** on the `read.list` (canonical_nodes) backend; **typed-rejected**
2852 /// on `search_filtered` because it would require a post-KNN `json_extract`
2853 /// that defeats the indexed pre-KNN filter (D3 no-demotion guarantee).
2854 Json(Predicate),
2855}
2856
2857/// 0.8.11 Slice 40 (#17) — the unified closed `Filter` contract. ONE superset
2858/// type with implicit-AND [`FilterTerm`]s, dispatched to one of **two** internal
2859/// compilation backends (Option A — the TYPE unifies, the COMPILATION
2860/// dispatches): the vec0-metadata indexed pre-KNN `WHERE` for `search_filtered`,
2861/// and `json_extract` over `canonical_nodes.body` for `read.list`. The shipped
2862/// `SearchFilter` (G10) and `Predicate` lists (G4) re-express as sugar that
2863/// lowers into this type (D4); the `filter=None` byte-identical-0.7.2-SQL pin is
2864/// preserved because the vec0 lowering routes back through the shipped
2865/// `vector_filter_clause` compilation verbatim.
2866#[derive(Clone, Debug, Default, PartialEq)]
2867pub struct Filter {
2868 /// AND-combined terms (implicit AND, inherits D-F5). Empty = unfiltered.
2869 pub terms: Vec<FilterTerm>,
2870}
2871
2872impl TryFrom<&SearchFilter> for Filter {
2873 type Error = EngineError;
2874
2875 /// D4 sugar lowering — the shipped G10 [`SearchFilter`] re-expressed as the
2876 /// unified [`Filter`]. Attribute equality is intentionally not part of the
2877 /// unified grammar, so it is refused rather than silently discarded. Field
2878 /// → term uses canonical order (`source_type`, `kind`, `created_after`,
2879 /// `status`) so an attribute-free round-trip stays byte-identical.
2880 fn try_from(sf: &SearchFilter) -> Result<Self, Self::Error> {
2881 if !sf.attributes.is_empty() {
2882 return Err(EngineError::InvalidFilter {
2883 reason:
2884 "projected attribute predicates are not supported by the unified Filter grammar"
2885 .to_string(),
2886 });
2887 }
2888 let mut terms = Vec::new();
2889 if let Some(s) = &sf.source_type {
2890 terms.push(FilterTerm::SourceType(s.clone()));
2891 }
2892 if let Some(k) = &sf.kind {
2893 terms.push(FilterTerm::Kind(k.clone()));
2894 }
2895 if let Some(c) = sf.created_after {
2896 terms.push(FilterTerm::CreatedAfter(c));
2897 }
2898 if let Some(s) = &sf.status {
2899 terms.push(FilterTerm::Status(s.clone()));
2900 }
2901 Ok(Filter { terms })
2902 }
2903}
2904
2905impl Filter {
2906 /// Backend dispatch for `search_filtered` (vec0 — indexed pre-KNN). Lowers
2907 /// the metadata subset `{SourceType, Kind, CreatedAfter, Status}` back into a
2908 /// [`SearchFilter`] (which the shipped `vector_filter_clause` compiles to the
2909 /// pre-KNN `WHERE`), and **typed-rejects** a [`FilterTerm::Json`] term with
2910 /// [`EngineError::InvalidFilter`] — the explicit no-demotion guarantee (D3).
2911 /// Field-by-variant assignment makes the output canonical-order-independent
2912 /// of `terms` ordering (hand-built router filters included). A later
2913 /// duplicate metadata term overwrites the earlier (last-wins).
2914 pub fn to_search_filter(&self) -> Result<SearchFilter, EngineError> {
2915 let mut sf = SearchFilter::default();
2916 for term in &self.terms {
2917 match term {
2918 FilterTerm::SourceType(s) => sf.source_type = Some(s.clone()),
2919 FilterTerm::Kind(k) => sf.kind = Some(k.clone()),
2920 FilterTerm::CreatedAfter(c) => sf.created_after = Some(*c),
2921 FilterTerm::Status(s) => sf.status = Some(s.clone()),
2922 FilterTerm::Json(_) => {
2923 return Err(EngineError::InvalidFilter {
2924 reason: "arbitrary json-path predicate not supported on search_filtered; \
2925 it would require a post-KNN json_extract that defeats the \
2926 indexed pre-KNN filter (ADR-0.8.11 D3 no-demotion guarantee)"
2927 .to_string(),
2928 });
2929 }
2930 }
2931 }
2932 Ok(sf)
2933 }
2934
2935 /// Backend dispatch for `read.list` (canonical_nodes — `json_extract`). The
2936 /// full set resolves here. Returns:
2937 /// - `Ok(Some(preds))` — the implicit-AND [`Predicate`] list to run; or
2938 /// - `Ok(None)` — a constant-folded **guaranteed-empty** result (a `Kind` or
2939 /// `SourceType` term that cannot match this partition), so the caller
2940 /// returns an empty `Vec` without touching SQL; or
2941 /// - `Err(InvalidFilter)` — a non-allowlisted path (defense-in-depth; the
2942 /// shorthand lowerings only ever use allowlisted paths).
2943 ///
2944 /// Lowering (D3): `Json(p)` → `p`; `Status(s)` →
2945 /// `json_path_eq("$.status", Text(s))`; `CreatedAfter(b)` →
2946 /// `json_path_compare("$.created_at", Gte, Integer(b))`; `Kind(k)` →
2947 /// constant-fold vs the partition `kind` arg (no-op if equal, empty if not);
2948 /// `SourceType(s)` → constant-fold vs `resolve_source_type(kind)` (no-op if
2949 /// equal, empty otherwise — the column does not exist in `body`).
2950 fn lower_for_read_list(&self, kind: &str) -> Result<Option<Vec<Predicate>>, EngineError> {
2951 let mut preds = Vec::new();
2952 for term in &self.terms {
2953 match term {
2954 FilterTerm::Json(p) => preds.push(p.clone()),
2955 FilterTerm::Status(s) => {
2956 preds.push(Predicate::json_path_eq("$.status", ScalarValue::Text(s.clone()))?);
2957 }
2958 FilterTerm::CreatedAfter(b) => {
2959 preds.push(Predicate::json_path_compare(
2960 "$.created_at",
2961 ComparisonOp::Gte,
2962 ScalarValue::Integer(*b),
2963 )?);
2964 }
2965 FilterTerm::Kind(k) => {
2966 // Constant-fold vs the partition argument (D1 impl decision).
2967 if k != kind {
2968 return Ok(None);
2969 }
2970 }
2971 FilterTerm::SourceType(s) => {
2972 // source_type is NOT a canonical_nodes column; it is a pure
2973 // function of `kind`. Constant-fold (D2/D3).
2974 match resolve_source_type(kind) {
2975 Ok(resolved) if resolved == s.as_str() => {}
2976 _ => return Ok(None),
2977 }
2978 }
2979 }
2980 }
2981 Ok(Some(preds))
2982 }
2983
2984 /// 0.8.11 Slice 40 — test seam: expose the vec0 backend dispatch so the
2985 /// unification suite can pin the typed-rejection (RED→GREEN) and that a
2986 /// metadata-only Filter lowers losslessly. Returns the lowered
2987 /// [`SearchFilter`] (or `InvalidFilter` for a `Json` term).
2988 #[doc(hidden)]
2989 pub fn to_search_filter_for_test(&self) -> Result<SearchFilter, EngineError> {
2990 self.to_search_filter()
2991 }
2992
2993 /// 0.8.11 Slice 40 — test seam: expose the `read.list` backend lowering so
2994 /// the unification suite can pin total dispatch incl. the `SourceType`/`Kind`
2995 /// constant-folds. `Ok(None)` == constant-folded-empty.
2996 #[doc(hidden)]
2997 pub fn lower_for_read_list_for_test(
2998 &self,
2999 kind: &str,
3000 ) -> Result<Option<Vec<Predicate>>, EngineError> {
3001 self.lower_for_read_list(kind)
3002 }
3003}
3004
3005/// G11 (Slice 15) — a document sent to a BYO-LLM extraction harness via
3006/// [`Engine::ingest_with_extractor`].
3007#[derive(Clone, Debug)]
3008pub struct ExtractDocument {
3009 /// Stable opaque identifier for this document. Used as `source_id` on
3010 /// ingested edges and for provenance tracking.
3011 pub source_doc_id: String,
3012 /// Full text body of the document to extract entities and relationships from.
3013 pub body: String,
3014}
3015
3016/// G11 (Slice 15) — receipt returned by [`Engine::ingest_with_extractor`].
3017#[derive(Clone, Debug, Default)]
3018pub struct IngestWithExtractorReceipt {
3019 /// Number of `canonical_nodes` rows written (new entity insertions; skipped
3020 /// for entities that already have a matching active logical_id).
3021 pub nodes_written: u64,
3022 /// Number of `canonical_edges` rows written (new fact-edge insertions;
3023 /// superseded prior edges are ALSO counted as rows written).
3024 pub edges_written: u64,
3025 /// Number of documents processed (including no-facts documents).
3026 pub docs_processed: u64,
3027}
3028
3029/// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — one (subject-entity, relation) axis to
3030/// consolidate via [`Engine::consolidate_with_provider`]. FathomDB assembles the
3031/// competing fact-edge cluster for this axis DETERMINISTICALLY (CPU-only, no
3032/// LLM) by querying active `canonical_edges` where `from_id = subject_logical_id`
3033/// AND `kind = relation`.
3034#[derive(Clone, Debug)]
3035pub struct ConsolidateAxis {
3036 /// Stable `logical_id` of the subject entity (edge `from_id`).
3037 pub subject_logical_id: String,
3038 /// The relation/edge `kind` whose competing fact-edges form the cluster.
3039 pub relation: String,
3040}
3041
3042/// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — one competing fact-edge in a candidate
3043/// cluster sent to the consolidation harness. Assembled deterministically from
3044/// `canonical_edges`; sent to the harness as the request payload; the harness's
3045/// verdict references edges back by `edge_ref` (the edge's stable `logical_id`).
3046#[derive(Clone, Debug)]
3047pub struct ConsolidateCandidateEdge {
3048 /// The edge's stable `logical_id` — the ref the harness uses in its verdict.
3049 pub edge_ref: String,
3050 /// The fact/relationship text (never rewritten by consolidation — §2.1).
3051 pub body: Option<String>,
3052 /// Event valid-time as INTEGER epoch seconds (UTC), if known.
3053 ///
3054 /// TC-33: epoch seconds, NOT ISO-8601. ISO-8601 lives only on the BYO-LLM
3055 /// extractor wire; `normalize_extractor_timestamp` is the one boundary.
3056 pub t_valid: Option<i64>,
3057 /// Event invalid-time as INTEGER epoch seconds (UTC), if already
3058 /// invalidated. `None` = still valid.
3059 pub t_invalid: Option<i64>,
3060 /// Extraction confidence ∈ [0.0, 1.0], if known.
3061 pub confidence: Option<f64>,
3062 /// Provenance: originating document id.
3063 pub source_doc_id: Option<String>,
3064 /// Provenance: extractor model id from the original BYO-LLM ingest.
3065 pub extractor_model_id: Option<String>,
3066}
3067
3068/// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — receipt returned by
3069/// [`Engine::consolidate_with_provider`]. Consolidation records supersession /
3070/// recency METADATA only (§2.1): edge bodies are never rewritten and no row is
3071/// ever deleted, so these counts describe metadata transitions, not content
3072/// changes.
3073#[derive(Clone, Debug, Default)]
3074pub struct ConsolidateReceipt {
3075 /// Number of (subject, relation) axes with a non-empty cluster that were
3076 /// dispatched to the harness.
3077 pub clusters_processed: u64,
3078 /// Number of candidate edges presented across all clusters.
3079 pub edges_examined: u64,
3080 /// Number of edges the harness ruled `keep` (no metadata change).
3081 pub edges_kept: u64,
3082 /// Number of edges the harness ruled `invalidate` (t_invalid set; row + body
3083 /// preserved).
3084 pub edges_invalidated: u64,
3085 /// Number of edges the harness ruled `supersede`/`merge` (marked superseded
3086 /// via the existing G0 tombstone column; row + body preserved).
3087 pub edges_superseded: u64,
3088}
3089
3090/// 0.8.20 Slice 5c (R-20-E3) — the provenance of a canonical row: which source
3091/// document it is attributable to, and therefore what `excise_source` must erase
3092/// when that source is withdrawn.
3093///
3094/// **Why a newtype and not `Option<String>`.** Erasure runs through provenance:
3095/// a row whose `source_id` is NULL is reachable by NO `excise_source` call and
3096/// is therefore **un-erasable**. Before 0.8.20 the public `PreparedWrite`
3097/// carried `source_id: Option<String>`, so a caller could express "no
3098/// provenance" and silently create such a row. A *runtime* rejection would not
3099/// have closed this: the facade crate re-exports `PreparedWrite` and
3100/// `Engine::write` is `pub`, so a caller can build the value directly and skip
3101/// any validation the engine performs. Replacing the field's type is what makes
3102/// the absence of provenance **inexpressible** rather than merely rejected —
3103/// the guarantee is enforced by `rustc`, not by a branch. `tests/ui/` in the
3104/// facade crate holds the compile-fail witness.
3105///
3106/// **This is a BREAKING change**, shipped ON by default as part of the 0.8.20
3107/// coordinated breaking-pair release. There is deliberately no compatibility
3108/// shim and no deprecation window: a shim would re-open the hole it closes.
3109///
3110/// **Reserved namespace.** Ids beginning with `_` belong to the engine and are
3111/// rejected by [`SourceId::new`]. Two are currently minted internally:
3112///
3113/// * [`SourceId::ENGINE_PREFIX`] (`_engine:`) — rows the engine derives for
3114/// itself (EXP-S coverage/graph substrate rows), which never pass through
3115/// `PreparedWrite` (design §4 item 6).
3116/// * [`SourceId::LEGACY_PRE_0_8_20`] (`_legacy:pre-0.8.20`) — stamped by schema
3117/// migration step 21 onto pre-0.8.20 rows that were stored with NULL
3118/// provenance, so they become erasable (R-20-E8). **Gated to UNGOVERNED rows
3119/// only** (`logical_id IS NULL`); a governed row keeps NULL `source_id` and
3120/// stays `purge`-addressable by its `logical_id` (TC-11 pin).
3121///
3122/// **`source_id` must not be PII.** It survives the erasure it authorises: the
3123/// `excise_source` audit row in `operational_mutations` records it verbatim, and
3124/// while 0.8.20 makes that audit row durable (design §2 defect D-A) the rule was
3125/// always that the handle you erase BY must not itself be the thing needing
3126/// erasure. Use an opaque document id, not an email address.
3127#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
3128pub struct SourceId(String);
3129
3130impl SourceId {
3131 /// Reserved prefix for engine-derived rows (design §4 item 6).
3132 pub const ENGINE_PREFIX: &'static str = "_engine:";
3133
3134 /// Reserved provenance stamped by schema migration step 21 onto pre-0.8.20
3135 /// UNGOVERNED rows that were stored with NULL provenance (R-20-E8).
3136 pub const LEGACY_PRE_0_8_20: &'static str = "_legacy:pre-0.8.20";
3137
3138 /// The single public constructor. Rejects the two ways a caller could
3139 /// express "effectively no provenance":
3140 ///
3141 /// * an empty or whitespace-only id — it names no source, and
3142 /// `excise_source` already refuses the empty string, so such a row would
3143 /// be un-erasable in practice;
3144 /// * an id in the engine's reserved `_`-prefixed namespace — a caller who
3145 /// could mint `_legacy:pre-0.8.20` could hide rows among the migration's
3146 /// back-filled ones, or mint `_engine:` rows that read as engine
3147 /// substrate.
3148 ///
3149 /// # Errors
3150 ///
3151 /// [`EngineError::WriteValidation`] for either rejection above.
3152 pub fn new(id: impl Into<String>) -> Result<Self, EngineError> {
3153 let id = id.into();
3154 if id.trim().is_empty() || id.starts_with('_') {
3155 return Err(EngineError::WriteValidation);
3156 }
3157 Ok(Self(id))
3158 }
3159
3160 /// Mint a reserved `_engine:*` provenance for an engine-derived row. Crate
3161 /// -internal by construction: the reserved namespace is exactly what
3162 /// [`SourceId::new`] refuses, so a caller cannot reach this spelling.
3163 pub(crate) fn engine_derived(role: &str) -> Self {
3164 Self(format!("{}{role}", Self::ENGINE_PREFIX))
3165 }
3166
3167 /// The on-disk `source_id` text.
3168 #[must_use]
3169 pub fn as_str(&self) -> &str {
3170 &self.0
3171 }
3172
3173 /// Consume into the owned on-disk text.
3174 #[must_use]
3175 pub fn into_string(self) -> String {
3176 self.0
3177 }
3178}
3179
3180impl AsRef<str> for SourceId {
3181 fn as_ref(&self) -> &str {
3182 &self.0
3183 }
3184}
3185
3186impl Display for SourceId {
3187 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
3188 f.write_str(&self.0)
3189 }
3190}
3191
3192impl TryFrom<String> for SourceId {
3193 type Error = EngineError;
3194
3195 fn try_from(value: String) -> Result<Self, Self::Error> {
3196 Self::new(value)
3197 }
3198}
3199
3200impl TryFrom<&str> for SourceId {
3201 type Error = EngineError;
3202
3203 fn try_from(value: &str) -> Result<Self, Self::Error> {
3204 Self::new(value)
3205 }
3206}
3207
3208/// Batch input shape for [`Engine::write`].
3209///
3210/// Marked `#[non_exhaustive]` per ADR-0.6.0-prepared-write-shape; new
3211/// entity variants land in 0.6.x without a major bump. Adding fields to
3212/// existing variants remains a binding-coordination change.
3213#[non_exhaustive]
3214#[derive(Clone, Debug, PartialEq)]
3215pub enum PreparedWrite {
3216 Node {
3217 kind: String,
3218 body: String,
3219 /// REQ-026 / AC-028 / AC-042 recovery seam, made **structurally
3220 /// mandatory** in 0.8.20 (R-20-E3). Was `Option<String>`; a `None`
3221 /// landed NULL on disk and produced a row no `excise_source` call could
3222 /// reach. See [`SourceId`] for why the fix is a type change rather than
3223 /// a validation check.
3224 source_id: SourceId,
3225 /// G0 (Slice 15) — stable cross-re-ingestion identity. `Some(id)`
3226 /// makes this write a transaction-time supersession of the prior
3227 /// active version of `(logical_id, kind)` (tombstone-then-insert).
3228 /// `None` is the legacy/own-identity default: a plain insert with a
3229 /// NULL `logical_id` (NULL-safe — never collides with other NULLs).
3230 logical_id: Option<String>,
3231 /// OPP-12 Phase-1 (0.8.19 Slice 5) — the create-time existence state.
3232 /// `InitialState::Active` (the [`Default`]) is the back-compat default and
3233 /// lands `state = 'active'` on disk (value-identical to the migration
3234 /// step-20 column DEFAULT). `InitialState::Pending` creates a quarantined
3235 /// node excluded from default retrieval. A `deleted`/`purged` node is
3236 /// UNREPRESENTABLE at create time (the [`InitialState`] type is the typed
3237 /// rejection) — those states are reachable only via the Slice-10
3238 /// `transition`/`purge` verbs.
3239 state: InitialState,
3240 /// OPP-12 Phase-1 (0.8.19 Slice 5) — advisory cause for the create-time
3241 /// `state` (e.g. the quarantine cause for a `pending` node), stored
3242 /// verbatim in `canonical_nodes.reason`. Engine never interprets it. `None`
3243 /// lands NULL (the back-compat default).
3244 reason: Option<String>,
3245 /// 0.8.20 Slice 15b (TC-34) — world-time validity window, INCLUSIVE lower
3246 /// bound, INTEGER epoch SECONDS UTC. `None` lands NULL = unbounded below.
3247 ///
3248 /// Slice 10b added the `valid_from`/`valid_until` columns, the [`ReadView`]
3249 /// validity predicate and [`Engine::crossed_boundary_since`] but NO writer,
3250 /// so a window could only be authored with raw SQL. These two fields are
3251 /// that writer. They are deliberately FIELDS rather than a new verb,
3252 /// exactly as [`PreparedWrite::Edge`] already carries `t_valid`/`t_invalid`:
3253 /// the governed command surface is unchanged.
3254 ///
3255 /// The pair is validated together — see `valid_until`.
3256 valid_from: Option<i64>,
3257 /// 0.8.20 Slice 15b (TC-34) — world-time validity window, EXCLUSIVE upper
3258 /// bound, INTEGER epoch SECONDS UTC. `None` lands NULL = unbounded above.
3259 ///
3260 /// The window is half-open `[valid_from, valid_until)`, matching the read
3261 /// predicate in `ReadView::validity_sql` exactly. Because it is half-open,
3262 /// a pair with `valid_from >= valid_until` describes an EMPTY window that no
3263 /// instant can ever satisfy — so [`Engine::write`] refuses it with
3264 /// [`EngineError::WriteValidation`] rather than storing a row that no
3265 /// default read could ever return. A ONE-SIDED window is never empty and is
3266 /// never refused, however extreme its single bound.
3267 ///
3268 /// **BREAKING (0.8.20 Slice 22, decision #18).** This refusal used to be
3269 /// [`EngineError::InvalidArgument`] NAMING both bounds. It is now the
3270 /// message-less `WriteValidation` unit variant — the one family the
3271 /// taxonomy of record assigns to a malformed submitted write SHAPE — so
3272 /// **the offending bounds are no longer carried in the error**. A caller
3273 /// that parsed them out must validate the pair before calling.
3274 valid_until: Option<i64>,
3275 },
3276 Edge {
3277 kind: String,
3278 from: String,
3279 to: String,
3280 /// REQ-026 / AC-028 / AC-042 recovery seam — see Node. Structurally
3281 /// mandatory since 0.8.20 (R-20-E3).
3282 source_id: SourceId,
3283 /// G0 (Slice 15) — see Node. Supersession semantics are identical on
3284 /// edges (keyed by `(logical_id, kind)`).
3285 logical_id: Option<String>,
3286 /// G11 (Slice 15) — the fact/relationship text. When `Some`, triggers
3287 /// FTS projection into `search_index_edges` and vector projection via
3288 /// the projection scheduler (kind `"edge_fact"`). Also triggers
3289 /// invalidate-not-accumulate on `(from_id, to_id, kind)`.
3290 body: Option<String>,
3291 /// G11 (Slice 15) — event valid-time. NULL = unknown / still valid.
3292 ///
3293 /// **TC-33 (HITL-RATIFIED 2026-07-21): INTEGER epoch seconds (UTC), not
3294 /// ISO-8601.** This is the GOVERNED SDK WRITE SURFACE, which carries the
3295 /// same representation as storage. ISO-8601 survives ONLY on the BYO-LLM
3296 /// extractor wire (`fathomdb.extract.v1`), where
3297 /// `normalize_extractor_timestamp` converts it with hard rejection.
3298 t_valid: Option<i64>,
3299 /// G11 (Slice 15) — event invalid-time. NULL = still valid.
3300 ///
3301 /// **TC-33: INTEGER epoch seconds (UTC)** — see `t_valid`. The
3302 /// NULL-means-still-valid semantic is load-bearing and unchanged, which
3303 /// is why the schema pins the type with a `typeof` CHECK rather than
3304 /// `NOT NULL`.
3305 t_invalid: Option<i64>,
3306 /// G11 (Slice 15) — extraction confidence ∈ [0.0, 1.0]. NULL for
3307 /// non-BYO-LLM-ingested edges.
3308 confidence: Option<f64>,
3309 /// G11 (Slice 15) — opaque model/provider id from the BYO-LLM harness
3310 /// `ready.model` field. NULL for non-BYO-LLM edges.
3311 extractor_model_id: Option<String>,
3312 /// R3 (Slice 30, SCHEMA-GATE-1, HITL-SIGNED 2026-06-13) — set when the
3313 /// ELPS extractor defaulted this edge's `t_valid` to `created_at` rather
3314 /// than deriving it from the document text. Such edges have untrustworthy
3315 /// event times and are excluded from graph-arm BFS temporal queries.
3316 /// `None`/`false` = not a fallback; `Some(true)` = fallback.
3317 temporal_fallback: Option<bool>,
3318 },
3319 OpStore {
3320 collection: String,
3321 record_key: String,
3322 schema_id: Option<String>,
3323 body: String,
3324 },
3325 AdminSchema {
3326 name: String,
3327 kind: String,
3328 schema_json: String,
3329 retention_json: String,
3330 },
3331}
3332
3333/// EXP-S (0.8.14 Slice 5, D1) — structural-role tag for a canonical row.
3334///
3335/// A SEPARATE axis from the doc-type `kind` (email/article/paper/meeting/
3336/// note/todo/doc/edge_fact): `row_kind` describes *what structural role* a row
3337/// plays in the "one store, many indexes" substrate, not what document type it
3338/// carries. Stored in `canonical_nodes.row_kind` (schema migration step 16).
3339///
3340/// `Leaf` is the default (a normal record; every existing/normal write is a
3341/// leaf — back-compat preserving). `Coverage` = coverage/summary rows;
3342/// `Graph` = graph structural rows. Engine-internal in 0.8.14 — there is NO
3343/// public Py/TS SDK surface for `row_kind` this release (`Leaf` for all normal
3344/// writes; `Coverage`/`Graph` are set only by internal paths). Cross-binding
3345/// parity (X1) is a Slice-40 concern.
3346#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3347pub enum RowKind {
3348 Leaf,
3349 Coverage,
3350 Graph,
3351}
3352
3353impl RowKind {
3354 /// On-disk `canonical_nodes.row_kind` spelling. Must match the migration
3355 /// step-16 `DEFAULT 'leaf'` and the schema vocabulary (D1).
3356 #[must_use]
3357 pub fn as_str(self) -> &'static str {
3358 match self {
3359 RowKind::Leaf => "leaf",
3360 RowKind::Coverage => "coverage",
3361 RowKind::Graph => "graph",
3362 }
3363 }
3364}
3365
3366/// OPP-12 record-lifecycle Phase-1 (0.8.19 Slice 5) — the existence axis.
3367///
3368/// One mutually-exclusive typed enum stored as TEXT in the `canonical_nodes.state`
3369/// column (schema migration step-20). Semantics (design §2 / plan §1):
3370/// `Pending` = present + versioned but NOT admitted to default retrieval
3371/// (quarantine / promotion gate);
3372/// `Active` = admitted to default retrieval (the shipped-corpus default);
3373/// `Deleted` = soft-deleted, retained + recoverable, excluded from default
3374/// reads, stays indexed behind the flag;
3375/// `Purged` = terminal, physically erased.
3376/// `Deleted`/`Purged` are reachable only through the Phase-2/Slice-10
3377/// `transition`/`purge` verbs — they can NEVER be a create-time state (see
3378/// [`InitialState`]).
3379#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3380pub enum LifecycleState {
3381 Pending,
3382 Active,
3383 Deleted,
3384 Purged,
3385}
3386
3387impl LifecycleState {
3388 /// On-disk `canonical_nodes.state` spelling. Must match the migration step-20
3389 /// `DEFAULT 'active'` and the `state = 'active'` default-read exclusion.
3390 #[must_use]
3391 pub fn as_str(self) -> &'static str {
3392 match self {
3393 LifecycleState::Pending => "pending",
3394 LifecycleState::Active => "active",
3395 LifecycleState::Deleted => "deleted",
3396 LifecycleState::Purged => "purged",
3397 }
3398 }
3399
3400 /// Parse the on-disk spelling back into the typed enum. `None` for any value
3401 /// outside the closed vocabulary (a corrupt/foreign `state`).
3402 #[must_use]
3403 pub fn from_str_opt(value: &str) -> Option<Self> {
3404 match value {
3405 "pending" => Some(LifecycleState::Pending),
3406 "active" => Some(LifecycleState::Active),
3407 "deleted" => Some(LifecycleState::Deleted),
3408 "purged" => Some(LifecycleState::Purged),
3409 _ => None,
3410 }
3411 }
3412
3413 /// OPP-12 Phase-1 (0.8.19 Slice 10) — the target states legally reachable from
3414 /// `self` via the `transition` VERB (design §2 legal-transition table). This
3415 /// is the verb-specific enumeration reported by `IllegalTransitionError.legal`:
3416 /// `Pending` → `[Active, Deleted]` (promote / reject)
3417 /// `Active` → `[Deleted]` (soft-delete)
3418 /// `Deleted` → `[Active]` (undelete)
3419 /// `Purged` → `[]` (terminal; nothing is reachable)
3420 /// `Purged` is DELIBERATELY excluded even from `Deleted`: reaching `purged` is
3421 /// the `purge` verb's job (see [`Engine::purge`]), NOT a legal `transition`
3422 /// target, so reporting it here would mislead a caller into thinking
3423 /// `transition(deleted → purged)` is legal when it is not. Likewise `Pending`
3424 /// is create-time-only and is never a `transition` target. Derived directly
3425 /// from [`is_legal_transition_move`] so this can never drift from the table.
3426 #[must_use]
3427 pub fn legal_next_states(self) -> Vec<LifecycleState> {
3428 [
3429 LifecycleState::Pending,
3430 LifecycleState::Active,
3431 LifecycleState::Deleted,
3432 LifecycleState::Purged,
3433 ]
3434 .into_iter()
3435 .filter(|&to| is_legal_transition_move(self, to))
3436 .collect()
3437 }
3438}
3439
3440/// OPP-12 Phase-1 (0.8.19 Slice 10) — whether `(from, to)` is one of the four
3441/// legal `transition`-verb moves (design §2 table): `pending→active` (promote),
3442/// `pending→deleted` (reject), `active→deleted` (soft-delete), `deleted→active`
3443/// (undelete). Every other pair — self-loops, any move to `Purged` (purge-only)
3444/// or `Pending` (create-only), or from `Purged` — is illegal via `transition`.
3445#[must_use]
3446fn is_legal_transition_move(from: LifecycleState, to: LifecycleState) -> bool {
3447 matches!(
3448 (from, to),
3449 (LifecycleState::Pending, LifecycleState::Active)
3450 | (LifecycleState::Pending, LifecycleState::Deleted)
3451 | (LifecycleState::Active, LifecycleState::Deleted)
3452 | (LifecycleState::Deleted, LifecycleState::Active)
3453 )
3454}
3455
3456/// OPP-12 Phase-1 (0.8.19 Slice 5) — the CREATE-TIME subset of [`LifecycleState`].
3457///
3458/// A write can only bring a node into existence as `Pending` or `Active` (design
3459/// §2 / gap-6). You CANNOT create a `Deleted`/`Purged` node — those states are
3460/// reachable only via the `transition`/`purge` verbs (Slice 10). Making the
3461/// create-time surface a separate two-variant type is the TYPED rejection: a
3462/// `deleted`/`purged` create is simply unrepresentable in the Rust API (the SDK
3463/// bindings map an out-of-subset string to a typed write-validation error).
3464#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
3465pub enum InitialState {
3466 Pending,
3467 /// The back-compat default: every pre-lifecycle write lands `Active`, matching
3468 /// the migration step-20 `DEFAULT 'active'`.
3469 #[default]
3470 Active,
3471}
3472
3473impl InitialState {
3474 /// On-disk `canonical_nodes.state` spelling for a create-time state.
3475 #[must_use]
3476 pub fn as_str(self) -> &'static str {
3477 match self {
3478 InitialState::Pending => "pending",
3479 InitialState::Active => "active",
3480 }
3481 }
3482
3483 /// The full [`LifecycleState`] this create-time state corresponds to.
3484 #[must_use]
3485 pub fn to_lifecycle_state(self) -> LifecycleState {
3486 match self {
3487 InitialState::Pending => LifecycleState::Pending,
3488 InitialState::Active => LifecycleState::Active,
3489 }
3490 }
3491
3492 /// Parse a caller-supplied create-time `state` string into the create-time
3493 /// subset. `Some(state)` for `"pending"`/`"active"`; `None` for `"deleted"`,
3494 /// `"purged"`, or any unknown value — the SDK bindings turn `None` into a
3495 /// typed write-validation rejection (you cannot CREATE a deleted/purged node).
3496 #[must_use]
3497 pub fn from_create_str(value: &str) -> Option<Self> {
3498 match value {
3499 "pending" => Some(InitialState::Pending),
3500 "active" => Some(InitialState::Active),
3501 _ => None,
3502 }
3503 }
3504}
3505
3506/// F5 (0.8.14 Slice 10) — per-field BM25F weights for the `search_index_v2`
3507/// multi-column FTS index. One weight per indexed field
3508/// (`kind`/`body`/`status`), applied as the field's contribution multiplier in
3509/// the BM25F weighted-term-frequency accumulation.
3510///
3511/// The default is uniform (`1.0` each) — the "unweighted" baseline the R-F5-1
3512/// acceptance test contrasts against. Boosting a field (e.g. `kind`) makes a
3513/// match in that field outrank a same-strength match in a lower-weighted field.
3514/// Engine-internal for 0.8.14: there is NO public Py/TS SDK surface for these
3515/// tunables this release (cross-binding parity is a Slice-40/X1 concern).
3516#[derive(Clone, Copy, Debug, PartialEq)]
3517pub struct Bm25fFieldWeights {
3518 pub kind: f64,
3519 pub body: f64,
3520 pub status: f64,
3521}
3522
3523impl Default for Bm25fFieldWeights {
3524 fn default() -> Self {
3525 Self { kind: 1.0, body: 1.0, status: 1.0 }
3526 }
3527}
3528
3529/// F5 (0.8.14 Slice 10) — the compiled BM25F query plan for the fielded lexical
3530/// arm (`ADR-0.8.1` §3.2 `BM25fQueryPlan`). Carries the tunable per-field
3531/// `weights` and the tunable length-normalization `b` (and the term-saturation
3532/// `k1`).
3533///
3534/// NOTE on `b`: SQLite FTS5's built-in `bm25()` auxiliary function pins its
3535/// internal `k1`/`b` and exposes ONLY per-column weights — it cannot express a
3536/// tunable `b`. So the score is computed in-engine (a textbook BM25F over the
3537/// FTS5-recalled candidates) rather than delegated to the built-in `bm25()`:
3538/// that is what makes `b` (and `k1`) genuinely tunable here, not a dead
3539/// parameter. The `search_index_v2` FTS5 index is still load-bearing — it does
3540/// the candidate recall (`MATCH`) that the scorer then ranks.
3541///
3542/// Defaults match Robertson/SQLite BM25 (`b = 0.75`, `k1 = 1.2`) with uniform
3543/// field weights. Engine-internal for 0.8.14 (no SDK surface).
3544#[derive(Clone, Copy, Debug, PartialEq)]
3545pub struct Bm25fQueryPlan {
3546 pub weights: Bm25fFieldWeights,
3547 pub b: f64,
3548 pub k1: f64,
3549}
3550
3551impl Default for Bm25fQueryPlan {
3552 fn default() -> Self {
3553 Self { weights: Bm25fFieldWeights::default(), b: 0.75, k1: 1.2 }
3554 }
3555}
3556
3557/// Snapshot of engine-internal counters returned by [`Engine::counters`].
3558///
3559/// Public key set is owned by `dev/design/lifecycle.md` § Public key set
3560/// and locked by AC-004a. Reading a snapshot is non-perturbing per
3561/// AC-004c. The 0.6.0 surface exposes exactly these seven fields.
3562#[derive(Clone, Debug, Default, Eq, PartialEq)]
3563pub struct CounterSnapshot {
3564 pub queries: u64,
3565 pub writes: u64,
3566 pub write_rows: u64,
3567 pub errors_by_code: BTreeMap<String, u64>,
3568 pub admin_ops: u64,
3569 pub cache_hit: u64,
3570 pub cache_miss: u64,
3571}
3572
3573pub use lifecycle::Subscription;
3574
3575/// Stable corruption-on-open detail carried by
3576/// [`EngineOpenError::Corruption`].
3577///
3578/// Layout owned by `dev/design/errors.md` § Corruption detail owner.
3579#[derive(Clone, Debug, Eq, PartialEq)]
3580pub struct CorruptionDetail {
3581 pub kind: CorruptionKind,
3582 pub stage: OpenStage,
3583 pub locator: CorruptionLocator,
3584 pub recovery_hint: RecoveryHint,
3585}
3586
3587/// Open-path corruption category.
3588///
3589/// 0.6.0 emits exactly the four members below; per
3590/// `dev/design/errors.md` § Engine.open corruption table, doctor-only
3591/// finding codes are not represented here.
3592#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3593pub enum CorruptionKind {
3594 WalReplayFailure,
3595 HeaderMalformed,
3596 SchemaInconsistent,
3597 EmbedderIdentityDrift,
3598}
3599
3600/// `Engine.open` stage at which corruption was detected.
3601///
3602/// Per ADR-0.6.0-corruption-open-behavior, `LockAcquisition` is intentionally
3603/// not a member here; lock contention is surfaced via
3604/// [`EngineOpenError::DatabaseLocked`].
3605#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3606pub enum OpenStage {
3607 WalReplay,
3608 HeaderProbe,
3609 SchemaProbe,
3610 EmbedderIdentity,
3611}
3612
3613/// Locator pointing at the corrupted region of the database file.
3614///
3615/// Variant set owned by `dev/design/errors.md` § CorruptionLocator
3616/// ownership. `OpaqueSqliteError` is the required fallback when SQLite
3617/// surfaces corruption without a usable structured locator.
3618#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3619pub enum CorruptionLocator {
3620 FileOffset { offset: u64 },
3621 PageId { page: u32 },
3622 TableRow { table: &'static str, rowid: i64 },
3623 Vec0ShadowRow { partition: &'static str, rowid: i64 },
3624 MigrationStep { from: u32, to: u32 },
3625 OpaqueSqliteError { sqlite_extended_code: i32 },
3626}
3627
3628/// Recovery dispatch surface attached to a corruption detail.
3629///
3630/// `code` is the stable dispatch key used by bindings and doctor output;
3631/// `doc_anchor` points at the documentation section that explains the
3632/// remediation path.
3633#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3634pub struct RecoveryHint {
3635 pub code: &'static str,
3636 pub doc_anchor: &'static str,
3637}
3638
3639#[derive(Clone, Debug, Eq, PartialEq)]
3640pub enum EngineOpenError {
3641 DatabaseLocked {
3642 holder_pid: Option<u32>,
3643 },
3644 Corruption(CorruptionDetail),
3645 IncompatibleSchemaVersion {
3646 seen: u32,
3647 supported: u32,
3648 },
3649 MigrationError {
3650 schema_version_before: u32,
3651 schema_version_current: u32,
3652 step_id: u32,
3653 },
3654 EmbedderIdentityMismatch {
3655 stored: EmbedderIdentity,
3656 supplied: EmbedderIdentity,
3657 },
3658 EmbedderDimensionMismatch {
3659 stored: u32,
3660 supplied: u32,
3661 },
3662 /// Embedder runtime returned a typed error during `Engine::open`.
3663 Embedder(RuntimeEmbedderError),
3664 Io {
3665 message: String,
3666 },
3667}
3668
3669/// Caller-facing selector for the embedder used by an opened engine
3670/// (`dev/design/embedder.md` §0).
3671#[derive(Clone)]
3672pub enum EmbedderChoice {
3673 /// Use the engine's default embedder. With the `default-embedder`
3674 /// Cargo feature enabled, this materializes a `CandleBgeEmbedder`
3675 /// via the EU-3 loader at `Engine::open`; on first use the loader
3676 /// downloads pinned bge-small-en-v1.5 weights from HuggingFace per
3677 /// `ADR-0.7.1-default-embedder-weight-fetch`. Without the feature,
3678 /// this returns `EmbedderError::Failed` directing the caller to
3679 /// rebuild with `--features default-embedder` or supply
3680 /// `EmbedderChoice::Caller`.
3681 Default,
3682 /// Caller supplies the embedder instance. The supplied embedder's
3683 /// `identity()` becomes the workspace's default-profile identity.
3684 Caller(Arc<dyn Embedder>),
3685 /// No embedder configured. Engine opens; subsequent vector writes
3686 /// fail with `EngineError::EmbedderNotConfigured`. Useful for
3687 /// read-only or canonical-only flows.
3688 None,
3689}
3690
3691impl Display for EngineOpenError {
3692 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
3693 match self {
3694 Self::DatabaseLocked { holder_pid } => match holder_pid {
3695 Some(pid) => write!(f, "database is locked by process {pid}"),
3696 None => write!(f, "database is locked by another engine instance"),
3697 },
3698 Self::Corruption(detail) => {
3699 write!(
3700 f,
3701 "engine corruption at {:?} stage: {}",
3702 detail.stage, detail.recovery_hint.code
3703 )
3704 }
3705 Self::IncompatibleSchemaVersion { seen, supported } => write!(
3706 f,
3707 "database schema version {seen} is incompatible with supported version {supported}"
3708 ),
3709 Self::MigrationError {
3710 schema_version_before,
3711 schema_version_current,
3712 step_id,
3713 } => write!(
3714 f,
3715 "schema migration failed at step {step_id}; schema version remained between {schema_version_before} and {schema_version_current}"
3716 ),
3717 Self::EmbedderIdentityMismatch { stored, supplied } => write!(
3718 f,
3719 "embedder identity mismatch: stored {}@{}, supplied {}@{}",
3720 stored.name, stored.revision, supplied.name, supplied.revision,
3721 ),
3722 Self::EmbedderDimensionMismatch { stored, supplied } => write!(
3723 f,
3724 "embedder vector dimension mismatch: stored {stored}, supplied {supplied}",
3725 ),
3726 Self::Embedder(err) => match err {
3727 RuntimeEmbedderError::Timeout => write!(f, "embedder timeout during open"),
3728 RuntimeEmbedderError::Failed { message } => {
3729 write!(f, "embedder failure during open: {message}")
3730 }
3731 },
3732 Self::Io { message } => write!(f, "database I/O error: {message}"),
3733 }
3734 }
3735}
3736
3737impl Error for EngineOpenError {}
3738
3739#[derive(Clone, Debug, Eq, PartialEq)]
3740pub enum EngineError {
3741 Storage,
3742 Projection,
3743 Vector,
3744 Embedder,
3745 EmbedderNotConfigured,
3746 KindNotVectorIndexed,
3747 EmbedderDimensionMismatch {
3748 expected: u32,
3749 actual: u32,
3750 },
3751 Scheduler,
3752 OpStore,
3753 WriteValidation,
3754 SchemaValidation,
3755 Overloaded,
3756 Closing,
3757 /// G11 (Slice 15) — BYO-LLM extractor subprocess error (protocol mismatch,
3758 /// spawn failure, or harness-returned error code).
3759 Extractor,
3760 /// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — BYO-LLM consolidation provider
3761 /// error (protocol mismatch, spawn/handshake failure, task not advertised in
3762 /// `supported_tasks`, or a malformed/out-of-cluster verdict). Rides the SAME
3763 /// `provider_session` transport as `Extractor`; this is the task-specific leaf.
3764 Consolidator,
3765 /// G4 (Slice 35) — filter predicate construction error: non-allowlisted
3766 /// path or invalid filter argument. NOT a panic — returned as a typed error
3767 /// from [`Predicate::json_path_eq`] / [`Predicate::json_path_compare`].
3768 InvalidFilter {
3769 reason: String,
3770 },
3771 /// Slice 20 (G5/G6) — an argument is out of the accepted range (e.g.
3772 /// `depth > 3` for graph traversal). The `msg` field carries a
3773 /// human-readable explanation; it is intentionally non-exhaustive so the
3774 /// binding layer can forward it as a `ValueError` / `TypeError`.
3775 InvalidArgument {
3776 msg: String,
3777 },
3778 /// 0.8.18 Slice 5 (#5 vector-equivalence probe KEYSTONE) — the open-time
3779 /// self-check re-embedded the 45 committed probes with the live backend and
3780 /// found a divergence beyond the frozen D4 floor (a Phase-1 mean-centered
3781 /// `embedding_bin` sign flip, OR a Phase-2 un-centered L2 distance over
3782 /// `VECTOR_EQUIVALENCE_L2_EPSILON`). `Engine::open` succeeded into a degraded
3783 /// state (`dense_disabled = true`); this query-time error is raised at the
3784 /// single choke point [`Engine::search_inner_with_stats`] BEFORE any embedding
3785 /// / vector SQL / graph seeding / CE rerank, refusing EVERY vector-dependent
3786 /// arm (`search`, `search_expand`, explain/rerank, graph-arm). The explicit
3787 /// text-only/FTS-only path ([`Engine::search_text_only`]) stays serviceable.
3788 /// Sibling of the open-time `EngineOpenError::EmbedderIdentityMismatch`; per
3789 /// ADR-0.8.18 codex R2 U1-1 the refusal surfaces as an `EngineError` (queries
3790 /// never surface `EngineOpenError`). `reason` carries a human-readable summary.
3791 VectorEquivalenceMismatch {
3792 reason: String,
3793 },
3794 /// OPP-12 Phase-1 (0.8.19 Slice 10) — a lifecycle `transition`/`purge` move
3795 /// that the engine-enforced legal-transition table (design §2) forbids.
3796 /// Raised for an illegal `transition` target (`purged`/`pending` are never
3797 /// `transition` targets; self-loops; a from→to pair not in the table) AND for
3798 /// a `purge` precondition failure (purge is legal only from `deleted`).
3799 /// `from_state`/`to_state` use the FULL, parity-safe field names (S7 — `from`
3800 /// is a Python reserved word); `legal` enumerates the target states reachable
3801 /// from `from_state` in the full state machine.
3802 IllegalTransition {
3803 from_state: LifecycleState,
3804 to_state: LifecycleState,
3805 legal: Vec<LifecycleState>,
3806 },
3807 /// OPP-12 Phase-1 (0.8.19 Slice 10) — a lifecycle verb (`transition`/`purge`)
3808 /// was addressed with a non-`Logical` id space (a `Content`/`h:` doc-seeded or
3809 /// `Passage`/`p:` synthetic id). Only the `Logical` (`l:`) space is
3810 /// lifecycle-addressable (design §3); this is a typed refusal, never a panic
3811 /// or a silent no-op. `id_space` carries the offending [`IdSpaceKind`].
3812 NotLifecycleAddressable {
3813 id_space: IdSpaceKind,
3814 },
3815 /// 0.8.20 Slice 5b (R-20-E5, design `0.8.20-slice0-erasure-design.md` §4
3816 /// item 4) — an erasure verb (`purge` / `excise_source` /
3817 /// `excise_collection_record`) deleted its rows but could NOT complete the
3818 /// erasure **at rest**, so it refuses to report success.
3819 ///
3820 /// The motivating case is the write-ahead log. `PRAGMA secure_delete=ON`
3821 /// zeroes pages freed inside the database file, but the erased content also
3822 /// sits in the WAL as committed frames from the ORIGINAL insert: an erasure
3823 /// DELETE appends new frames, it never rewrites old ones. Only a
3824 /// `wal_checkpoint(TRUNCATE)` removes them, and a concurrent reader pinning a
3825 /// WAL snapshot makes that checkpoint return `busy`. After a bounded retry
3826 /// the verb raises THIS error rather than returning `Ok` over erased bytes
3827 /// that are still `grep`-able on disk.
3828 ///
3829 /// **Contract: an erasure verb must never report success on an incomplete
3830 /// erasure.** The row deletions are committed and durable when this is
3831 /// raised; what failed is the at-rest scrub. The remedy is to retry the verb
3832 /// (or `recover --truncate-wal`) once the blocking reader has finished.
3833 /// `stage` names the uncompleted step (e.g. `"wal_checkpoint"`,
3834 /// `"telemetry_redaction"`); `detail` is a human-readable summary.
3835 ErasureIncomplete {
3836 stage: String,
3837 detail: String,
3838 },
3839 /// 0.8.20 Slice 15d (R-20-PR) — `configure_projections` refused an
3840 /// incompatible/DESTRUCTIVE change to an existing projection `name` that was
3841 /// NOT accompanied by an explicit `drop`. Omission from the spec never drops
3842 /// (C3, `api-surface.md:27`); a role REMOVAL or a tokenizer/embedder change
3843 /// on a live projection would silently discard an expensive-to-rebuild
3844 /// resource, so it is refused with the destructive `delta` surfaced. The
3845 /// caller re-issues with `drop: [name]` to consciously rebuild.
3846 ProjectionDestructive {
3847 name: String,
3848 delta: String,
3849 },
3850}
3851
3852impl Display for EngineError {
3853 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
3854 match self {
3855 Self::Storage => write!(f, "storage error"),
3856 Self::Projection => write!(f, "projection error"),
3857 Self::Vector => write!(f, "vector error"),
3858 Self::Embedder => write!(f, "embedder error"),
3859 Self::EmbedderNotConfigured => write!(f, "embedder is not configured"),
3860 Self::KindNotVectorIndexed => write!(f, "kind is not configured for vector indexing"),
3861 Self::EmbedderDimensionMismatch { expected, actual } => {
3862 write!(f, "embedder dimension mismatch: expected {expected}, actual {actual}")
3863 }
3864 Self::Scheduler => write!(f, "scheduler error"),
3865 Self::OpStore => write!(f, "op-store error"),
3866 Self::WriteValidation => write!(f, "write validation error"),
3867 Self::SchemaValidation => write!(f, "schema validation error"),
3868 Self::Overloaded => write!(f, "engine overloaded"),
3869 Self::Closing => write!(f, "engine is closing"),
3870 Self::Extractor => write!(f, "extractor error"),
3871 Self::Consolidator => write!(f, "consolidator error"),
3872 Self::InvalidFilter { reason } => write!(f, "invalid filter: {reason}"),
3873 Self::InvalidArgument { msg } => write!(f, "invalid argument: {msg}"),
3874 Self::VectorEquivalenceMismatch { reason } => {
3875 write!(f, "vector-equivalence self-check failed; dense retrieval refused: {reason}")
3876 }
3877 Self::IllegalTransition { from_state, to_state, legal } => {
3878 let legal_list = legal.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ");
3879 write!(
3880 f,
3881 "illegal lifecycle transition {} -> {}; legal targets from {}: [{}]",
3882 from_state.as_str(),
3883 to_state.as_str(),
3884 from_state.as_str(),
3885 legal_list,
3886 )
3887 }
3888 Self::NotLifecycleAddressable { id_space } => write!(
3889 f,
3890 "id space {:?} ({}) is not lifecycle-addressable; only the logical (l:) space is",
3891 id_space,
3892 id_space.prefix(),
3893 ),
3894 Self::ErasureIncomplete { stage, detail } => write!(
3895 f,
3896 "erasure incomplete at stage '{stage}': the rows were deleted but the erasure \
3897 could not be completed at rest ({detail})",
3898 ),
3899 Self::ProjectionDestructive { name, delta } => write!(
3900 f,
3901 "configure_projections refused a destructive change to projection '{name}' \
3902 without an explicit drop ({delta}); re-issue with drop: [\"{name}\"] to rebuild",
3903 ),
3904 }
3905 }
3906}
3907
3908impl EngineError {
3909 /// Stable machine-readable code for `errors_by_code` keys.
3910 ///
3911 /// Names match the binding-facing class stems in
3912 /// `dev/design/errors.md` § Binding-facing class matrix.
3913 fn stable_code(&self) -> &'static str {
3914 match self {
3915 Self::Storage => "StorageError",
3916 Self::Projection => "ProjectionError",
3917 Self::Vector => "VectorError",
3918 Self::Embedder => "EmbedderError",
3919 Self::EmbedderNotConfigured => "EmbedderNotConfiguredError",
3920 Self::KindNotVectorIndexed => "KindNotVectorIndexedError",
3921 Self::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
3922 Self::Scheduler => "SchedulerError",
3923 Self::OpStore => "OpStoreError",
3924 Self::WriteValidation => "WriteValidationError",
3925 Self::SchemaValidation => "SchemaValidationError",
3926 Self::Overloaded => "OverloadedError",
3927 Self::Closing => "ClosingError",
3928 Self::Extractor => "ExtractorError",
3929 Self::Consolidator => "ConsolidatorError",
3930 Self::InvalidFilter { .. } => "InvalidFilterError",
3931 Self::InvalidArgument { .. } => "InvalidArgumentError",
3932 Self::VectorEquivalenceMismatch { .. } => "VectorEquivalenceMismatchError",
3933 Self::IllegalTransition { .. } => "IllegalTransitionError",
3934 Self::NotLifecycleAddressable { .. } => "NotLifecycleAddressableError",
3935 Self::ErasureIncomplete { .. } => "ErasureIncompleteError",
3936 Self::ProjectionDestructive { .. } => "ProjectionDestructiveError",
3937 }
3938 }
3939}
3940
3941impl Error for EngineError {}
3942
3943/// Doctor `check-integrity` invocation flags. `quick` and `round_trip`
3944/// are accepted in 0.6.0 but treated as default; only `full` activates
3945/// `PRAGMA integrity_check`. Per `dev/design/recovery.md` § Doctor-only
3946/// flags.
3947#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
3948pub struct CheckIntegrityOpts {
3949 pub quick: bool,
3950 pub full: bool,
3951 pub round_trip: bool,
3952}
3953
3954/// One section of an [`IntegrityReport`]. Either every check in the
3955/// section was clean, or one or more typed [`Finding`]s describe the
3956/// detected issue. Per AC-043b.
3957#[derive(Clone, Debug, Eq, PartialEq)]
3958pub enum Section {
3959 Clean,
3960 Findings(Vec<Finding>),
3961}
3962
3963/// Single doctor finding record. Stable report-shape per AC-043c. The
3964/// `code` and `doc_anchor` strings are stable dispatch keys owned by
3965/// `dev/design/recovery.md` § Code-to-operator-action cross-reference.
3966#[derive(Clone, Debug, Eq, PartialEq)]
3967pub struct Finding {
3968 pub code: &'static str,
3969 pub stage: &'static str,
3970 pub locator: CorruptionLocator,
3971 pub doc_anchor: &'static str,
3972 pub detail: String,
3973}
3974
3975/// Three-section integrity report. AC-043a pins exactly these three
3976/// keys.
3977#[derive(Clone, Debug, Eq, PartialEq)]
3978pub struct IntegrityReport {
3979 pub physical: Section,
3980 pub logical: Section,
3981 pub semantic: Section,
3982}
3983
3984/// Result of a successful [`Engine::safe_export`] call. The returned
3985/// `manifest_sha256` equals the SHA-256 of the export file bytes (per
3986/// AC-039a) and matches the `sha256` field written into the manifest
3987/// JSON.
3988#[derive(Clone, Debug, Eq, PartialEq)]
3989pub struct SafeExportArtifact {
3990 pub export_path: PathBuf,
3991 pub manifest_path: PathBuf,
3992 pub manifest_sha256: String,
3993}
3994
3995/// Phase 9 Pack B trace report (AC-042). One event per canonical row
3996/// attributable to the requested `source_id`, ordered by `write_cursor`
3997/// ascending.
3998#[derive(Clone, Debug, Eq, PartialEq)]
3999pub struct TraceReport {
4000 pub source_ref: String,
4001 pub events: Vec<TraceEvent>,
4002}
4003
4004/// Single canonical-row tracing record. `table` is one of
4005/// `"canonical_nodes"` or `"canonical_edges"`.
4006#[derive(Clone, Debug, Eq, PartialEq)]
4007pub struct TraceEvent {
4008 pub write_cursor: u64,
4009 pub kind: String,
4010 pub table: &'static str,
4011}
4012
4013/// Which shadow-state surface a [`RebuildReport`] describes.
4014/// `Projections` covers the full FTS5 + vec0 + projection-terminal
4015/// rebuild emitted by [`Engine::rebuild_projections`]. `Vec0` covers
4016/// the vec0-only path emitted by [`Engine::rebuild_vec0`].
4017#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4018pub enum RebuildKind {
4019 Projections,
4020 Vec0,
4021}
4022
4023/// Structured result of a rebuild operation. `rows_invalidated` is the
4024/// total shadow-state rows truncated before re-derivation; `rows_rebuilt`
4025/// is the count of rows the synchronous rebuild loop re-materialised
4026/// (asynchronous re-enqueue work performed by the projection scheduler is
4027/// not counted here). `projection_cursor_after` is the post-rebuild value
4028/// of the projection cursor.
4029#[derive(Clone, Debug, Eq, PartialEq)]
4030pub struct RebuildReport {
4031 pub kind: RebuildKind,
4032 pub rows_invalidated: u64,
4033 pub rows_rebuilt: u64,
4034 pub projection_cursor_after: u64,
4035}
4036
4037/// Phase 9 Pack B excise report (AC-028a/b/c). Counts are post-excise
4038/// totals; `projections_invalidated` reports the shadow-row invalidation
4039/// total (FTS5 + vec0 + projection terminal) for the excised source.
4040#[derive(Clone, Debug, Eq, PartialEq)]
4041pub struct ExciseReport {
4042 pub source_ref: String,
4043 pub nodes_excised: u64,
4044 pub edges_excised: u64,
4045 pub projections_invalidated: u64,
4046}
4047
4048/// 0.8.20 Slice 15d (R-20-PR, C-1) — one member of a [`ProjectionSpec`]'s role
4049/// set. **Exactly three members** (HITL-ratified S8, `api-surface.md:87`):
4050/// `searchable→FTS` and `searchable→vector` are NOT roles — they are tier labels
4051/// carried by the `fts`/`vector` sub-objects of the spec, so an attribute is
4052/// `Searchable` once and the sub-objects select FTS-only / vector-only / both.
4053#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
4054pub enum ProjectionRole {
4055 /// Projects into the EAV store + its `(attr_name, attr_value)` composite
4056 /// index — cheap equality/range, built same-transaction.
4057 Filterable,
4058 /// The F9 importance/recency signal. **Graceful-absent (Q6a):** declaring
4059 /// it is legal and never errors, but the engine DEFERS the build until F9
4060 /// exists and grafts it on the next idempotent `configure_projections`.
4061 Rankable,
4062 /// Full-text / dense recall of the meaning text. The `fts`/`vector`
4063 /// sub-objects select the sub-target.
4064 Searchable,
4065}
4066
4067impl ProjectionRole {
4068 #[must_use]
4069 pub fn as_str(self) -> &'static str {
4070 match self {
4071 ProjectionRole::Filterable => "filterable",
4072 ProjectionRole::Rankable => "rankable",
4073 ProjectionRole::Searchable => "searchable",
4074 }
4075 }
4076
4077 #[must_use]
4078 pub fn from_str_opt(value: &str) -> Option<Self> {
4079 match value {
4080 "filterable" => Some(ProjectionRole::Filterable),
4081 "rankable" => Some(ProjectionRole::Rankable),
4082 "searchable" => Some(ProjectionRole::Searchable),
4083 _ => None,
4084 }
4085 }
4086}
4087
4088/// 0.8.20 Slice 15d (R-20-PR) — the `searchable→FTS` sub-target selector.
4089#[derive(Clone, Debug, Default, Eq, PartialEq)]
4090pub struct ProjectionFts {
4091 /// Optional tokenizer override; `None` ⇒ the engine default FTS5 tokenizer
4092 /// (`body`-FTS's `porter unicode61 remove_diacritics 2`). A custom
4093 /// per-attr tokenizer is the ≥0.9.x multi-field FTS work — recorded but
4094 /// not honoured here (graceful-graft later, same as `rankable`).
4095 pub tokenizer: Option<String>,
4096}
4097
4098/// 0.8.20 Slice 20 (R-20-DR) — the ENGINE-SET readiness of the
4099/// `searchable→vector` projection, per
4100/// `dev/design/record-lifecycle-protocol/projection-registry-and-async-embed.md`
4101/// §3.
4102///
4103/// **Exactly three members.** `filterable` and `searchable→FTS` are
4104/// same-transaction (non-stale on commit) so they need no readiness axis at all;
4105/// `searchable→vector` is **async, rebuild-durable**, so it carries one.
4106///
4107/// **Naming discipline (load-bearing).** The token **`pending` is RESERVED for
4108/// the admission axis** (quarantine/trust — an app judgment). Index-readiness is
4109/// a DIFFERENT, orthogonal dimension (a record can be
4110/// `active ∧ is_latest ∧ admissible` yet `dense_readiness = embedding`), so this
4111/// enum deliberately does **not** reuse that word: the non-ready member is
4112/// `Embedding`, never `Pending`.
4113#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
4114pub enum DenseReadiness {
4115 /// Engine-selected state for a session with no usable dense runtime (an
4116 /// absent embedder or refused vector equivalence). Caller input remains
4117 /// accept-inert; reads select this through the shared runtime predicate.
4118 Unavailable,
4119 /// At least one row in the vector projection's row set has not yet reached a
4120 /// projection terminal — embedding is outstanding. This is the ONLY
4121 /// tolerable torn state: readiness `embedding` with the vector absent (the
4122 /// dense arm reads as partial and RRF under-ranks; it does not hide).
4123 Embedding,
4124 /// Every row in the vector projection's row set has reached a projection
4125 /// terminal — the dense arm is caught up. Because the vector INSERT and the
4126 /// terminal record are written in ONE transaction
4127 /// ([`commit_projection_outcomes`]), `Ready` can never be observed with the
4128 /// vector row absent (design §4.1 invariant 1).
4129 Ready,
4130}
4131
4132impl DenseReadiness {
4133 #[must_use]
4134 pub fn as_str(self) -> &'static str {
4135 match self {
4136 DenseReadiness::Unavailable => "unavailable",
4137 DenseReadiness::Embedding => "embedding",
4138 DenseReadiness::Ready => "ready",
4139 }
4140 }
4141
4142 /// The three accepted spellings. `"pending"` is DELIBERATELY not one of them
4143 /// (reserved for the admission axis) and so parses to `None`.
4144 #[must_use]
4145 pub fn from_str_opt(value: &str) -> Option<Self> {
4146 match value {
4147 "unavailable" => Some(DenseReadiness::Unavailable),
4148 "embedding" => Some(DenseReadiness::Embedding),
4149 "ready" => Some(DenseReadiness::Ready),
4150 _ => None,
4151 }
4152 }
4153}
4154
4155/// The reason [`ProjectionRuntimeStatus::runtime_embedder_available`] is false.
4156///
4157/// This facade is deliberately distinct from the internal lifecycle
4158/// `ProjectionStatus`: it describes this open engine session's ability to run
4159/// the shared dense pipeline, not the terminal state of a canonical row.
4160#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4161pub enum ProjectionRuntimeUnavailabilityReason {
4162 /// A usable dense runtime is attached, so there is no unavailability.
4163 None,
4164 /// This engine session was opened without an attached embedder.
4165 NoRuntime,
4166 /// The attached embedder failed the existing vector-equivalence guard.
4167 VectorEquivalenceDisabled,
4168}
4169
4170impl ProjectionRuntimeUnavailabilityReason {
4171 #[must_use]
4172 pub fn as_str(self) -> &'static str {
4173 match self {
4174 Self::None => "none",
4175 Self::NoRuntime => "no_runtime",
4176 Self::VectorEquivalenceDisabled => "vector_equivalence_disabled",
4177 }
4178 }
4179}
4180
4181/// The dense-readiness projection of [`ProjectionRuntimeStatusEntry`].
4182///
4183/// `NotDeclared` means that the declaration has no *effective* vector arm.
4184/// The remaining states reuse the shared runtime/readiness facts, which are
4185/// corpus-wide until the engine gains per-projection dense work tracking.
4186#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4187pub enum ProjectionStatusDenseReadiness {
4188 /// The declaration has no `searchable` + vector sub-object pair.
4189 NotDeclared,
4190 /// An effective vector arm exists but this session has no usable runtime.
4191 Unavailable,
4192 /// An effective vector arm has eligible outstanding shared dense work.
4193 Embedding,
4194 /// An effective vector arm's shared dense work is quiescent.
4195 Ready,
4196}
4197
4198impl ProjectionStatusDenseReadiness {
4199 #[must_use]
4200 pub fn as_str(self) -> &'static str {
4201 match self {
4202 Self::NotDeclared => "not_declared",
4203 Self::Unavailable => "unavailable",
4204 Self::Embedding => "embedding",
4205 Self::Ready => "ready",
4206 }
4207 }
4208}
4209
4210/// One declaration's current dense status in [`ProjectionRuntimeStatus`].
4211#[derive(Clone, Debug, Eq, PartialEq)]
4212pub struct ProjectionRuntimeStatusEntry {
4213 /// Declared projection name. Entries are returned in ascending name order.
4214 pub name: String,
4215 /// Current dense state for this declaration's effective vector arm.
4216 pub dense_readiness: ProjectionStatusDenseReadiness,
4217}
4218
4219/// A pure, current view of projection-runtime facts for one open engine session.
4220///
4221/// `runtime_embedder_available` and its reason describe the dense runtime, not
4222/// whether any projection is declared. `projections` contains one entry per
4223/// durable declaration, sorted by name. `vector_unsupported_kinds` is current
4224/// and declaration-scoped: it is empty unless at least one declaration has an
4225/// effective (`searchable` + vector) arm.
4226#[derive(Clone, Debug, Eq, PartialEq)]
4227pub struct ProjectionRuntimeStatus {
4228 /// Whether an attached embedder passed the identity/equivalence safeguards.
4229 pub runtime_embedder_available: bool,
4230 /// `None` exactly when `runtime_embedder_available` is true.
4231 pub runtime_unavailability_reason: ProjectionRuntimeUnavailabilityReason,
4232 /// One sorted entry for every durable declaration.
4233 pub projections: Vec<ProjectionRuntimeStatusEntry>,
4234 /// Sorted, deduplicated permanently non-committable kinds for an effective arm.
4235 pub vector_unsupported_kinds: Vec<String>,
4236}
4237
4238/// 0.8.20 Slice 15d (R-20-PR) — the `searchable→vector` sub-target selector.
4239///
4240/// **Slice 20 (R-20-DR) attached `dense_readiness` HERE, additively:** this
4241/// sub-object is STORED by 15d (so the shape exists and a caller can declare a
4242/// vector projection); Slice 20 hangs the READ-METADATA readiness flag off it.
4243/// Nothing in 15d's persisted shape changed (the registry columns
4244/// `vector_embedder` + `vector_declared` still round-trip the declaration) —
4245/// **readiness is DERIVED, never stored**, so there is no schema step and no
4246/// separate flag that could tear (see [`derive_dense_readiness`]).
4247#[derive(Clone, Debug, Default, Eq, PartialEq)]
4248pub struct ProjectionVector {
4249 /// Optional embedder override; `None` ⇒ the engine's shipped default.
4250 pub embedder: Option<String>,
4251 /// 0.8.20 Slice 20 (R-20-DR) — **READ METADATA, engine-set.** Populated by
4252 /// [`Engine::read_projections`]; `None` on every caller-authored spec.
4253 ///
4254 /// It is **not part of the declaration**: `configure_projections` neither
4255 /// stores nor honours it (see [`StoredProjection::from_spec`], which reads
4256 /// only `embedder`), so a value supplied here is INERT — the engine always
4257 /// reports the derived truth. This is deliberately accept-inert rather than
4258 /// hard-reject so `read.projections` output stays feedable straight back
4259 /// into `configure_projections` (the fix-4 read→configure round-trip, which
4260 /// both bindings pin with a test).
4261 ///
4262 /// **0.8.20 Slice 23 (`R-20-SV`) correction (TC-39 class).** This doc used to
4263 /// justify accept-inert by analogy with "the already-audited accept-inert
4264 /// ruling on an `fts`/`vector` sub-object declared without the `searchable`
4265 /// role". **That ruling is OVERRULED** — the HITL ruled the shape an INVALID
4266 /// SPEC on 2026-07-24 and [`apply_projection_config`] now rejects it with
4267 /// [`EngineError::WriteValidation`]. `dense_readiness` accept-inert is
4268 /// UNCHANGED and stands on its own footing: it is engine-set READ METADATA,
4269 /// never part of the declaration, so there is nothing about it to reject.
4270 ///
4271 /// The bindings still HARD-REJECT the shapes that could
4272 /// not round-trip: a readiness supplied with `vector = false`, and any
4273 /// spelling outside `{unavailable, embedding, ready}`.
4274 pub dense_readiness: Option<DenseReadiness>,
4275}
4276
4277/// 0.8.20 Slice 15d (R-20-PR / C-1) — a single declarative projection
4278/// declaration. HITL-ratified shape (`api-surface.md:85-89`):
4279/// `{ name, roles: Set<ProjectionRole>, fts?, vector? }`. `roles` carries SET
4280/// semantics (dedup + membership; an attribute can be `Filterable` AND
4281/// `Searchable`) — encoded here as a sorted, de-duplicated `BTreeSet`. Named
4282/// `roles`, not `kind` (`kind` is the node/edge type discriminator).
4283#[derive(Clone, Debug, Eq, PartialEq)]
4284pub struct ProjectionSpec {
4285 pub name: String,
4286 pub roles: BTreeSet<ProjectionRole>,
4287 pub fts: Option<ProjectionFts>,
4288 pub vector: Option<ProjectionVector>,
4289 /// Optional ordered literal object-member path in the canonical node body.
4290 /// `None` preserves the legacy direct top-level lookup by `name`.
4291 pub source: Option<Vec<String>>,
4292}
4293
4294/// 0.8.20 Slice 15d (R-20-PR) — the diff [`Engine::configure_projections`]
4295/// applied. Idempotent re-registration yields `unchanged == true` with all
4296/// vecs empty (the "re-registration is a no-op" acceptance signal). A
4297/// destructive change without an explicit `drop` is an `Err`, not a delta.
4298#[derive(Clone, Debug, Default, Eq, PartialEq)]
4299pub struct ProjectionDelta {
4300 /// Attribute names whose same-transaction projections (EAV / property-FTS)
4301 /// were (re)built by this apply.
4302 pub built: Vec<String>,
4303 /// Attribute names dropped (explicit `drop` list) — their EAV + property-FTS
4304 /// rows and registry row removed.
4305 pub dropped: Vec<String>,
4306 /// Attribute names whose declared roles were persisted but NOT built:
4307 /// `rankable` (F9 not yet live) and the `searchable→vector` sub-target
4308 /// (Slice 20). These graft on a future idempotent apply. No error.
4309 pub deferred: Vec<String>,
4310 /// True iff nothing was built, dropped, or newly deferred — the whole apply
4311 /// diffed to a no-op.
4312 pub unchanged: bool,
4313 /// 0.8.20 Slice 22 (R-20-VC / **TC-67**) — **node KINDS, not attribute
4314 /// names.** The vector-eligible node kinds present in the corpus that the
4315 /// vector writer can NEVER commit, so no `searchable→vector` declaration
4316 /// will ever produce an embedding for them.
4317 ///
4318 /// # Why this field exists — the silence it replaces
4319 ///
4320 /// [`kind_is_vector_committable`] (Slice 20c fix-2) restricted enrolment to
4321 /// the kinds [`resolve_source_type`] maps, because enrolling any other kind
4322 /// is a permanent liveness wedge. That fix was correct and is unchanged —
4323 /// but it made the exclusion **silent**: the declaration persists, its name
4324 /// is pushed onto [`ProjectionDelta::deferred`], and the caller cannot tell
4325 /// "waiting on the embedder" (transient) from "this kind will never be
4326 /// embedded" (permanent). Per the HITL ruling on TC-67 the remedy is
4327 /// option **(c) REPORT** — the vocabulary is NOT grown and the Pack-1 D3
4328 /// partition-key lock is NOT touched (`dev/design/0.7.0-vector-quant-pack1.md`).
4329 ///
4330 /// # Axis, and why the name is what it is
4331 ///
4332 /// `built` / `dropped` / `deferred` are all lists of **projection attribute
4333 /// names**. This one is a list of **node kinds** — a different axis entirely,
4334 /// so the name says `kinds` explicitly and is prefixed `vector_` to bind it
4335 /// to the dense arm (an unsupported kind is still fully FTS/lexically
4336 /// searchable). Sorted and de-duplicated (`SELECT DISTINCT … ORDER BY kind`).
4337 ///
4338 /// # It is a STATE report, not a diff
4339 ///
4340 /// Unlike the other three vectors it does not describe what this call
4341 /// changed; it describes the corpus as it stands. So it is populated on an
4342 /// idempotent re-apply too (where `unchanged == true` and the other three
4343 /// are empty), and it deliberately does NOT feed [`ProjectionDelta::unchanged`].
4344 /// That is what makes the declare-time residual cheap to live with: to
4345 /// refresh the report after writing new kinds, re-apply the same spec — a
4346 /// no-op that still returns a current report.
4347 ///
4348 /// # Independent of the embedder
4349 ///
4350 /// Computed whenever a `searchable→vector` projection is declared, whether
4351 /// or not this session has a usable dense runtime. The vocabulary is static, so
4352 /// "this kind can never be embedded" is true in a no-embedder session too —
4353 /// and must not be conflated with the Q6a graceful-absent deferral, which is
4354 /// transient and is reported through `deferred`.
4355 ///
4356 /// Empty (never absent) when there is nothing to report.
4357 pub vector_unsupported_kinds: Vec<String>,
4358}
4359
4360/// 0.8.20 Slice 5b (R-20-E7) — outcome of
4361/// [`Engine::excise_collection_record`]. `records_excised` counts the erased
4362/// `operational_mutations` versions (an append-only-log collection keeps every
4363/// version of a key); `state_rows_excised` counts the erased
4364/// `operational_state` row (0 or 1).
4365///
4366/// `record_digest` is `SHA-256(collection + 0x1F + record_key)` — the audit
4367/// handle. The raw `record_key` is deliberately NOT carried: it is arbitrary
4368/// caller-supplied text and may itself be the identifier being erased, so
4369/// echoing it into a durable audit row would defeat the erasure.
4370#[derive(Clone, Debug, Eq, PartialEq)]
4371pub struct ExciseRecordReport {
4372 pub collection: String,
4373 pub record_digest: String,
4374 pub records_excised: u64,
4375 pub state_rows_excised: u64,
4376}
4377
4378/// Typed outcome of [`Engine::verify_embedder`]. Mismatches do not raise
4379/// `EngineError`; the operator workflow needs to see the stored vs.
4380/// supplied pair to decide on next action.
4381#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4382pub enum VerifyEmbedderStatus {
4383 Match,
4384 IdentityMismatch,
4385 DimensionMismatch,
4386 BothMismatch,
4387}
4388
4389/// Result of [`Engine::verify_embedder`]. `stored_identity` is the
4390/// `name:revision` pair persisted in `_fathomdb_embedder_profiles`;
4391/// `supplied_identity` echoes the operator's input verbatim.
4392#[derive(Clone, Debug, Eq, PartialEq)]
4393pub struct VerifyEmbedderReport {
4394 pub stored_identity: String,
4395 pub stored_dimension: u32,
4396 pub supplied_identity: String,
4397 pub supplied_dimension: u32,
4398 pub status: VerifyEmbedderStatus,
4399}
4400
4401/// Single table or index entry emitted by [`Engine::dump_schema`].
4402#[derive(Clone, Debug, Eq, PartialEq)]
4403pub struct SchemaObject {
4404 pub name: String,
4405 pub sql: String,
4406}
4407
4408/// Result of [`Engine::dump_schema`]. `user_version` is the
4409/// `PRAGMA user_version` sentinel. Canonical tables appear first per
4410/// [`fathomdb_schema::CANONICAL_TABLES`], then remaining non-`sqlite_*`
4411/// tables alphabetically. Indexes follow the same alphabetical rule.
4412#[derive(Clone, Debug, Eq, PartialEq)]
4413pub struct DumpSchemaReport {
4414 pub user_version: u32,
4415 pub tables: Vec<SchemaObject>,
4416 pub indexes: Vec<SchemaObject>,
4417}
4418
4419/// Single canonical-table row count emitted by [`Engine::dump_row_counts`].
4420#[derive(Clone, Debug, Eq, PartialEq)]
4421pub struct TableRowCount {
4422 pub name: String,
4423 pub rows: u64,
4424}
4425
4426/// Result of [`Engine::dump_row_counts`]. Canonical tables only;
4427/// projection / FTS / vec0 shadow tables are excluded. Order matches
4428/// [`fathomdb_schema::CANONICAL_TABLES`].
4429#[derive(Clone, Debug, Eq, PartialEq)]
4430pub struct DumpRowCountsReport {
4431 pub counts: Vec<TableRowCount>,
4432}
4433
4434/// 0.8.20 Slice 5d (R-20-E8) — one `source_id` bucket in an
4435/// [`OrphanProvenanceReport`]. `source_id` is `None` for the NULL-provenance
4436/// bucket, which after migration step 21 should contain ONLY governed NODES.
4437#[derive(Clone, Debug, Eq, PartialEq)]
4438pub struct OrphanProvenanceSource {
4439 /// `None` = the NULL-`source_id` bucket.
4440 pub source_id: Option<String>,
4441 /// Canonical rows (nodes + edges) carrying this provenance.
4442 pub rows: u64,
4443 /// How many of `rows` carry a `logical_id`.
4444 ///
4445 /// NOT the same thing as "purge-addressable": only a NODE's `logical_id`
4446 /// confers purge-addressability. An EDGE's `logical_id` is a supersession
4447 /// identity and reaches no erasure verb (see
4448 /// [`Engine::orphan_provenance`]), so governed edges are counted here but
4449 /// are NOT subtracted from
4450 /// [`OrphanProvenanceReport::unerasable_rows`].
4451 pub governed_rows: u64,
4452 /// True for the engine's reserved `_`-prefixed namespace (`_engine:*`,
4453 /// `_legacy:pre-0.8.20`). Reserved buckets are reachable only through the
4454 /// operator seam `excise_source`, never through the governed
4455 /// [`Engine::erase_source`].
4456 pub reserved: bool,
4457}
4458
4459/// Result of [`Engine::orphan_provenance`] — the per-`source_id` census behind
4460/// `fathomdb doctor orphan-provenance` (design §4 item 11).
4461///
4462/// `unerasable_rows` is the load-bearing field: canonical rows carrying
4463/// NEITHER a `source_id` NOR a `logical_id`. Such a row is reachable by no
4464/// erasure verb at all — `purge` keys on `logical_id`, `erase_source` keys on
4465/// `source_id` — so it can never be deleted on request. Slice 5c made that
4466/// state unwritable and migration step 21 back-filled the historical cases, so
4467/// a non-zero count means the invariant has been violated and the verb exits
4468/// `DOCTOR_FOUND_ISSUES`.
4469#[derive(Clone, Debug, Eq, PartialEq)]
4470pub struct OrphanProvenanceReport {
4471 /// Per-`source_id` buckets, ordered by descending `rows` then `source_id`
4472 /// so the output is deterministic (a diagnostic that reorders between runs
4473 /// cannot be diffed).
4474 pub sources: Vec<OrphanProvenanceSource>,
4475 /// Total canonical rows surveyed.
4476 pub total_rows: u64,
4477 /// Rows with NO `source_id` AND NO `logical_id` — un-erasable by any verb.
4478 pub unerasable_rows: u64,
4479}
4480
4481/// Result of [`Engine::dump_profile`]. Mirrors the open-time embedder
4482/// posture + the per-kind vector configuration registered in
4483/// `_fathomdb_vector_kinds`.
4484#[derive(Clone, Debug, Eq, PartialEq)]
4485pub struct DumpProfileReport {
4486 pub embedder_identity: String,
4487 pub embedder_dimension: u32,
4488 pub vectorized_kinds: Vec<String>,
4489}
4490
4491/// 0.7.2 PR-2b — result of [`Engine::recompute_mean`] (the manual
4492/// `doctor recompute-mean` path) and of the shared in-transaction
4493/// recompute core. `drift_cos_before` is the cosine between the freshly
4494/// derived corpus mean and the previously-pinned mean (1.0 when nothing
4495/// was pinned yet, i.e. a first pin). `mean_was_pinned` distinguishes a
4496/// refresh of an existing mean from an initial pin. See
4497/// `dev/design/embedder.md` §0.3.
4498#[derive(Clone, Debug, PartialEq)]
4499pub struct MeanRecomputeReport {
4500 pub dim: u32,
4501 pub old_doc_count: u64,
4502 pub doc_count_requantized: u64,
4503 pub drift_cos_before: f32,
4504 pub mean_was_pinned: bool,
4505 pub elapsed_ms: u64,
4506}
4507
4508/// Typed outcome of [`Engine::truncate_wal`]. `Done` matches SQLite's
4509/// `busy = 0` return from `PRAGMA wal_checkpoint(TRUNCATE)`; any other
4510/// value surfaces as `Busy`.
4511#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4512pub enum TruncateWalStatus {
4513 Done,
4514 Busy,
4515}
4516
4517/// Result of [`Engine::truncate_wal`]. Carries the three counters
4518/// returned by `PRAGMA wal_checkpoint(TRUNCATE)`: `busy`, `log_frames`,
4519/// `checkpointed_frames`.
4520#[derive(Clone, Debug, Eq, PartialEq)]
4521pub struct TruncateWalReport {
4522 pub status: TruncateWalStatus,
4523 pub busy: u32,
4524 pub log_frames: u32,
4525 pub checkpointed_frames: u32,
4526}
4527
4528impl Drop for Engine {
4529 fn drop(&mut self) {
4530 let _ = self.close();
4531 }
4532}
4533
4534impl Engine {
4535 fn usable_dense_runtime(&self) -> bool {
4536 usable_dense_runtime(
4537 self.runtime_embedder.as_deref(),
4538 self.dense_disabled.load(Ordering::Acquire),
4539 )
4540 }
4541
4542 pub fn open(path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
4543 Self::open_with_embedder_and_subscriber(
4544 path,
4545 default_embedder_identity(),
4546 None,
4547 None,
4548 None,
4549 &mut |_| {},
4550 )
4551 }
4552
4553 /// Open an engine with an explicit [`EmbedderChoice`].
4554 ///
4555 /// Per `dev/design/embedder.md` §0 + the 0.7.1 EU-5 campaign, this is
4556 /// the canonical entry point for selecting how the workspace's
4557 /// default embedder is supplied. See [`EmbedderChoice`] for the
4558 /// semantics of each variant; in particular `Default` materializes
4559 /// the pinned BGE embedder via the loader when the `default-embedder`
4560 /// feature is enabled.
4561 pub fn open_with_choice(
4562 path: impl Into<PathBuf>,
4563 choice: EmbedderChoice,
4564 ) -> Result<OpenedEngine, EngineOpenError> {
4565 match choice {
4566 EmbedderChoice::Default => Self::open_default_embedder(path),
4567 EmbedderChoice::Caller(embedder) => {
4568 let identity = embedder.identity();
4569 Self::open_with_embedder_and_subscriber(
4570 path,
4571 identity,
4572 Some(embedder),
4573 None,
4574 None,
4575 &mut |_| {},
4576 )
4577 }
4578 EmbedderChoice::None => Self::open_with_embedder_and_subscriber(
4579 path,
4580 default_embedder_identity(),
4581 None,
4582 None,
4583 None,
4584 &mut |_| {},
4585 ),
4586 }
4587 }
4588
4589 /// EU-5b: materialize the engine's pinned default embedder
4590 /// (`CandleBgeEmbedder` backed by the EU-3 loader) and open the
4591 /// workspace with it. Without the `default-embedder` feature, fails
4592 /// with a typed `Embedder` error rather than touching the network.
4593 #[cfg(feature = "default-embedder")]
4594 fn open_default_embedder(path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
4595 use std::time::Instant as DownloadInstant;
4596 let download_start = DownloadInstant::now();
4597 let weights = fathomdb_embedder::loader::load_pinned_default_embedder().map_err(|err| {
4598 EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
4599 message: format!("default embedder loader: {err}"),
4600 })
4601 })?;
4602 let events = weights.events.clone();
4603 let download_ms = if weights.bytes_downloaded > 0 {
4604 Some(u64::try_from(download_start.elapsed().as_millis()).unwrap_or(u64::MAX))
4605 } else {
4606 None
4607 };
4608 let embedder =
4609 fathomdb_embedder::CandleBgeEmbedder::new_from_weights(weights).map_err(|err| {
4610 EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
4611 message: format!("default embedder construct: {err}"),
4612 })
4613 })?;
4614 let embedder: Arc<dyn Embedder> = Arc::new(embedder);
4615 let identity = embedder.identity();
4616 let loader_info = LoaderInfo { download_ms, events };
4617 Self::open_with_embedder_and_subscriber(
4618 path,
4619 identity,
4620 Some(embedder),
4621 Some(loader_info),
4622 None,
4623 &mut |_| {},
4624 )
4625 }
4626
4627 #[cfg(not(feature = "default-embedder"))]
4628 fn open_default_embedder(_path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
4629 Err(EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
4630 message: "EmbedderChoice::Default requires the `default-embedder` Cargo feature"
4631 .to_string(),
4632 }))
4633 }
4634
4635 pub fn open_with_migration_event_sink(
4636 path: impl Into<PathBuf>,
4637 mut emit_migration_event: impl FnMut(&MigrationStepReport),
4638 ) -> Result<OpenedEngine, EngineOpenError> {
4639 Self::open_with_embedder_and_subscriber(
4640 path,
4641 default_embedder_identity(),
4642 None,
4643 None,
4644 None,
4645 &mut emit_migration_event,
4646 )
4647 }
4648
4649 #[cfg(debug_assertions)]
4650 #[doc(hidden)]
4651 pub fn open_with_migrations_for_test(
4652 path: impl Into<PathBuf>,
4653 migrations: &'static [fathomdb_schema::Migration],
4654 mut emit_migration_event: impl FnMut(&MigrationStepReport),
4655 ) -> Result<OpenedEngine, EngineOpenError> {
4656 Self::open_with_migrations(
4657 path,
4658 migrations,
4659 default_embedder_identity(),
4660 None,
4661 None,
4662 &mut emit_migration_event,
4663 None,
4664 )
4665 }
4666
4667 #[doc(hidden)]
4668 pub fn open_with_subscriber_for_test(
4669 path: impl Into<PathBuf>,
4670 subscriber: Arc<dyn lifecycle::Subscriber>,
4671 ) -> Result<OpenedEngine, EngineOpenError> {
4672 Self::open_with_embedder_and_subscriber(
4673 path,
4674 default_embedder_identity(),
4675 None,
4676 None,
4677 Some(subscriber),
4678 &mut |_| {},
4679 )
4680 }
4681
4682 #[doc(hidden)]
4683 pub fn open_without_embedder_for_test(
4684 path: impl Into<PathBuf>,
4685 ) -> Result<OpenedEngine, EngineOpenError> {
4686 Self::open_with_embedder_and_subscriber(
4687 path,
4688 default_embedder_identity(),
4689 None,
4690 None,
4691 None,
4692 &mut |_| {},
4693 )
4694 }
4695
4696 #[doc(hidden)]
4697 pub fn open_with_embedder_for_test(
4698 path: impl Into<PathBuf>,
4699 embedder: Arc<dyn Embedder>,
4700 ) -> Result<OpenedEngine, EngineOpenError> {
4701 let identity = embedder.identity();
4702 Self::open_with_embedder_and_subscriber(
4703 path,
4704 identity,
4705 Some(embedder),
4706 None,
4707 None,
4708 &mut |_| {},
4709 )
4710 }
4711
4712 fn open_with_embedder_and_subscriber(
4713 path: impl Into<PathBuf>,
4714 embedder_identity: EmbedderIdentity,
4715 runtime_embedder: Option<Arc<dyn Embedder>>,
4716 loader_info: Option<LoaderInfo>,
4717 initial_subscriber: Option<Arc<dyn lifecycle::Subscriber>>,
4718 emit_migration_event: &mut impl FnMut(&MigrationStepReport),
4719 ) -> Result<OpenedEngine, EngineOpenError> {
4720 Self::open_with_migrations(
4721 path,
4722 MIGRATIONS,
4723 embedder_identity,
4724 runtime_embedder,
4725 loader_info,
4726 emit_migration_event,
4727 initial_subscriber,
4728 )
4729 }
4730
4731 fn open_with_migrations(
4732 path: impl Into<PathBuf>,
4733 migrations: &'static [fathomdb_schema::Migration],
4734 embedder_identity: EmbedderIdentity,
4735 runtime_embedder: Option<Arc<dyn Embedder>>,
4736 loader_info: Option<LoaderInfo>,
4737 emit_migration_event: &mut impl FnMut(&MigrationStepReport),
4738 initial_subscriber: Option<Arc<dyn lifecycle::Subscriber>>,
4739 ) -> Result<OpenedEngine, EngineOpenError> {
4740 let canonical_path = canonical_database_path(&path.into())?;
4741 let lock = acquire_lock(&canonical_path)?;
4742 let open_result = Self::open_locked(
4743 canonical_path.clone(),
4744 migrations,
4745 &embedder_identity,
4746 emit_migration_event,
4747 );
4748
4749 match open_result {
4750 Ok((connection, readers, mut report, reader_lookaside_rcs)) => {
4751 // EU-5b — splice the loader's measurements + structured
4752 // events into the report. The loader path is the only
4753 // surface that produces these today; caller-supplied
4754 // embedders and EmbedderChoice::None leave them as the
4755 // open_locked defaults (None / empty).
4756 if let Some(info) = loader_info {
4757 if info.download_ms.is_some() {
4758 report.embedder_download_ms = info.download_ms;
4759 }
4760 if !info.events.is_empty() {
4761 report.embedder_events = info.events;
4762 }
4763 }
4764
4765 // 0.8.18 Slice 5 (#5 vector-equivalence probe KEYSTONE) — run the
4766 // open-time self-check on the FINAL post-recovery connection (the
4767 // mean is already pinned/recovered inside open_locked, U1-b). First
4768 // registration persists the 45 UN-centered f32 references; a
4769 // subsequent open re-embeds + asserts P1 (mean-centered flip count,
4770 // floor 0) and P2 (un-centered L2 ε). Divergence ⇒ degraded-open
4771 // (`dense_disabled=true`), surfaced on the OpenReport (R-VEQ-6); the
4772 // query-time refusal fires later at `search_inner_with_stats`.
4773 // A durable declaration is a prospective dense arm even before
4774 // it has enrolled a kind. Check it before the boot graft below:
4775 // a refused backend may leave the declaration at rest, but must
4776 // not enrol, requeue, dispatch, or write any dense work.
4777 let prospective_dense_arm =
4778 vector_projection_declared(&connection).map_err(|_| EngineOpenError::Io {
4779 message: "could not inspect declared vector projection on open".to_string(),
4780 })?;
4781 let veq = run_vector_equivalence_probe(
4782 &connection,
4783 runtime_embedder.as_deref(),
4784 &embedder_identity,
4785 report.embedder_mean_vec_pinned,
4786 prospective_dense_arm,
4787 );
4788 report.dense_disabled = veq.dense_disabled;
4789 report.dense_disabled_reason = veq.reason.clone();
4790
4791 let dense_runtime_usable =
4792 usable_dense_runtime(runtime_embedder.as_deref(), veq.dense_disabled);
4793 let boot_graft_enqueued = if dense_runtime_usable {
4794 boot_graft_declared_vector_backfill(&connection).map_err(|_| {
4795 EngineOpenError::Io {
4796 message: "could not graft declared vector projection on boot"
4797 .to_string(),
4798 }
4799 })?
4800 } else {
4801 false
4802 };
4803
4804 let next_cursor = load_next_cursor(&connection);
4805 let subscribers = Arc::new(lifecycle::SubscriberRegistry::new());
4806 let profiling_enabled = Arc::new(AtomicBool::new(false));
4807 let slow_threshold_ms = Arc::new(AtomicU64::new(DEFAULT_SLOW_THRESHOLD_MS));
4808 let mut profile_contexts: Vec<Box<ProfileContext>> = Vec::new();
4809 let scheduler_embedder =
4810 if dense_runtime_usable { runtime_embedder.clone() } else { None };
4811 let projection_runtime = ProjectionRuntime::new(
4812 canonical_path.clone(),
4813 scheduler_embedder,
4814 embedder_identity.clone(),
4815 report.embedder_mean_vec_pinned,
4816 Arc::clone(&subscribers),
4817 );
4818
4819 install_profile_callback(
4820 &connection,
4821 &subscribers,
4822 &profiling_enabled,
4823 &slow_threshold_ms,
4824 &mut profile_contexts,
4825 );
4826 for reader in &readers {
4827 install_profile_callback(
4828 reader,
4829 &subscribers,
4830 &profiling_enabled,
4831 &slow_threshold_ms,
4832 &mut profile_contexts,
4833 );
4834 }
4835
4836 let opened = OpenedEngine {
4837 engine: Self {
4838 path: canonical_path.clone(),
4839 next_cursor: AtomicU64::new(next_cursor),
4840 closed: AtomicBool::new(false),
4841 lock: Mutex::new(Some(lock)),
4842 connection: Mutex::new(Some(connection)),
4843 reader_pool: ReaderWorkerPool::new(readers),
4844 counters: lifecycle::Counters::new(),
4845 subscribers,
4846 profiling_enabled,
4847 slow_threshold_ms,
4848 runtime_embedder,
4849 runtime_embedder_identity: embedder_identity,
4850 projection_runtime,
4851 provenance_row_cap: AtomicU64::new(DEFAULT_PROVENANCE_ROW_CAP),
4852 profile_contexts: Mutex::new(profile_contexts),
4853 reader_lookaside_rcs,
4854 telemetry: Mutex::new(None),
4855 telemetry_enabled: AtomicBool::new(false),
4856 dense_disabled: AtomicBool::new(veq.dense_disabled),
4857 dense_disabled_reason: Mutex::new(veq.reason),
4858 vector_equivalence_refusals: AtomicU64::new(0),
4859 #[cfg(debug_assertions)]
4860 force_next_commit_failure: AtomicBool::new(false),
4861 },
4862 report,
4863 };
4864 if let Some(subscriber) = initial_subscriber {
4865 opened.engine.subscribers.attach_persistent(subscriber);
4866 }
4867 if dense_runtime_usable
4868 && (boot_graft_enqueued
4869 || database_has_pending_projection_work(&canonical_path).unwrap_or(false))
4870 {
4871 opened.engine.projection_runtime.notify_new_work();
4872 }
4873 Ok(opened)
4874 }
4875 Err(err) => {
4876 if let Some(subscriber) = initial_subscriber {
4877 emit_open_error_event(&subscriber, &err);
4878 }
4879 drop(lock);
4880 Err(err)
4881 }
4882 }
4883 }
4884
4885 fn open_locked(
4886 path: PathBuf,
4887 migrations: &'static [fathomdb_schema::Migration],
4888 embedder_identity: &EmbedderIdentity,
4889 emit_migration_event: &mut impl FnMut(&MigrationStepReport),
4890 ) -> Result<(Connection, Vec<Connection>, OpenReport, Vec<i32>), EngineOpenError> {
4891 init_perf_experiments_runtime();
4892 register_sqlite_vec_extension();
4893 let mut connection = Connection::open(&path)
4894 .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))?;
4895 // Order pinned by `dev/design/errors.md` § OpenStage matrix: each
4896 // step routes its own SQLite-level error to a distinct
4897 // `CorruptionKind` (Header → WalReplay → Schema → EmbedderIdentity).
4898 // The schema and WAL probes both happen BEFORE `pragma WAL`
4899 // because that pragma also reads page 1 — letting it run first
4900 // would reclassify schema-side corruption as a WAL replay
4901 // failure, breaking the AC-035b stable-code contract.
4902 probe_database_header(&connection)?;
4903 probe_open_integrity(&connection)?;
4904 probe_wal_sidecar(&path)?;
4905 // 0.7.0 perf-experiments: apply writer-side experiment PRAGMAs
4906 // (page_size, etc.) BEFORE journal_mode + migrations. page_size
4907 // is silently ignored once any table exists; this is the only
4908 // legal window to set it on a fresh DB. Gated on
4909 // FATHOMDB_PERF_EXPERIMENTS=1; no-op in production.
4910 apply_perf_experiment_writer_pragmas(&connection);
4911 // OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — standing
4912 // `secure_delete=ON` on the writer, applied at EVERY open (fresh + migrated).
4913 // It zeroes every page freed by a future DELETE, so the Slice-10 `purge`
4914 // hard-erase is complete WITHOUT a per-purge `VACUUM`. It is a connection
4915 // PRAGMA (not schema DDL), so it belongs here, not in the 19→20 migration.
4916 // RESIDUAL (documented, not forced): pages freed on a pre-20 DB BEFORE this
4917 // was enabled are not retroactively scrubbed; there is no migration-time
4918 // full `VACUUM` (O(db-size)). NOTE: this is a standing pragma set at EVERY
4919 // connection open (writer here, plus the reader-pool and
4920 // `open_runtime_connection`), NOT the writer alone — non-writer connections
4921 // also free pages (projection / vector-rewrite DELETEs), so a writer-only
4922 // `secure_delete` would leak freed content on disk. See the matching
4923 // reader/runtime open comment (~lines 3335-3336).
4924 connection
4925 .pragma_update(None, "secure_delete", "ON")
4926 .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
4927 connection
4928 .pragma_update(None, "journal_mode", "WAL")
4929 .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
4930
4931 reject_legacy_shape(&connection)?;
4932 let migration = migrate_with_event_sink(&connection, migrations, emit_migration_event)
4933 .map_err(map_migration_error)?;
4934 // 0.8.0 Slice 5 (G1) — global FTS5 tokenizer-default upgrade. Step 11
4935 // drops + recreates `search_index` with the new tokenizer, leaving it
4936 // EMPTY on a migrated DB. The projection scheduler will NOT
4937 // repopulate it (`database_has_pending_projection_work` keys "pending"
4938 // off `_fathomdb_projection_terminal`, which the migration does not
4939 // clear). Re-tokenize from the canonical source rows here, on the
4940 // writer connection, single-threaded, before readers spawn —
4941 // projection-only, no source-record migration.
4942 //
4943 // Crash-retryable (fix-1): step 11 commits `user_version = 11` with an
4944 // empty index in its OWN transaction; this reproject commits in a
4945 // LATER transaction. A crash in that window leaves a durable v11 + empty
4946 // index, on which a boundary-crossing guard (`before < 11`) is FALSE,
4947 // skipping repair forever. So gate on the completion marker's ABSENCE
4948 // (written atomically with the reindex) instead: idempotent, and a
4949 // crash before the reindex commit simply re-runs on the next open.
4950 if migration.schema_version_after >= SEARCH_INDEX_TOKENIZER_SCHEMA_VERSION
4951 && !search_index_tokenizer_reproject_complete(&connection).map_err(|_| {
4952 EngineOpenError::Io {
4953 message: "could not read search_index tokenizer reproject marker".to_string(),
4954 }
4955 })?
4956 {
4957 reproject_search_index_after_tokenizer_upgrade(&connection).map_err(|_| {
4958 EngineOpenError::Io {
4959 message: "could not re-tokenize search_index after tokenizer upgrade"
4960 .to_string(),
4961 }
4962 })?;
4963 }
4964 let mut embedder_mean_vec_pinned = check_embedder_profile(&connection, embedder_identity)?;
4965 ensure_vector_partition(&mut connection, embedder_identity.dimension).map_err(|_| {
4966 EngineOpenError::Io { message: "could not initialize vector partition".to_string() }
4967 })?;
4968
4969 // 0.8.20 Slice 15c (TC-33) fix-6 [codex §9 P1] — the step-23
4970 // `canonical_edges` recreate drops every edge row (NO DATA MIGRATION) and
4971 // removes their `_fathomdb_vector_rows` sidecar rows, but the vec0
4972 // `vector_default` shadow those mirror is engine-created + dim-aware, so
4973 // the migration cannot delete its rows. Left behind, an orphaned edge vec0
4974 // row (whose `canonical_edges` row is gone) still occupies a top-K KNN
4975 // candidate slot — `build_vector_phase1_sql` reads candidates DIRECTLY
4976 // from `vector_default` before hydrating them through the canonical tables
4977 // — and is then discarded at hydration, so an upgraded DB silently returns
4978 // too few / no vector results. Prune the orphans now that
4979 // `ensure_vector_partition` guarantees `vector_default` exists, BEFORE the
4980 // mean-vec row-count recovery below (so the count excludes them). One-time
4981 // and crash-retryable via the durable completion marker; a no-op on any
4982 // healthy corpus (every vec0 row has a sidecar entry), so recall / eu7
4983 // fidelity are unchanged on a DB that never dropped edges.
4984 if migration.schema_version_after >= EDGE_TEMPORAL_EPOCH_SCHEMA_VERSION
4985 && !edge_vector_prune_complete(&connection).map_err(|_| EngineOpenError::Io {
4986 message: "could not read edge-vector prune marker".to_string(),
4987 })?
4988 {
4989 prune_orphaned_edge_vectors(&connection).map_err(|_| EngineOpenError::Io {
4990 message: "could not prune orphaned edge vector rows".to_string(),
4991 })?;
4992 }
4993
4994 // 0.8.20 Slice 15d (R-20-PR, Q5) — boot re-derive the projection registry
4995 // (the engine `ProjectionSpec` is a derived cache). For every persisted
4996 // declaration, clear + backfill its EAV / property-FTS rows from the
4997 // canonical nodes so a crash window (registry row survives, projection
4998 // rows partial) self-heals idempotently. A no-op single empty-table read
4999 // on every DB that has not declared a projection. On the writer
5000 // connection, single-threaded, before readers spawn — like the tokenizer
5001 // reproject above. Runs after the fix-6 edge-vector prune above; the two
5002 // are independent boot reconciliations.
5003 rederive_projections_on_boot(&connection).map_err(|_| EngineOpenError::Io {
5004 message: "could not re-derive projection registry on boot".to_string(),
5005 })?;
5006
5007 // 0.8.20 Slice 21 fix-1 (codex §9 round 1 [P2], ledger `TC-71`) — bring an
5008 // ALREADY-ENROLLED inert vector kind into agreement with the role-aware
5009 // decision. Slice 21c closed the three forward doors, but a database that
5010 // already ran the old code under `{roles:[filterable], vector:{}}` keeps
5011 // its `_fathomdb_vector_kinds` rows — `vector_kind_needs_enrolment`
5012 // short-circuits on `kind_is_vector_indexed` and never reaches the new
5013 // predicate, and `project_canonical_node_row` reads only the registry
5014 // membership — so upgrading did not actually stop the unwanted embeddings.
5015 // Narrowly authorised (registry EXISTS, declares a `vector` sub-object,
5016 // and declares no `searchable→vector` projection) so a LEGACY workspace
5017 // with a working dense arm is never touched; see
5018 // [`registry_governs_an_inert_dense_arm`]. Deletes no embedding. Runs
5019 // BEFORE `run_vector_equivalence_probe` (which fires after `open_locked`
5020 // returns), so a database whose only enrolment was the inert one pays no
5021 // probe embeds on the healing open. Another boot reconciliation on the
5022 // writer connection, single-threaded, before readers spawn.
5023 reconcile_inert_vector_enrolments_on_boot(&connection).map_err(|_| {
5024 EngineOpenError::Io {
5025 message: "could not reconcile inert vector kind enrolments on boot".to_string(),
5026 }
5027 })?;
5028
5029 // 0.8.20 Slice 15e — reconcile the live `vector_default` attribute columns
5030 // with the registry's `filterable` set. On a DB whose vec0 shape already
5031 // matches the registry (the common case, incl. every reopen of a DB that
5032 // declared filterable projections in a prior session) this is a pure
5033 // no-op: the diff is empty, so boot never re-inserts and NEVER silently
5034 // wipes the corpus. It converges only a shape that drifted from the
5035 // registry (e.g. a restored registry row). A no-op when the table is
5036 // absent (no embedder). Runs on the writer connection, single-threaded,
5037 // before readers spawn — like the boot re-derive above.
5038 {
5039 let tx = connection.transaction().map_err(|_| EngineOpenError::Io {
5040 message: "could not begin vector-attr reconcile on boot".to_string(),
5041 })?;
5042 reconcile_vector_attr_columns(&tx, embedder_identity.dimension).map_err(|_| {
5043 EngineOpenError::Io {
5044 message: "could not reconcile vector attribute columns on boot".to_string(),
5045 }
5046 })?;
5047 tx.commit().map_err(|_| EngineOpenError::Io {
5048 message: "could not commit vector-attr reconcile on boot".to_string(),
5049 })?;
5050 }
5051
5052 // EU-5f — recovery pin (`dev/design/embedder.md` §0.3, Hazard 4). If
5053 // the identity is MC-required, no mean is pinned, yet the workspace
5054 // already holds >= MEAN_VEC_PIN_THRESHOLD vector rows (e.g. a crash
5055 // between the threshold-crossing write and its pin commit), derive
5056 // the mean from the existing un-centered rows and pin+re-quantize
5057 // now, single-threaded, before the projection workers spawn. The
5058 // NULL guard makes this idempotent on subsequent opens.
5059 if identity_requires_mean_centering(embedder_identity) && !embedder_mean_vec_pinned {
5060 let row_count: u64 = connection
5061 .query_row("SELECT COUNT(*) FROM vector_default", [], |row| row.get(0))
5062 .unwrap_or(0);
5063 if row_count >= MEAN_VEC_PIN_THRESHOLD {
5064 recover_mean_vec_pin(&mut connection, embedder_identity).map_err(|_| {
5065 EngineOpenError::Io {
5066 message: "could not recover mean-centering pin".to_string(),
5067 }
5068 })?;
5069 embedder_mean_vec_pinned = true;
5070 }
5071 }
5072
5073 let warmup_started = Instant::now();
5074 // Static identity capability — see `dev/design/embedder.md`
5075 // §0.6. Today only the bge-small identity reports `true`; the
5076 // noop scaffolding identity is `false`. EU-5b's identity flip
5077 // makes the Default path return `true` here automatically.
5078 let embedder_mean_centering_required = embedder_identity.name == BGE_SMALL_EMBEDDER_NAME;
5079 // EU-5a2 — populated from `_fathomdb_embedder_profiles.mean_vec`
5080 // by `check_embedder_profile` above (was hard-coded `false` in
5081 // EU-5a1). Dimension invariant (§0.2) enforced by that check.
5082 let report = OpenReport {
5083 schema_version_before: migration.schema_version_before,
5084 schema_version_after: migration.schema_version_after,
5085 migration_steps: migration.migration_steps,
5086 embedder_warmup_ms: u64::try_from(warmup_started.elapsed().as_millis())
5087 .unwrap_or(u64::MAX),
5088 query_backend: "fathomdb-query + sqlite-vec",
5089 default_embedder: embedder_identity.clone(),
5090 // TODO(EU-5b): surface `LoadedWeights.download_ms` from the
5091 // loader once the Default path materializes through it.
5092 embedder_download_ms: None,
5093 // TODO(EU-5b): surface `LoadedWeights.events` from the loader.
5094 embedder_events: Vec::new(),
5095 embedder_mean_centering_required,
5096 embedder_mean_vec_pinned,
5097 // 0.8.18 Slice 5 — set by the #5 self-check in `open_with_migrations`
5098 // (which has the runtime embedder in scope). `open_locked` returns the
5099 // non-degraded default; the probe runs after this returns.
5100 dense_disabled: false,
5101 dense_disabled_reason: None,
5102 };
5103
5104 let mut readers = Vec::with_capacity(READER_POOL_SIZE);
5105 let mut lookaside_rcs: Vec<i32> = Vec::with_capacity(READER_POOL_SIZE);
5106 for _ in 0..READER_POOL_SIZE {
5107 let reader = Connection::open(&path)
5108 .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))?;
5109 // Pack 6.G G.1: configure per-connection lookaside BEFORE
5110 // any PRAGMA / prepare runs on this reader. Reordering this
5111 // after the journal-mode / query_only PRAGMAs would let
5112 // SQLite silently ignore the lookaside setting.
5113 let rc: i32 = configure_reader_lookaside(&reader);
5114 debug_assert_eq!(
5115 rc,
5116 rusqlite::ffi::SQLITE_OK,
5117 "sqlite3_db_config(LOOKASIDE) must return SQLITE_OK on a freshly opened reader",
5118 );
5119 lookaside_rcs.push(rc);
5120 reader
5121 .pragma_update(None, "journal_mode", "WAL")
5122 .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
5123 // OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — `secure_delete=ON`
5124 // at EVERY connection open, not just the writer. `secure_delete` is a
5125 // per-connection pager flag, so a reader-pool connection that frees a
5126 // page (vector-rewrite / projection DELETEs run off non-writer
5127 // connections) would otherwise leave that freed content on disk,
5128 // defeating GDPR erasure. Set BEFORE `query_only=ON` so the ordering is
5129 // unambiguous (the flag is a pager setting, not a DB write).
5130 reader
5131 .pragma_update(None, "secure_delete", "ON")
5132 .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
5133 reader
5134 .pragma_update(None, "query_only", "ON")
5135 .map_err(|err| map_open_sqlite_error(err, OpenStage::SchemaProbe))?;
5136 apply_perf_experiment_reader_pragmas(&reader);
5137 readers.push(reader);
5138 }
5139
5140 Ok((connection, readers, report, lookaside_rcs))
5141 }
5142
5143 #[must_use]
5144 pub fn path(&self) -> &Path {
5145 &self.path
5146 }
5147
5148 pub fn write(&self, batch: &[PreparedWrite]) -> Result<WriteReceipt, EngineError> {
5149 let category = if batch_is_admin(batch) {
5150 lifecycle::EventCategory::Admin
5151 } else {
5152 lifecycle::EventCategory::Writer
5153 };
5154 self.emit_event(lifecycle::Phase::Started, category, None);
5155 let started = Instant::now();
5156 let outcome = self.write_inner(batch);
5157 self.detect_slow(started, category);
5158 match outcome {
5159 Ok(receipt) => {
5160 let rows = u64::try_from(batch.len()).unwrap_or(u64::MAX);
5161 if batch_is_admin(batch) {
5162 self.counters.record_admin();
5163 } else {
5164 self.counters.record_write(rows);
5165 }
5166 self.emit_event(lifecycle::Phase::Finished, category, None);
5167 Ok(receipt)
5168 }
5169 Err(err) => {
5170 let code = err.stable_code();
5171 self.counters.record_error(code);
5172 // AC-003d: capture-ordinal < raise-ordinal — Failed and Error
5173 // events both fire before the EngineError returns to the caller.
5174 self.emit_event(lifecycle::Phase::Failed, category, Some(code));
5175 self.emit_event(
5176 lifecycle::Phase::Failed,
5177 lifecycle::EventCategory::Error,
5178 Some(code),
5179 );
5180 Err(err)
5181 }
5182 }
5183 }
5184
5185 /// 0.8.20 Slice 20c (R-20-DR remainder) — **late enrolment**, the write-path
5186 /// half of the C4 rider.
5187 ///
5188 /// [`enqueue_declared_vector_backfill`] enrols the kinds the corpus held AT
5189 /// DECLARATION TIME. A kind first written AFTERWARDS would otherwise fall
5190 /// through [`project_canonical_node_row`]'s `kind_is_vector_indexed` gate
5191 /// straight onto a permanent `'up_to_date'` terminal and be silently,
5192 /// irrecoverably un-embedded — the identical false-ready barrier, reached by
5193 /// writing second instead of declaring second.
5194 ///
5195 /// **Gated on a usable dense runtime**, which is exactly why this lives on
5196 /// `Engine` and not inside the projector: with `EmbedderChoice::None` or a
5197 /// refused vector-equivalence guard there is no dense arm at all, so
5198 /// enrolling a kind would only queue embeds that cannot safely run. The
5199 /// declaration still PERSISTS without a usable runtime — it defers until a
5200 /// later safe open grafts it, or until an idempotent apply in an approved
5201 /// session. This mirrors the `run_vector_equivalence_probe` gate.
5202 ///
5203 /// Enrolment is an idempotent `INSERT OR IGNORE`, so running it on the writer
5204 /// connection just OUTSIDE the batch transaction is safe: if the batch then
5205 /// fails, the workspace is left having enrolled a kind for which no row
5206 /// exists — inert.
5207 ///
5208 /// fix-1 (codex §9 [P2]) — the `vector_projection_declared` probe below is
5209 /// what stops this path re-enrolling immediately after
5210 /// [`unenrol_registry_vector_node_kinds`] has run: the inverse removes the
5211 /// registry row, and with no active declaration this returns without
5212 /// re-adding it. (The kind registry DOES now have delete paths: that one, plus
5213 /// the Slice-21 fix-1 reconciliation
5214 /// [`reconcile_inert_vector_enrolments_on_boot`]. Both are gated on the SAME
5215 /// predicate this probe reads, so neither can be undone by a later write.)
5216 ///
5217 /// Cost: the registry probe is skipped entirely once the kind is enrolled, so
5218 /// a workspace with a live dense arm pays nothing; a workspace that never
5219 /// declared a vector projection pays two `prepare_cached` `EXISTS` probes per
5220 /// batch-row of an unenrolled kind. (Slice 21c / `TC-71` made that probe
5221 /// require the `searchable` ROLE, and deliberately kept the second `EXISTS`
5222 /// as a fast negative so this cost is unchanged — see
5223 /// [`vector_projection_declared`].)
5224 ///
5225 /// fix-2 (codex §9 [P2]) — a late enrolment now runs the SAME stranded-row
5226 /// treatment the declare-time door runs ([`reenqueue_stranded_vector_rows`]),
5227 /// and returns `true` iff that re-enqueued anything. Enrolling a kind while
5228 /// enqueueing ONLY the batch's own row left every earlier row of that kind
5229 /// holding its permanent `'up_to_date'` terminal with no vector, so once the
5230 /// new row drained readiness reported `ready` with pre-existing vector-eligible
5231 /// rows unembedded — a FALSE READY. Reached, for instance, by a database that
5232 /// persisted the declaration while opened WITHOUT an embedder and then reopened
5233 /// WITH one and wrote before re-applying the projection.
5234 ///
5235 /// fix-5 (codex §9 round 4 [P2]) — the registry INSERT and the un-stranding
5236 /// commit as ONE `BEGIN IMMEDIATE`…`COMMIT` (the shape
5237 /// [`rederive_projections_on_boot`] and
5238 /// [`reproject_search_index_after_tokenizer_upgrade`] already use). fix-2 ran
5239 /// them as two, and that window is not benign: a crash or a failed repair in
5240 /// between leaves the kind REGISTERED with the older rows still holding their
5241 /// `'up_to_date'` terminals and no vectors — and that state is SELF-SEALING,
5242 /// because `kind_is_vector_indexed` is then true, so every later write skips
5243 /// this path and therefore skips the repair, while readiness reads `ready` for
5244 /// rows nothing will ever embed. Only a manual re-apply of the projection
5245 /// recovers it. No marker table and no new recovery path: the two statements
5246 /// simply share a transaction.
5247 ///
5248 /// That transaction is opened on the writer connection just OUTSIDE the batch
5249 /// transaction: if the batch then fails, the workspace is left having enrolled
5250 /// a kind whose rows are correctly queued for the dense arm the registry does
5251 /// declare — inert, and self-healing on the next write or apply.
5252 fn enrol_batch_vector_kinds(
5253 &self,
5254 connection: &Connection,
5255 batch: &[PreparedWrite],
5256 ) -> Result<bool, EngineError> {
5257 if !self.usable_dense_runtime() {
5258 return Ok(false);
5259 }
5260 // READ-ONLY pre-pass. Nothing is written here, so the overwhelmingly
5261 // common case — every kind in the batch already enrolled, or no vector
5262 // projection declared at all — still pays only the probes it paid before
5263 // and never takes a write lock.
5264 let mut to_enrol: Vec<&str> = Vec::new();
5265 for write in batch {
5266 // Only `Node` writes: edge bodies enrol `'edge_fact'` themselves in
5267 // `project_canonical_edge_row` (G11), unconditionally and already.
5268 let PreparedWrite::Node { kind, .. } = write else { continue };
5269 if to_enrol.contains(&kind.as_str()) {
5270 continue;
5271 }
5272 if self.vector_kind_needs_enrolment(connection, kind, RowKind::Leaf)? {
5273 to_enrol.push(kind);
5274 }
5275 }
5276 if to_enrol.is_empty() {
5277 return Ok(false);
5278 }
5279 self.enrol_and_unstrand(connection, &to_enrol)
5280 }
5281
5282 /// 0.8.20 Slice 20c — would enrolling `kind` be correct here? The READ-ONLY
5283 /// half of a late enrolment; [`Engine::enrol_and_unstrand`] is the write half.
5284 /// The live-embedder precondition is the CALLER's (see
5285 /// [`Engine::enrol_batch_vector_kinds`]).
5286 fn vector_kind_needs_enrolment(
5287 &self,
5288 connection: &Connection,
5289 kind: &str,
5290 row_kind: RowKind,
5291 ) -> Result<bool, EngineError> {
5292 // `graph` rows are lexically searchable but NEVER embedded
5293 // (`index_targets_for_row_kind`), so they must not drag their kind into
5294 // the vector registry — that would start embedding every other row of
5295 // that kind.
5296 if !index_targets_for_row_kind(row_kind).vector {
5297 return Ok(false);
5298 }
5299 // fix-2 (codex §9 [P1]) — the SAME restriction the declare-time door
5300 // applies, from the SAME predicate, so the two cannot drift: a kind the
5301 // vector writer cannot commit must never be enrolled, or the projection
5302 // worker wedges on it forever. See [`kind_is_vector_committable`].
5303 if !kind_is_vector_committable(kind) {
5304 return Ok(false);
5305 }
5306 if kind_is_vector_indexed(connection, kind)? {
5307 return Ok(false);
5308 }
5309 if !vector_projection_declared(connection).map_err(|_| EngineError::Storage)? {
5310 return Ok(false);
5311 }
5312 Ok(true)
5313 }
5314
5315 /// 0.8.20 Slice 20c fix-5 (codex §9 round 4 [P2]) — the WRITE half of a LATE
5316 /// enrolment: register the kinds AND repair the rows they strand, in ONE
5317 /// transaction. Returns `true` iff the repair re-enqueued anything (the caller
5318 /// must then `notify_new_work()`, since those rows are outside its batch).
5319 ///
5320 /// Split out so both write-path doors ([`Engine::enrol_batch_vector_kinds`]
5321 /// and the `#[doc(hidden)]` `write_canonical_row_with_kind_for_test`) share it
5322 /// verbatim, and so neither can register a kind without owing the repair.
5323 ///
5324 /// `register_vector_kind` is `INSERT OR IGNORE` and
5325 /// [`reenqueue_stranded_vector_rows`] is idempotent, so the read-only pre-pass
5326 /// that chose `kinds` does not need re-validating under the write lock: the
5327 /// worst a stale decision costs is one no-op `MIN` probe.
5328 fn enrol_and_unstrand(
5329 &self,
5330 connection: &Connection,
5331 kinds: &[&str],
5332 ) -> Result<bool, EngineError> {
5333 connection.execute_batch("BEGIN IMMEDIATE").map_err(|_| EngineError::Storage)?;
5334 let result = (|| -> rusqlite::Result<bool> {
5335 for kind in kinds {
5336 register_vector_kind(connection, kind)?;
5337 }
5338 reenqueue_stranded_vector_rows(connection)
5339 })();
5340 match result {
5341 Ok(enqueued) => {
5342 connection.execute_batch("COMMIT").map_err(|_| EngineError::Storage)?;
5343 Ok(enqueued)
5344 }
5345 Err(_) => {
5346 let _ = connection.execute_batch("ROLLBACK");
5347 Err(EngineError::Storage)
5348 }
5349 }
5350 }
5351
5352 fn write_inner(&self, batch: &[PreparedWrite]) -> Result<WriteReceipt, EngineError> {
5353 self.ensure_open()?;
5354
5355 if batch.is_empty() {
5356 return Err(EngineError::WriteValidation);
5357 }
5358
5359 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5360 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
5361 let plans = validate_batch(connection, batch)?;
5362 validate_nested_projection_sources_for_write(connection, batch)?;
5363 // 0.8.20 Slice 20c (R-20-DR remainder) — LATE ENROLMENT, before
5364 // `collect_projection_jobs` reads the vector-kind registry to decide
5365 // whether the dispatcher needs waking. See
5366 // `Engine::enrol_batch_vector_kinds`.
5367 //
5368 // fix-2 (codex §9 [P2]) — the flag says the enrolment ALSO un-stranded
5369 // rows outside this batch. Those rows are not in `projection_jobs` (that
5370 // only walks the batch), so it is OR-ed into `pending_projection` below:
5371 // `drain` is a passive barrier and never a trigger (C4 rider), so work
5372 // enqueued without a wake would sit until the next unrelated write.
5373 let unstranded = self.enrol_batch_vector_kinds(connection, batch)?;
5374 let projection_jobs = collect_projection_jobs(connection, batch)?;
5375 #[cfg(debug_assertions)]
5376 if self.force_next_commit_failure.swap(false, Ordering::SeqCst) {
5377 return Err(EngineError::Storage);
5378 }
5379 // One cursor per row. `base_cursor` is the last committed cursor;
5380 // row i in the batch gets cursor `base_cursor + i + 1`, and the
5381 // batch's final cursor (returned in WriteReceipt and stored as
5382 // the new `next_cursor`) is `base_cursor + batch.len()`. Sharing
5383 // one cursor across the batch previously collapsed every vec0
5384 // INSERT onto the same rowid via `INSERT OR IGNORE` — see
5385 // `dev/notes/0.7.0-engine-batch-vec0-collapse.md`.
5386 let base_cursor = self.next_cursor.load(Ordering::SeqCst);
5387 let increment = u64::try_from(batch.len()).unwrap_or(u64::MAX);
5388 let last_cursor = base_cursor.saturating_add(increment);
5389 // G11 (Slice 15) — edge bodies also need projection-runtime notification.
5390 // `collect_projection_jobs` only tracks Node items (pre-fetched for
5391 // cursor assignment); edge bodies update `_fathomdb_projection_state` in
5392 // `commit_batch` but need the scanner to wake up via `notify_new_work`.
5393 let has_edge_body_work =
5394 batch.iter().any(|w| matches!(w, PreparedWrite::Edge { body: Some(_), .. }));
5395 let pending_projection = !projection_jobs.is_empty() || has_edge_body_work || unstranded;
5396
5397 let dangling_edge_endpoints = match commit_batch(
5398 connection,
5399 batch,
5400 &plans,
5401 base_cursor,
5402 self.provenance_row_cap.load(Ordering::Relaxed),
5403 ) {
5404 Ok(count) => count,
5405 Err(err) => {
5406 self.emit_sqlite_internal_error(&err);
5407 return Err(EngineError::Storage);
5408 }
5409 };
5410 self.next_cursor.store(last_cursor, Ordering::SeqCst);
5411 if pending_projection {
5412 self.projection_runtime.notify_new_work();
5413 }
5414
5415 // G0 — surface the per-row cursors (1:1 with input order). Row i got
5416 // `base_cursor + i + 1`, matching the allocation in `commit_batch`.
5417 let row_cursors = (0..batch.len())
5418 .map(|i| base_cursor.saturating_add((i as u64).saturating_add(1)))
5419 .collect();
5420 Ok(WriteReceipt { cursor: last_cursor, row_cursors, dangling_edge_endpoints })
5421 }
5422
5423 /// G11 (Slice 15) — BYO-LLM ingest: spawn an external extraction harness
5424 /// speaking the `fathomdb.extract.v1` NDJSON-over-stdio protocol, send
5425 /// documents for extraction, and write the resulting entities
5426 /// (→ `canonical_nodes`) and fact-edges (→ `canonical_edges` with G11
5427 /// enrichment columns) to the store.
5428 ///
5429 /// `cmd` is argv (first element = program, rest = args). Documents are
5430 /// batched per the harness's `max_docs_per_request`. Entity `logical_id`
5431 /// is derived as `sha256("<type>:<name>")` (lowercase, hex-encoded) for
5432 /// stable cross-re-ingestion identity. Edge `logical_id` is derived as
5433 /// `sha256("<from_lid>:<to_lid>:<relation>")`. Both are consistent with
5434 /// G0 supersession: re-ingesting the same document yields the same ids,
5435 /// triggering tombstone-then-insert rather than accumulation.
5436 ///
5437 /// Returns [`EngineError::Extractor`] on protocol errors (bad handshake,
5438 /// subprocess spawn failure, JSON decode error). `no_facts` warnings from
5439 /// the harness are not errors and do not affect the receipt counts.
5440 pub fn ingest_with_extractor(
5441 &self,
5442 cmd: &[&str],
5443 documents: &[ExtractDocument],
5444 ) -> Result<IngestWithExtractorReceipt, EngineError> {
5445 // 0.8.6 Slice 5 (ADR-0.8.6): the spawn + hello/ready handshake +
5446 // request_id framing + error mapping now live in the reusable
5447 // `provider_session` transport seam, parameterized by `ProviderTask`.
5448 // `ingest_with_extractor` is the thin extract caller: it opens a session
5449 // for `ProviderTask::Extract` then runs the extract-specific payload
5450 // build + DB writes. The session owns child reaping via Drop.
5451 let mut session = self.provider_session(ProviderTask::Extract, cmd)?;
5452 self.run_extract_session(&mut session, documents)
5453 }
5454
5455 /// 0.8.6 Slice 5 (ADR-0.8.6) — open a provider session: spawn the caller
5456 /// subprocess, run the `hello`/`ready` handshake for `task`, and negotiate
5457 /// `supported_tasks`. The transport (NDJSON over stdio, the detached stdout
5458 /// drainer, the bounded-recv timeout, the `request_id` framing, and the
5459 /// catch-all `EngineError::Extractor` mapping) is identical across tasks;
5460 /// only the protocol string (`fathomdb.<task>.v1`) and the negotiated task
5461 /// name differ. For `ProviderTask::Extract` the wire is byte-identical to the
5462 /// pre-0.8.6 `fathomdb.extract.v1` path.
5463 fn provider_session(
5464 &self,
5465 task: ProviderTask,
5466 cmd: &[&str],
5467 ) -> Result<ProviderSession, EngineError> {
5468 let (program, args) = cmd.split_first().ok_or(EngineError::Extractor)?;
5469 let mut child = Command::new(program)
5470 .args(args)
5471 .stdin(Stdio::piped())
5472 .stdout(Stdio::piped())
5473 .stderr(Stdio::inherit())
5474 .spawn()
5475 .map_err(|_| EngineError::Extractor)?;
5476
5477 let child_stdin = match child.stdin.take() {
5478 Some(s) => s,
5479 None => {
5480 let _ = child.kill();
5481 let _ = child.wait();
5482 return Err(EngineError::Extractor);
5483 }
5484 };
5485 let child_stdout = match child.stdout.take() {
5486 Some(s) => s,
5487 None => {
5488 let _ = child.kill();
5489 let _ = child.wait();
5490 return Err(EngineError::Extractor);
5491 }
5492 };
5493
5494 // fix-35 [P1/P2]: drain stdout on a dedicated thread so (a) every read can
5495 // be bounded with a timeout — a hung harness can no longer block ingest
5496 // forever — and (b) the child's stdout pipe is drained continuously,
5497 // preventing a large-request deadlock (parent blocked writing stdin while
5498 // the child blocks writing a full stdout pipe). The handle is detached:
5499 // joining could hang if a misbehaving child holds stdout open past its
5500 // stdin EOF, so the session's `Drop` (child.kill()) is what guarantees
5501 // thread exit.
5502 let io_timeout = extractor_io_timeout();
5503 let (line_tx, line_rx) = mpsc::channel::<std::io::Result<String>>();
5504 thread::spawn(move || {
5505 let mut reader = BufReader::new(child_stdout);
5506 loop {
5507 let mut buf = String::new();
5508 match reader.read_line(&mut buf) {
5509 Ok(0) => break,
5510 Ok(_) => {
5511 if line_tx.send(Ok(buf)).is_err() {
5512 break;
5513 }
5514 }
5515 Err(e) => {
5516 let _ = line_tx.send(Err(e));
5517 break;
5518 }
5519 }
5520 }
5521 });
5522
5523 let mut session = ProviderSession {
5524 task,
5525 child,
5526 writer: std::io::BufWriter::new(child_stdin),
5527 line_rx,
5528 io_timeout,
5529 model: None,
5530 max_docs_per_request: 8,
5531 };
5532 // On any handshake/negotiation error the session is dropped here, which
5533 // reaps the child (Drop) — matching the prior outer kill/wait semantics.
5534 session.handshake()?;
5535 Ok(session)
5536 }
5537
5538 /// 0.8.6 Slice 5 — extract-specific driver over a `ProviderSession`. The
5539 /// payload build (documents → entities/edges) and DB writes are byte-identical
5540 /// to the pre-0.8.6 inner loop; only the spawn/handshake/framing moved into
5541 /// the shared session.
5542 fn run_extract_session(
5543 &self,
5544 session: &mut ProviderSession,
5545 documents: &[ExtractDocument],
5546 ) -> Result<IngestWithExtractorReceipt, EngineError> {
5547 let extractor_model_id = session.model.clone();
5548 let max_docs = session.max_docs_per_request;
5549
5550 // --- per-batch extract → write loop ---
5551 let mut nodes_written: u64 = 0;
5552 let mut edges_written: u64 = 0;
5553 let docs_processed = documents.len() as u64;
5554
5555 for (batch_idx, batch) in documents.chunks(max_docs).enumerate() {
5556 let request_id = format!("req-{batch_idx}");
5557 let docs_json: Vec<Value> = batch
5558 .iter()
5559 .map(|d| {
5560 serde_json::json!({
5561 "source_doc_id": d.source_doc_id,
5562 "body": d.body,
5563 })
5564 })
5565 .collect();
5566
5567 // Send the framed extract request and receive its matching `result`.
5568 // The session adds protocol/type/request_id and validates the
5569 // type=="result" + matching request_id envelope (fix-24 [P2]).
5570 let result = session
5571 .request(&request_id, vec![("documents".to_string(), Value::Array(docs_json))])?;
5572
5573 // R-20-E2 (0.8.20 Slice 5c, design §4 item 10) — every row this batch
5574 // produces takes its provenance from the CALLER's
5575 // `ExtractDocument.source_doc_id`, NEVER from the model's echo of that
5576 // field. The echo is attacker-/error-controlled: a harness that omits
5577 // it used to yield rows with NULL `source_id`, which no
5578 // `excise_source` call can reach — the model could make a row
5579 // permanently un-erasable simply by dropping a key.
5580 //
5581 // `resolve_provenance` therefore admits the echo only as a SELECTOR
5582 // among ids the caller already supplied in THIS batch, and never as a
5583 // value:
5584 //
5585 // * single-document batch — attribution is unambiguous, so the
5586 // caller's id is used and the echo is ignored outright;
5587 // * multi-document batch — the echo must name one of the batch's
5588 // caller-supplied ids (the caller's own copy of the string is
5589 // then stored). An absent or unrecognised echo is a protocol
5590 // violation and fails the ingest LOUDLY with
5591 // `EngineError::Extractor`, because the alternative — guessing an
5592 // attribution — would silently mis-file the row under a document
5593 // whose erasure would then not remove it.
5594 let batch_provenance = batch
5595 .iter()
5596 .map(|d| SourceId::new(d.source_doc_id.clone()))
5597 .collect::<Result<Vec<_>, _>>()?;
5598 let resolve_provenance = |echo: Option<&str>| -> Result<SourceId, EngineError> {
5599 if let [only] = batch_provenance.as_slice() {
5600 return Ok(only.clone());
5601 }
5602 let echo = echo.ok_or(EngineError::Extractor)?;
5603 batch_provenance
5604 .iter()
5605 .find(|caller_id| caller_id.as_str() == echo)
5606 .cloned()
5607 .ok_or(EngineError::Extractor)
5608 };
5609
5610 // --- map entities → PreparedWrite::Node with stable logical_id ---
5611 let entities =
5612 result.get("entities").and_then(|v| v.as_array()).cloned().unwrap_or_default();
5613 let raw_edges =
5614 result.get("edges").and_then(|v| v.as_array()).cloned().unwrap_or_default();
5615
5616 // R3 (SCHEMA-GATE-1): collect substituted_t_valid values from
5617 // temporal_fallback warnings. An edge whose t_valid matches one of
5618 // these values had its event time defaulted to created_at (not
5619 // text-grounded) and must be flagged so BFS can exclude it.
5620 //
5621 // TC-33: kept as RAW `Value`s here and normalised below, together
5622 // with the edge side, through the SAME function. See the
5623 // normalisation block for why that is load-bearing.
5624 let raw_fallback_dates: Vec<&Value> = result
5625 .get("warnings")
5626 .and_then(|v| v.as_array())
5627 .map(|ws| {
5628 ws.iter()
5629 .filter(|w| {
5630 w.get("kind").and_then(|k| k.as_str()) == Some("temporal_fallback")
5631 })
5632 .filter_map(|w| w.get("substituted_t_valid"))
5633 .collect()
5634 })
5635 .unwrap_or_default();
5636
5637 if !entities.is_empty() {
5638 let node_batch: Vec<PreparedWrite> = entities
5639 .iter()
5640 .map(|entity| -> Result<PreparedWrite, EngineError> {
5641 let name = entity.get("name").and_then(|v| v.as_str()).unwrap_or("");
5642 let kind = entity.get("type").and_then(|v| v.as_str()).unwrap_or("entity");
5643 // R-20-E2: caller-grounded, echo used only as a selector.
5644 let source_doc_id = resolve_provenance(
5645 entity.get("source_doc_id").and_then(|v| v.as_str()),
5646 )?;
5647 // fix-34 [P1]: derive_logical_id now rejects an empty name
5648 // or a ':' in kind — inputs that would collide distinct
5649 // entities onto one identity and silently drop one.
5650 let logical_id = derive_logical_id(kind, name)?;
5651 Ok(PreparedWrite::Node {
5652 kind: kind.to_string(),
5653 body: name.to_string(),
5654 source_id: source_doc_id,
5655 logical_id: Some(logical_id),
5656 state: InitialState::Active,
5657 reason: None,
5658 valid_from: None,
5659 valid_until: None,
5660 })
5661 })
5662 .collect::<Result<Vec<_>, _>>()?;
5663
5664 // fix-29/fix-34 [P2]: deduplicate within the batch by logical_id so
5665 // a harness that returns the same entity twice does not write a row
5666 // that immediately supersedes its sibling (shared with the edge arm).
5667 let node_batch = dedup_prepared_by_logical_id(node_batch);
5668
5669 // fix-23 [P2]: skip entities whose logical_id is already active
5670 // to avoid needless supersede churn on re-ingest.
5671 let ids: Vec<String> = node_batch
5672 .iter()
5673 .filter_map(|w| {
5674 if let PreparedWrite::Node { logical_id: Some(id), .. } = w {
5675 Some(id.clone())
5676 } else {
5677 None
5678 }
5679 })
5680 .collect();
5681 let existing: std::collections::HashSet<String> = self
5682 // Internal existence probe: STRICT view — this must see
5683 // exactly the rows the pre-slice code saw.
5684 .read_get_many(&ids, &ReadView::default())?
5685 .into_iter()
5686 .zip(ids)
5687 .filter_map(|(opt, id)| opt.map(|_| id))
5688 .collect();
5689 let new_nodes: Vec<PreparedWrite> = node_batch
5690 .into_iter()
5691 .filter(|w| {
5692 if let PreparedWrite::Node { logical_id: Some(id), .. } = w {
5693 !existing.contains(id)
5694 } else {
5695 true
5696 }
5697 })
5698 .collect();
5699 if !new_nodes.is_empty() {
5700 let n = new_nodes.len() as u64;
5701 self.write(&new_nodes)?;
5702 nodes_written = nodes_written.saturating_add(n);
5703 }
5704 }
5705
5706 // --- map edges → PreparedWrite::Edge with G11 columns ---
5707 if !raw_edges.is_empty() {
5708 // fix-33 [P1]: the protocol gives edges NO endpoint types —
5709 // `from_entity`/`to_entity` reference entities BY NAME (or alias).
5710 // Build a name+alias → (canonical name, type) index from the same
5711 // result's `entities[]` so each endpoint's logical_id matches the
5712 // node's. (Nodes derive id from the entity's real type; defaulting
5713 // the edge endpoint kind to "entity" orphaned every contract-faithful
5714 // edge from its nodes and tripped the G8 dangling probe.)
5715 //
5716 // Two passes so a canonical NAME always wins over a (different
5717 // entity's) ALIAS regardless of `entities[]` order: pass 1 inserts
5718 // all canonical names, pass 2 fills aliases only where no name
5719 // already claims that key. (Name↔name clashes remain first-wins —
5720 // contradictory input; no principled resolution exists.)
5721 let mut entity_index: std::collections::HashMap<String, (String, String)> =
5722 std::collections::HashMap::new();
5723 for entity in &entities {
5724 let name = entity.get("name").and_then(|v| v.as_str()).unwrap_or("");
5725 if name.is_empty() {
5726 continue;
5727 }
5728 let kind =
5729 entity.get("type").and_then(|v| v.as_str()).unwrap_or("entity").to_string();
5730 entity_index
5731 .entry(name.to_lowercase())
5732 .or_insert_with(|| (name.to_string(), kind));
5733 }
5734 for entity in &entities {
5735 let name = entity.get("name").and_then(|v| v.as_str()).unwrap_or("");
5736 if name.is_empty() {
5737 continue;
5738 }
5739 let kind =
5740 entity.get("type").and_then(|v| v.as_str()).unwrap_or("entity").to_string();
5741 if let Some(aliases) = entity.get("aliases").and_then(|v| v.as_array()) {
5742 for alias in aliases.iter().filter_map(|a| a.as_str()) {
5743 if !alias.is_empty() {
5744 entity_index
5745 .entry(alias.to_lowercase())
5746 .or_insert_with(|| (name.to_string(), kind.clone()));
5747 }
5748 }
5749 }
5750 }
5751
5752 // TC-33 — normalise EVERY extractor timestamp here, in ONE pass,
5753 // under ONE connection lock, BEFORE any edge is built. Both the
5754 // edge side (`t_valid`/`t_invalid`) and the temporal_fallback
5755 // warning side (`substituted_t_valid`) go through the SAME
5756 // function, and any value that cannot be normalised HARD-REJECTS
5757 // the whole ingest.
5758 //
5759 // **Normalising both sides is load-bearing, and nothing would
5760 // have caught it.** `temporal_fallback` is decided by comparing
5761 // the edge's t_valid against the warnings' substituted_t_valid.
5762 // That was a RAW BYTE-FOR-BYTE STRING MATCH with
5763 // `.unwrap_or(false)` on the miss path, and `substituted_t_valid`
5764 // is a FREE-FORM JSON key on the ELPS warnings envelope, not a
5765 // Rust struct field. So normalising only the edge side would
5766 // leave the set never matching, `.unwrap_or(false)` firing, and
5767 // EVERY fallback edge silently becoming a TRUSTED edge — with no
5768 // compile error anywhere. That flag is the only thing excluding
5769 // untrustworthy-time edges from graph BFS and graph seeding.
5770 //
5771 // Normalising both sides also FIXES a pre-existing brittleness:
5772 // `2025-03-20T09:30:00Z` and `2025-03-20T09:30:00+00:00` are the
5773 // same instant but MISS each other under a byte comparison. They
5774 // now compare equal as epochs.
5775 //
5776 // A malformed `substituted_t_valid` rejects rather than being
5777 // skipped: skipping it would leave the edge unflagged, i.e.
5778 // treated as TRUSTED — the same fail-open in a different place.
5779 //
5780 // The lock is taken and released HERE; `self.write(...)` below
5781 // re-acquires it, so no lock is held across the write.
5782 // (t_valid, t_invalid) epoch pair per edge, in `raw_edges` order.
5783 type EdgeTimes = Vec<(Option<i64>, Option<i64>)>;
5784 let (edge_times, fallback_epochs): (EdgeTimes, std::collections::HashSet<i64>) = {
5785 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5786 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
5787
5788 let mut times = Vec::with_capacity(raw_edges.len());
5789 for edge in &raw_edges {
5790 times.push((
5791 normalize_extractor_timestamp(
5792 connection,
5793 "t_valid",
5794 edge.get("t_valid"),
5795 )?,
5796 normalize_extractor_timestamp(
5797 connection,
5798 "t_invalid",
5799 edge.get("t_invalid"),
5800 )?,
5801 ));
5802 }
5803
5804 let mut epochs = std::collections::HashSet::new();
5805 for raw in &raw_fallback_dates {
5806 if let Some(epoch) = normalize_extractor_timestamp(
5807 connection,
5808 "substituted_t_valid",
5809 Some(raw),
5810 )? {
5811 epochs.insert(epoch);
5812 }
5813 }
5814 (times, epochs)
5815 };
5816
5817 let edge_batch: Vec<PreparedWrite> = raw_edges
5818 .iter()
5819 .zip(&edge_times)
5820 .map(|(edge, &(t_valid, t_invalid))| -> Result<PreparedWrite, EngineError> {
5821 let from_entity =
5822 edge.get("from_entity").and_then(|v| v.as_str()).unwrap_or("");
5823 let to_entity =
5824 edge.get("to_entity").and_then(|v| v.as_str()).unwrap_or("");
5825 let relation =
5826 edge.get("relation").and_then(|v| v.as_str()).unwrap_or("related_to");
5827 let body = edge.get("body").and_then(|v| v.as_str()).map(str::to_string);
5828 // TC-33: `t_valid`/`t_invalid` were normalised (and any
5829 // malformed or non-string value hard-rejected) in the
5830 // pass above; they arrive here as epoch seconds.
5831 // fix-26 [P2]: validate confidence is in [0.0, 1.0] at the
5832 // protocol boundary; reject out-of-range values.
5833 let confidence = match edge.get("confidence").and_then(|v| v.as_f64()) {
5834 Some(c) if !(0.0..=1.0).contains(&c) => {
5835 return Err(EngineError::Extractor);
5836 }
5837 c => c,
5838 };
5839 // R-20-E2: caller-grounded, echo used only as a selector.
5840 let source_doc_id =
5841 resolve_provenance(edge.get("source_doc_id").and_then(|v| v.as_str()))?;
5842
5843 // fix-33 [P1]: resolve each endpoint via the entities[]
5844 // index (by name or alias) → the entity's canonical
5845 // (name, type); fall back to kind "entity" only for a truly
5846 // unlisted name (synthesized dangling endpoints ARE listed,
5847 // so this is the defensive path). derive_logical_id (fix-34)
5848 // still rejects an empty name / ':' in kind.
5849 let (from_name, from_kind) = entity_index
5850 .get(&from_entity.to_lowercase())
5851 .cloned()
5852 .unwrap_or_else(|| (from_entity.to_string(), "entity".to_string()));
5853 let (to_name, to_kind) = entity_index
5854 .get(&to_entity.to_lowercase())
5855 .cloned()
5856 .unwrap_or_else(|| (to_entity.to_string(), "entity".to_string()));
5857 let from_lid = derive_logical_id(&from_kind, &from_name)?;
5858 let to_lid = derive_logical_id(&to_kind, &to_name)?;
5859 let edge_key = format!("{from_lid}:{to_lid}:{relation}");
5860 let edge_lid = derive_logical_id("edge", &edge_key)?;
5861
5862 // TC-33: BOTH sides are now epochs from the SAME
5863 // normalisation, so this compares instants rather than
5864 // byte strings.
5865 let is_temporal_fallback =
5866 t_valid.is_some_and(|tv| fallback_epochs.contains(&tv));
5867 Ok(PreparedWrite::Edge {
5868 kind: relation.to_string(),
5869 from: from_lid,
5870 to: to_lid,
5871 source_id: source_doc_id,
5872 logical_id: Some(edge_lid),
5873 body,
5874 t_valid,
5875 t_invalid,
5876 confidence,
5877 extractor_model_id: extractor_model_id.clone(),
5878 temporal_fallback: if is_temporal_fallback { Some(true) } else { None },
5879 })
5880 })
5881 .collect::<Result<Vec<_>, _>>()?;
5882 // fix-34 [P2]: dedup edges by logical_id, mirroring the node arm
5883 // (fix-29) — a duplicate edge in one harness response would
5884 // otherwise write a row that immediately supersedes its sibling.
5885 let edge_batch = dedup_prepared_by_logical_id(edge_batch);
5886 let n = edge_batch.len() as u64;
5887 self.write(&edge_batch)?;
5888 edges_written = edges_written.saturating_add(n);
5889 }
5890 }
5891
5892 // The `ProviderSession` (and its writer/child) is dropped by the caller
5893 // when `ingest_with_extractor` returns: Drop sends stdin EOF and reaps
5894 // the child, matching the prior explicit drop(writer)+kill/wait.
5895 Ok(IngestWithExtractorReceipt { nodes_written, edges_written, docs_processed })
5896 }
5897
5898 /// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — BYO-LLM CONSOLIDATION / RECENCY.
5899 ///
5900 /// The SECOND consumer of the one `provider_session` transport (ADR-0.8.6):
5901 /// consolidation reuses the exact NDJSON-over-stdio transport, hello/ready
5902 /// handshake, `supported_tasks` negotiation, `request_id` framing, and
5903 /// bounded-recv timeout — only the protocol string
5904 /// (`fathomdb.consolidate.v1`) and the task-specific payload differ. There is
5905 /// NO second transport and NO second handshake.
5906 ///
5907 /// For each `(subject, relation)` axis, FathomDB assembles a candidate
5908 /// cluster of competing active fact-edges DETERMINISTICALLY (CPU-only, no
5909 /// LLM), sends it to the caller-supplied harness, and applies the returned
5910 /// verdicts. **CALLER-SIDE BYO-LLM**: the harness is the caller's subprocess;
5911 /// the library never embeds or calls an LLM and makes NO network egress.
5912 ///
5913 /// **Load-bearing semantic (ADR-0.8.12 §2.1):** consolidation records
5914 /// supersession / recency METADATA only — `invalidate` sets `t_invalid`,
5915 /// `supersede`/`merge` marks the row superseded via the existing G0 tombstone
5916 /// column. Edge BODIES are NEVER rewritten and NO row is ever deleted (the
5917 /// 0.8.3 lesson: blind content-merge HURT accuracy). The original rows
5918 /// survive; the engine stays deterministic.
5919 ///
5920 /// Returns [`EngineError::Consolidator`] on any transport/handshake/protocol
5921 /// fault or a malformed / out-of-cluster verdict.
5922 pub fn consolidate_with_provider(
5923 &self,
5924 cmd: &[&str],
5925 axes: &[ConsolidateAxis],
5926 ) -> Result<ConsolidateReceipt, EngineError> {
5927 // Reuse the shared transport verbatim; remap its (Extractor-flavoured)
5928 // transport error to the task-specific Consolidator leaf.
5929 let mut session = self
5930 .provider_session(ProviderTask::Consolidate, cmd)
5931 .map_err(|_| EngineError::Consolidator)?;
5932 self.run_consolidate_session(&mut session, axes)
5933 }
5934
5935 /// 0.8.12 Slice 15 — consolidate-specific driver over a `ProviderSession`.
5936 /// Mirrors [`run_extract_session`][Engine::run_extract_session]: assemble the
5937 /// task payload, run the framed request over the shared session, apply the
5938 /// task-specific DB effect. The cluster assembly + verdict application are
5939 /// CPU-only/deterministic.
5940 fn run_consolidate_session(
5941 &self,
5942 session: &mut ProviderSession,
5943 axes: &[ConsolidateAxis],
5944 ) -> Result<ConsolidateReceipt, EngineError> {
5945 let mut receipt = ConsolidateReceipt::default();
5946
5947 for (i, axis) in axes.iter().enumerate() {
5948 // 1. Deterministically assemble the candidate cluster (CPU-only, no LLM).
5949 let cluster = self.assemble_consolidate_cluster(axis)?;
5950 if cluster.is_empty() {
5951 continue;
5952 }
5953 receipt.clusters_processed = receipt.clusters_processed.saturating_add(1);
5954 receipt.edges_examined = receipt.edges_examined.saturating_add(cluster.len() as u64);
5955
5956 // 2. Send the cluster; receive the verdict envelope. The session adds
5957 // protocol/type/request_id and validates type=="result" + matching
5958 // request_id. Any transport/protocol fault → Consolidator.
5959 let request_id = format!("req-{i}");
5960 // TC-33: storage and `ConsolidateCandidateEdge` are INTEGER epoch
5961 // seconds, but the harness WIRE is ISO-8601 — the same split as the
5962 // extractor boundary. Render on the way out; the verdict's
5963 // `t_invalid` is normalised back on the way in. Without this the
5964 // harness would receive epoch integers and (since the reference stub
5965 // echoes the winner's `t_valid` straight back as `t_invalid`) its
5966 // reply would be rejected by our own inbound normaliser.
5967 let edges_json: Vec<Value> = {
5968 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5969 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
5970 // TC-33 fix-1 backstop [DEFENSIVE — unreachable]. A stored
5971 // `Some(ts)` that fails to render must NOT become a silent
5972 // `null`: that is exactly the "still valid" resurrection vector.
5973 // With `reject_unrenderable_edge_epoch` guarding the write
5974 // boundary, no unrenderable epoch can reach storage — so this is
5975 // a hard-assert upholding that invariant STRUCTURALLY, not the
5976 // primary defence. `None` (unknown) still renders to JSON null;
5977 // only a NON-NULL stored epoch that fails to render is an error.
5978 let render = |field: &str, value: Option<i64>| -> Result<Value, EngineError> {
5979 match value {
5980 None => Ok(Value::Null),
5981 Some(ts) => match epoch_seconds_to_iso8601(connection, ts) {
5982 Some(iso) => Ok(Value::from(iso)),
5983 None => Err(EngineError::InvalidArgument {
5984 msg: format!(
5985 "INVARIANT VIOLATION (TC-33 fix-1): stored edge `{field}` = \
5986 {ts} is unrenderable to ISO-8601 and would have gone to the \
5987 consolidation wire as a silent null (\"still valid\"). The \
5988 write boundary should have made this unstorable."
5989 ),
5990 }),
5991 },
5992 }
5993 };
5994 cluster
5995 .iter()
5996 .map(|e| {
5997 Ok::<Value, EngineError>(serde_json::json!({
5998 "edge_ref": e.edge_ref,
5999 "body": e.body,
6000 "t_valid": render("t_valid", e.t_valid)?,
6001 "t_invalid": render("t_invalid", e.t_invalid)?,
6002 "confidence": e.confidence,
6003 "source_doc_id": e.source_doc_id,
6004 "extractor_model_id": e.extractor_model_id,
6005 }))
6006 })
6007 .collect::<Result<Vec<Value>, EngineError>>()?
6008 };
6009 let cluster_json = serde_json::json!({
6010 "subject": axis.subject_logical_id,
6011 "relation": axis.relation,
6012 "edges": edges_json,
6013 });
6014 let result = session
6015 .request(&request_id, vec![("cluster".to_string(), cluster_json)])
6016 .map_err(|_| EngineError::Consolidator)?;
6017
6018 // 3. Apply the verdicts (metadata-only; original rows + bodies survive).
6019 let verdicts = result
6020 .get("verdicts")
6021 .and_then(|v| v.as_array())
6022 .ok_or(EngineError::Consolidator)?
6023 .clone();
6024 self.apply_consolidate_verdicts(&cluster, &verdicts, &mut receipt)?;
6025 }
6026
6027 Ok(receipt)
6028 }
6029
6030 /// 0.8.12 Slice 15 — assemble the competing fact-edge cluster for one
6031 /// `(subject, relation)` axis, deterministically, from active `canonical_edges`
6032 /// (`from_id = subject AND kind = relation AND superseded_at IS NULL`), ordered
6033 /// by `write_cursor` (stable insertion order). CPU-only; no network, no LLM.
6034 fn assemble_consolidate_cluster(
6035 &self,
6036 axis: &ConsolidateAxis,
6037 ) -> Result<Vec<ConsolidateCandidateEdge>, EngineError> {
6038 self.ensure_open()?;
6039 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
6040 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
6041 let mut stmt = connection
6042 .prepare(
6043 "SELECT logical_id, body, t_valid, t_invalid, confidence, source_id, \
6044 extractor_model_id \
6045 FROM canonical_edges \
6046 WHERE from_id = ?1 AND kind = ?2 AND superseded_at IS NULL \
6047 ORDER BY write_cursor",
6048 )
6049 .map_err(|_| EngineError::Storage)?;
6050 let rows = stmt
6051 .query_map(params![axis.subject_logical_id, axis.relation], |r| {
6052 Ok(ConsolidateCandidateEdge {
6053 edge_ref: r.get::<_, Option<String>>(0)?.unwrap_or_default(),
6054 body: r.get(1)?,
6055 t_valid: r.get(2)?,
6056 t_invalid: r.get(3)?,
6057 confidence: r.get(4)?,
6058 source_doc_id: r.get(5)?,
6059 extractor_model_id: r.get(6)?,
6060 })
6061 })
6062 .map_err(|_| EngineError::Storage)?;
6063 let out: rusqlite::Result<Vec<ConsolidateCandidateEdge>> = rows.collect();
6064 // Skip any edge with a NULL/empty logical_id (no stable ref to round-trip).
6065 Ok(out
6066 .map_err(|_| EngineError::Storage)?
6067 .into_iter()
6068 .filter(|e| !e.edge_ref.is_empty())
6069 .collect())
6070 }
6071
6072 /// 0.8.12 Slice 15 — apply the harness verdicts as METADATA-ONLY transitions
6073 /// (ADR-0.8.12 §2.1). NEVER rewrites a body, NEVER deletes a row. A verdict
6074 /// referencing an edge not in the presented cluster, or an unknown verdict
6075 /// kind, is a protocol fault → [`EngineError::Consolidator`].
6076 fn apply_consolidate_verdicts(
6077 &self,
6078 cluster: &[ConsolidateCandidateEdge],
6079 verdicts: &[Value],
6080 receipt: &mut ConsolidateReceipt,
6081 ) -> Result<(), EngineError> {
6082 let known: std::collections::HashSet<&str> =
6083 cluster.iter().map(|e| e.edge_ref.as_str()).collect();
6084 // fix-1 [P2] bijection: the verdict set must cover the presented cluster
6085 // EXACTLY — every presented edge ruled on, none ruled on twice.
6086 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
6087
6088 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
6089 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
6090 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
6091
6092 for v in verdicts {
6093 let edge_ref =
6094 v.get("edge_ref").and_then(|x| x.as_str()).ok_or(EngineError::Consolidator)?;
6095 // The harness may only rule on edges FathomDB presented in the cluster.
6096 if !known.contains(edge_ref) {
6097 return Err(EngineError::Consolidator);
6098 }
6099 // fix-1 [P2]: a repeated edge_ref is a protocol fault (not a bijection).
6100 if !seen.insert(edge_ref) {
6101 return Err(EngineError::Consolidator);
6102 }
6103 let verdict =
6104 v.get("verdict").and_then(|x| x.as_str()).ok_or(EngineError::Consolidator)?;
6105 // Look up the active edge's projection cursor BEFORE any UPDATE so a
6106 // supersede (which clears `superseded_at IS NULL`) can still find it.
6107 let active_cursor = Self::active_edge_write_cursor(&tx, edge_ref)?;
6108 match verdict {
6109 "keep" => {
6110 receipt.edges_kept = receipt.edges_kept.saturating_add(1);
6111 }
6112 "invalidate" => {
6113 // Recency metadata: set t_invalid; the row and its body are
6114 // left intact (this is NOT a destructive content rewrite).
6115 //
6116 // TC-33: the CONSOLIDATION harness is the same class of
6117 // BYO-LLM boundary as the extractor, so it carries ISO-8601
6118 // on the wire and is normalised here with the SAME hard
6119 // rejection. Previously the raw string went straight into the
6120 // UPDATE with no validation whatsoever.
6121 // fix-3 [P2]: consolidation is a BYO-LLM PROVIDER boundary, so
6122 // a malformed / non-string `t_invalid` is a PROVIDER protocol
6123 // fault → `Consolidator`, NOT the extractor/user `InvalidArgument`
6124 // that `normalize_extractor_timestamp` emits. Remap it to match
6125 // the two sibling failure modes on this same value (missing key
6126 // and null/unparseable-to-None, both `Consolidator`). Consistent,
6127 // not a diagnostic loss: `Consolidator` is a unit variant and the
6128 // adjacent `.ok_or(EngineError::Consolidator)` cases already
6129 // discard any message.
6130 let ts = normalize_extractor_timestamp(
6131 &tx,
6132 "t_invalid",
6133 Some(v.get("t_invalid").ok_or(EngineError::Consolidator)?),
6134 )
6135 .map_err(|_| EngineError::Consolidator)?
6136 .ok_or(EngineError::Consolidator)?;
6137 tx.execute(
6138 "UPDATE canonical_edges SET t_invalid = ?1 \
6139 WHERE logical_id = ?2 AND superseded_at IS NULL",
6140 params![ts, edge_ref],
6141 )
6142 .map_err(|_| EngineError::Storage)?;
6143 // fix-1 [P1]: prune the STATIC projection shadow rows so the
6144 // consolidated-away edge stops surfacing in FTS/vector — but
6145 // ONLY when the edge is ended as of the engine's "now",
6146 // mirroring the graph-traversal filter `edge_validity_sql`.
6147 // A future-dated t_invalid keeps the edge valid ⇒ keep the
6148 // projection. NON-DESTRUCTIVE: the canonical_edges row + body
6149 // survive (ADR-0.8.12 §2.1).
6150 //
6151 // TC-33: this used to be `SELECT datetime(?1) <= datetime('now')`
6152 // — an inline clock, AND a misleading error class: junk made
6153 // the SELECT yield SQL NULL, so `r.get::<bool>` failed as
6154 // `EngineError::Storage`. Both timestamps are integers now, so
6155 // the comparison is plain Rust against the bound `:now` seam.
6156 if let Some(cursor) = active_cursor {
6157 let ended = ts <= current_epoch_seconds();
6158 if ended {
6159 // fix-2 [P2]: KEEP the projection terminal row. The
6160 // canonical_edges row stays NON-superseded (invalidate
6161 // is metadata-only), and `database_has_pending_projection_work`
6162 // flags any non-superseded edge that has a body but no
6163 // terminal as pending; since `next_pending_projection_jobs`
6164 // only scans cursors ABOVE the stored projection cursor, an
6165 // already-projected invalidated edge would never be requeued
6166 // and `drain()`/`wait_for_idle` would hang forever. Dropping
6167 // the FTS/vec shadows (below) hides it from active retrieval;
6168 // retaining the terminal keeps the scheduler idle.
6169 Self::prune_edge_projection_shadows(&tx, cursor, true)?;
6170 }
6171 }
6172 receipt.edges_invalidated = receipt.edges_invalidated.saturating_add(1);
6173 }
6174 // `merge` maps cleanly to supersede + metadata (ADR-0.8.12 §3):
6175 // the loser is marked superseded; the winner ("by"/"into") is the
6176 // surviving active row. No body is merged.
6177 "supersede" | "merge" => {
6178 // Mark superseded via the existing G0 tombstone column; the row
6179 // survives (invalidate-not-delete). Use a fresh monotonic cursor.
6180 let cursor = self.next_cursor.fetch_add(1, Ordering::SeqCst).saturating_add(1);
6181 tx.execute(
6182 "UPDATE canonical_edges SET superseded_at = ?1 \
6183 WHERE logical_id = ?2 AND superseded_at IS NULL",
6184 params![cursor, edge_ref],
6185 )
6186 .map_err(|_| EngineError::Storage)?;
6187 // fix-1 [P1]: a superseded edge is unconditionally out of the
6188 // active set (graph traversal filters `superseded_at IS NULL`),
6189 // so prune its FTS/vector projection shadow rows to match.
6190 if let Some(active_cursor) = active_cursor {
6191 // A superseded row is excluded from the pending-work check
6192 // (`superseded_at IS NOT NULL`), so dropping its terminal too
6193 // is safe (matches the excise pattern) and cannot phantom-pend.
6194 Self::prune_edge_projection_shadows(&tx, active_cursor, false)?;
6195 }
6196 receipt.edges_superseded = receipt.edges_superseded.saturating_add(1);
6197 }
6198 _ => return Err(EngineError::Consolidator),
6199 }
6200 }
6201
6202 // fix-1 [P2]: bijection completeness — every presented cluster edge must
6203 // have received exactly one verdict.
6204 if seen.len() != known.len() {
6205 return Err(EngineError::Consolidator);
6206 }
6207
6208 tx.commit().map_err(|_| EngineError::Storage)?;
6209 Ok(())
6210 }
6211
6212 /// fix-1 [P1] — the active (non-superseded) row's projection `write_cursor`
6213 /// for a fact-edge `logical_id`, or `None` if there is no active row. The
6214 /// cursor keys the STATIC projection shadow rows (FTS `search_index_edges`,
6215 /// vec0 `vector_default` by rowid, `_fathomdb_vector_rows`,
6216 /// `_fathomdb_projection_terminal`).
6217 fn active_edge_write_cursor(
6218 tx: &rusqlite::Transaction<'_>,
6219 edge_ref: &str,
6220 ) -> Result<Option<i64>, EngineError> {
6221 tx.query_row(
6222 "SELECT write_cursor FROM canonical_edges \
6223 WHERE logical_id = ?1 AND superseded_at IS NULL",
6224 params![edge_ref],
6225 |r| r.get::<_, i64>(0),
6226 )
6227 .optional()
6228 .map_err(|_| EngineError::Storage)
6229 }
6230
6231 /// fix-1 [P1] — prune the STATIC projection shadow rows for a canonical
6232 /// row's `write_cursor` so a consolidated-away edge stops surfacing in
6233 /// FTS/vector retrieval. Mirrors the excision pattern at
6234 /// `excise_source_inner` (invalidate-not-delete: the canonical row + body
6235 /// are NEVER touched here).
6236 ///
6237 /// `keep_terminal` retains the `_fathomdb_projection_terminal` marker — set it
6238 /// when the canonical row stays NON-superseded (an `invalidate` verdict), so the
6239 /// projection scheduler still treats the cursor as done (fix-2 [P2]); clear it
6240 /// when the row is superseded (excluded from the pending-work scan anyway).
6241 ///
6242 /// FIXED (0.8.12 Slice A, R-CON-2 named default-ON blocker; Slice-20 codex
6243 /// §9 [P2]): a full `rebuild_projections` re-projects every non-superseded
6244 /// edge with a body from `canonical_edges` — this used to re-materialise an
6245 /// invalidated edge's FTS/vec shadows even though graph traversal excludes
6246 /// it via the `t_invalid > now` filter. The FTS rebuild SELECT
6247 /// (`rebuild_shadow_state`), the vec projection queue
6248 /// (`next_pending_projection_jobs`), and the pending-work probe
6249 /// (`database_has_pending_projection_work`) now all carry the same
6250 /// `edge_validity_sql` filter as graph traversal (TC-33: INTEGER compare
6251 /// against the bound `:now`, formerly `datetime(t_invalid) > datetime('now')`),
6252 /// so a rebuild is durable across the recency exclusion.
6253 fn prune_edge_projection_shadows(
6254 tx: &rusqlite::Transaction<'_>,
6255 cursor: i64,
6256 keep_terminal: bool,
6257 ) -> Result<(), EngineError> {
6258 tx.execute("DELETE FROM search_index_edges WHERE write_cursor = ?1", [cursor])
6259 .map_err(|_| EngineError::Storage)?;
6260 // vec0 rowid is the canonical row's write_cursor. TC-76: via the one
6261 // vec0-delete primitive ([`delete_vector_partition_row`]).
6262 delete_vector_partition_row(tx, cursor).map_err(|_| EngineError::Storage)?;
6263 tx.execute("DELETE FROM _fathomdb_vector_rows WHERE write_cursor = ?1", [cursor])
6264 .map_err(|_| EngineError::Storage)?;
6265 if !keep_terminal {
6266 tx.execute(
6267 "DELETE FROM _fathomdb_projection_terminal WHERE write_cursor = ?1",
6268 [cursor],
6269 )
6270 .map_err(|_| EngineError::Storage)?;
6271 }
6272 Ok(())
6273 }
6274
6275 pub fn search(&self, query: &str) -> Result<SearchResult, EngineError> {
6276 self.search_with_limit(query, DEFAULT_SEARCH_RESULT_LIMIT)
6277 }
6278
6279 /// Hybrid search with an explicit ranked-result limit in `1..=100`.
6280 pub fn search_with_limit(
6281 &self,
6282 query: &str,
6283 limit: usize,
6284 ) -> Result<SearchResult, EngineError> {
6285 self.search_filtered_with_limit(query, None, limit)
6286 }
6287
6288 /// 0.8.20 Slice 15b fix-2 (R-20-NV / R-20-RV) — `search` under an explicit
6289 /// [`ReadView`], the escape hatch matching the one the five read verbs got in
6290 /// Slice 10b. `search(query)` is exactly `search_view(query, &ReadView::default())`.
6291 ///
6292 /// **Scope: the VALIDITY axis only.** `include_out_of_window` and
6293 /// `valid_as_of` are honoured; the EXISTENCE flags (`include_superseded`,
6294 /// `include_inactive`) are **refused** with
6295 /// [`EngineError::InvalidArgument`] rather than silently ignored. Relaxing
6296 /// `superseded_at IS NULL` on a retrieval path would resurrect the stale-body
6297 /// leak the Slice-15 fix-1 review closed, and search hydrates from projection
6298 /// indexes (`search_index`, `vector_default`) that are not version-complete —
6299 /// so "include superseded" has no truthful answer here. Refusing says that;
6300 /// ignoring would be the dead surface this fix exists to remove.
6301 ///
6302 /// Governed surface: PROPOSED / NOT SIGNED (0.8.20 Slice 15b fix-2).
6303 pub fn search_view(&self, query: &str, view: &ReadView) -> Result<SearchResult, EngineError> {
6304 self.search_view_with_limit(query, view, DEFAULT_SEARCH_RESULT_LIMIT)
6305 }
6306
6307 /// Hybrid search under a [`ReadView`] with an explicit ranked-result limit.
6308 pub fn search_view_with_limit(
6309 &self,
6310 query: &str,
6311 view: &ReadView,
6312 limit: usize,
6313 ) -> Result<SearchResult, EngineError> {
6314 self.search_reranked_view_with_limit(query, None, 0, false, 0.3, 0, false, view, limit)
6315 }
6316
6317 /// 0.8.20 Slice 15b fix-2 (R-20-NV / R-20-RV) — the FULL-arity view entry
6318 /// point: [`search_reranked`][Engine::search_reranked] /
6319 /// [`search_explained`][Engine::search_explained] under an explicit
6320 /// [`ReadView`]. This is what the Python and TypeScript `search(..., view=)`
6321 /// bindings call, so a caller can combine a content filter, the CE knobs and
6322 /// a validity view in one query — passing `view` must not silently disable
6323 /// the filter, and passing a filter must not silently disable `view`.
6324 ///
6325 /// `search_reranked(q, f, d, g, a, p)` is exactly
6326 /// `search_reranked_view(q, f, d, g, a, p, false, &ReadView::default())`.
6327 ///
6328 /// Validity axis only; existence flags are refused. See
6329 /// [`search_view`][Engine::search_view].
6330 ///
6331 /// Governed surface: PROPOSED / NOT SIGNED (0.8.20 Slice 15b fix-2).
6332 #[allow(clippy::too_many_arguments)] // mirrors search_explained + the view
6333 pub fn search_reranked_view(
6334 &self,
6335 query: &str,
6336 filter: Option<SearchFilter>,
6337 rerank_depth: usize,
6338 use_graph_arm: bool,
6339 alpha: f64,
6340 pool_n: usize,
6341 explain: bool,
6342 view: &ReadView,
6343 ) -> Result<SearchResult, EngineError> {
6344 self.search_reranked_view_with_limit(
6345 query,
6346 filter,
6347 rerank_depth,
6348 use_graph_arm,
6349 alpha,
6350 pool_n,
6351 explain,
6352 view,
6353 DEFAULT_SEARCH_RESULT_LIMIT,
6354 )
6355 }
6356
6357 /// Full-arity hybrid search under a [`ReadView`] with an explicit ranked-result limit.
6358 #[allow(clippy::too_many_arguments)]
6359 pub fn search_reranked_view_with_limit(
6360 &self,
6361 query: &str,
6362 filter: Option<SearchFilter>,
6363 rerank_depth: usize,
6364 use_graph_arm: bool,
6365 alpha: f64,
6366 pool_n: usize,
6367 explain: bool,
6368 view: &ReadView,
6369 limit: usize,
6370 ) -> Result<SearchResult, EngineError> {
6371 let limit = validate_search_result_limit(limit)?;
6372 self.search_reranked_with_explain(
6373 query,
6374 filter,
6375 rerank_depth,
6376 use_graph_arm,
6377 alpha,
6378 pool_n,
6379 explain,
6380 *view,
6381 limit,
6382 )
6383 }
6384
6385 /// G10 — hybrid `search` with an optional closed [`SearchFilter`]. `None`
6386 /// (or an all-`None` filter) is the unfiltered path whose phase-1 SQL is
6387 /// byte-identical to 0.7.2. The filter prunes the vector branch in the
6388 /// single phase-1 candidates statement and constrains the text branch by the
6389 /// same metadata. Ranking is the unconditional G9 RRF fusion.
6390 pub fn search_filtered(
6391 &self,
6392 query: &str,
6393 filter: Option<SearchFilter>,
6394 ) -> Result<SearchResult, EngineError> {
6395 self.search_filtered_with_limit(query, filter, DEFAULT_SEARCH_RESULT_LIMIT)
6396 }
6397
6398 /// Hybrid search with an optional [`SearchFilter`] and explicit ranked-result limit.
6399 pub fn search_filtered_with_limit(
6400 &self,
6401 query: &str,
6402 filter: Option<SearchFilter>,
6403 limit: usize,
6404 ) -> Result<SearchResult, EngineError> {
6405 // 0.8.11 Slice 40 (R-FIL-2): re-express the shipped G10 `SearchFilter`
6406 // sugar through the unified `Filter` type, then lower back to the vec0
6407 // backend's `SearchFilter` (D4). The round-trip is lossless +
6408 // canonical-order-preserving, so the produced phase-1 SQL stays
6409 // byte-identical to 0.7.2 on the `None`/all-`None` path. `SearchFilter`
6410 // never carries a `Json` term, so `to_search_filter` never rejects here.
6411 //
6412 // 0.8.20 Slice 15e — the unified `Filter`/`FilterTerm` grammar does not yet
6413 // carry `filterable`-attribute terms (a later slice adds that surface), so
6414 // the round-trip would drop `SearchFilter.attributes`. Carry them across
6415 // explicitly: they already route pre-KNN through `vector_filter_clause`.
6416 let lowered = filter
6417 .map(|mut sf| {
6418 let attributes = sf.attributes.clone();
6419 // Attribute predicates are intentionally absent from the unified
6420 // grammar, but this legacy/hybrid entry point owns their existing
6421 // pre-KNN lowering. Remove them only for the metadata round-trip,
6422 // then restore them on its `SearchFilter` output.
6423 sf.attributes.clear();
6424 Filter::try_from(&sf).and_then(|filter| {
6425 filter.to_search_filter().map(|mut lo| {
6426 lo.attributes = attributes;
6427 lo
6428 })
6429 })
6430 })
6431 .transpose()?;
6432 // FIX-6: delegate to search_reranked(depth=0, use_graph_arm=false) to eliminate the
6433 // ~26-line duplicate body that would otherwise drift with search_reranked.
6434 // 0.8.5: depth=0 is inert, so the α/pool_n defaults (0.3, 0) never reach the blend.
6435 self.search_reranked_with_limit(query, lowered, 0, false, 0.3, 0, limit)
6436 }
6437
6438 /// 0.8.11 Slice 40 (#17) — unified-`Filter` entry point for the vec0 search
6439 /// backend. Lowers the metadata subset to the indexed pre-KNN `WHERE` and
6440 /// **typed-rejects** a [`FilterTerm::Json`] term with
6441 /// [`EngineError::InvalidFilter`] (D3 no-demotion guarantee). This is the
6442 /// unified surface the 0.8.15 router `constraints` block reasons over; the
6443 /// shipped [`Engine::search_filtered`]`(query, Option<SearchFilter>)` stays
6444 /// as sugar over the same path.
6445 pub fn search_filter(&self, query: &str, filter: &Filter) -> Result<SearchResult, EngineError> {
6446 self.search_filter_with_limit(query, filter, DEFAULT_SEARCH_RESULT_LIMIT)
6447 }
6448
6449 /// Unified-filter hybrid search with an explicit ranked-result limit.
6450 pub fn search_filter_with_limit(
6451 &self,
6452 query: &str,
6453 filter: &Filter,
6454 limit: usize,
6455 ) -> Result<SearchResult, EngineError> {
6456 let sf = filter.to_search_filter()?;
6457 self.search_reranked_with_limit(query, Some(sf), 0, false, 0.3, 0, limit)
6458 }
6459
6460 /// 0.8.1 Slice 10 (R1) / Slice 30 (R3) — `search_reranked`: hybrid search
6461 /// with optional CE reranking and optional graph-BFS third arm. `rerank_depth
6462 /// = 0` is the identity (soft-fallback) path, byte-identical to
6463 /// [`search_filtered`][Engine::search_filtered]. `rerank_depth = N > 0`
6464 /// applies the cross-encoder over the top-N fused hits (when the
6465 /// `default-reranker` feature is enabled and the model is loaded); without the
6466 /// model, the call falls back to the fused order.
6467 ///
6468 /// `use_graph_arm = false` (the default) produces byte-identical results to
6469 /// the pre-Slice-30 two-arm pipeline. `use_graph_arm = true` seeds a BFS over
6470 /// temporal fact-edges from the top-10 fused hits and fuses the reachable
6471 /// nodes as a third RRF arm.
6472 ///
6473 /// Governed surface: re-exported from `fathomdb` facade.
6474 pub fn search_reranked(
6475 &self,
6476 query: &str,
6477 filter: Option<SearchFilter>,
6478 rerank_depth: usize,
6479 use_graph_arm: bool,
6480 alpha: f64,
6481 pool_n: usize,
6482 ) -> Result<SearchResult, EngineError> {
6483 self.search_reranked_with_limit(
6484 query,
6485 filter,
6486 rerank_depth,
6487 use_graph_arm,
6488 alpha,
6489 pool_n,
6490 DEFAULT_SEARCH_RESULT_LIMIT,
6491 )
6492 }
6493
6494 /// Hybrid search with optional reranking and an explicit ranked-result limit.
6495 #[allow(clippy::too_many_arguments)]
6496 pub fn search_reranked_with_limit(
6497 &self,
6498 query: &str,
6499 filter: Option<SearchFilter>,
6500 rerank_depth: usize,
6501 use_graph_arm: bool,
6502 alpha: f64,
6503 pool_n: usize,
6504 limit: usize,
6505 ) -> Result<SearchResult, EngineError> {
6506 let limit = validate_search_result_limit(limit)?;
6507 // explain=false → `SearchResult.explanation == None`, byte-identical results.
6508 self.search_reranked_with_explain(
6509 query,
6510 filter,
6511 rerank_depth,
6512 use_graph_arm,
6513 alpha,
6514 pool_n,
6515 false,
6516 ReadView::default(),
6517 limit,
6518 )
6519 }
6520
6521 /// 0.8.8 EXP-OBS (Slice 5) — `search_explained`: the opt-in `explain=true`
6522 /// surface. Identical retrieval to [`search_reranked`][Engine::search_reranked]
6523 /// (same fused/CE ranking, same `results`), additionally returning a
6524 /// [`Explanation`] sidecar on `SearchResult.explanation` with per-hit arm
6525 /// provenance + score breakdown + a query-level [`QueryTrace`]. The default
6526 /// `search`/`search_filtered`/`search_reranked` paths are unaffected and stay
6527 /// byte-identical (R-OBS-2).
6528 ///
6529 /// Governed surface: re-exported from `fathomdb` facade.
6530 pub fn search_explained(
6531 &self,
6532 query: &str,
6533 filter: Option<SearchFilter>,
6534 rerank_depth: usize,
6535 use_graph_arm: bool,
6536 alpha: f64,
6537 pool_n: usize,
6538 ) -> Result<SearchResult, EngineError> {
6539 self.search_explained_with_limit(
6540 query,
6541 filter,
6542 rerank_depth,
6543 use_graph_arm,
6544 alpha,
6545 pool_n,
6546 DEFAULT_SEARCH_RESULT_LIMIT,
6547 )
6548 }
6549
6550 /// Explained hybrid search with an explicit ranked-result limit.
6551 #[allow(clippy::too_many_arguments)]
6552 pub fn search_explained_with_limit(
6553 &self,
6554 query: &str,
6555 filter: Option<SearchFilter>,
6556 rerank_depth: usize,
6557 use_graph_arm: bool,
6558 alpha: f64,
6559 pool_n: usize,
6560 limit: usize,
6561 ) -> Result<SearchResult, EngineError> {
6562 let limit = validate_search_result_limit(limit)?;
6563 self.search_reranked_with_explain(
6564 query,
6565 filter,
6566 rerank_depth,
6567 use_graph_arm,
6568 alpha,
6569 pool_n,
6570 true,
6571 ReadView::default(),
6572 limit,
6573 )
6574 }
6575
6576 /// 0.8.18 Slice 5 (#5 vector-equivalence probe, R-VEQ-4) — the explicit
6577 /// **text-only / FTS-only** search path. It does NOT embed the query and does
6578 /// NOT route through the vector-dependent choke point
6579 /// [`search_inner_with_stats`][Engine::search_inner_with_stats], so it NEVER
6580 /// raises [`EngineError::VectorEquivalenceMismatch`] and stays serviceable when
6581 /// the engine opened in the degraded `dense_disabled` state (the D2 "keep FTS
6582 /// servable" contract; codex R2 U1-2). Results come from the node- and
6583 /// edge-body FTS branches only — no vector recall, no CE rerank, no graph arm.
6584 /// Available regardless of degraded state; when dense is healthy it is simply a
6585 /// text-only view of the same corpus. Matching node- and edge-body
6586 /// candidates are body-deduplicated and deterministically ranked before
6587 /// the requested result limit is applied.
6588 ///
6589 /// Governed surface: re-exported from the `fathomdb` facade + Py/TS bindings.
6590 pub fn search_text_only(&self, query: &str) -> Result<SearchResult, EngineError> {
6591 self.search_text_only_with_limit(query, DEFAULT_SEARCH_RESULT_LIMIT)
6592 }
6593
6594 /// Text-only search with an explicit ranked-result limit in `1..=100`.
6595 ///
6596 /// For the same immutable selection and effective validity time, a smaller
6597 /// limit's ordered results are the prefix of a larger limit's results.
6598 pub fn search_text_only_with_limit(
6599 &self,
6600 query: &str,
6601 limit: usize,
6602 ) -> Result<SearchResult, EngineError> {
6603 self.search_text_only_view_with_limit(query, &ReadView::default(), limit)
6604 }
6605
6606 /// 0.8.20 Slice 15b fix-2 (R-20-NV / R-20-RV) — [`search_text_only`][Engine::search_text_only]
6607 /// under an explicit [`ReadView`]. Same validity-axis-only scope, and the same
6608 /// typed refusal of the existence flags, as [`search_view`][Engine::search_view].
6609 ///
6610 /// Governed surface: PROPOSED / NOT SIGNED (0.8.20 Slice 15b fix-2).
6611 pub fn search_text_only_view(
6612 &self,
6613 query: &str,
6614 view: &ReadView,
6615 ) -> Result<SearchResult, EngineError> {
6616 self.search_text_only_view_with_limit(query, view, DEFAULT_SEARCH_RESULT_LIMIT)
6617 }
6618
6619 /// Text-only search under a [`ReadView`] with an explicit ranked-result limit.
6620 ///
6621 /// For the same immutable selection and explicit effective validity time, a
6622 /// smaller limit's ordered results are the prefix of a larger limit's
6623 /// results. `ReadView::valid_as_of = None` resolves independently per call,
6624 /// so callers comparing calls must provide a fixed value.
6625 pub fn search_text_only_view_with_limit(
6626 &self,
6627 query: &str,
6628 view: &ReadView,
6629 limit: usize,
6630 ) -> Result<SearchResult, EngineError> {
6631 let limit = validate_search_result_limit(limit)?;
6632 self.ensure_open()?;
6633 view.reject_existence_relaxation_on_search()?;
6634 if query.trim().is_empty() {
6635 return Err(EngineError::WriteValidation);
6636 }
6637 let compiled = compile_text_query(query);
6638 let candidate_limit =
6639 self.projection_runtime.shared.search_limit_override.load(Ordering::SeqCst).max(limit);
6640 let (response_tx, response_rx) = mpsc::sync_channel::<ReaderResponse>(1);
6641 // This explicit marker distinguishes direct text-only search from a hybrid
6642 // request whose embedder yields no vector. Only the direct path gets the
6643 // fixed node candidate bound before node/edge body deduplication and RRF.
6644 let request = ReaderRequest::Search {
6645 compiled,
6646 query_vector: None,
6647 query_vector_bin: None,
6648 result_limit: limit,
6649 candidate_limit,
6650 direct_text_candidate_limit: Some(MAX_SEARCH_RESULT_LIMIT),
6651 filter: None,
6652 recency_enabled: false,
6653 importance_enabled: false,
6654 vector_stage_only: false,
6655 raw_query: Box::from(query),
6656 rerank_depth: 0,
6657 use_graph_arm: false,
6658 alpha: 0.3,
6659 pool_n: 0,
6660 explain: false,
6661 view: *view,
6662 respond: response_tx,
6663 };
6664 if self.reader_pool.dispatch(request).is_err() {
6665 return Err(EngineError::Closing);
6666 }
6667 let search_result = response_rx.recv().map_err(|_| EngineError::Storage)?;
6668 let (cursor, soft_fallback, results, _graph_stats, explanation) = match search_result {
6669 Ok(result) => result,
6670 // fix-3 (codex §9 [P2]) — carry the reader-snapshot validation verdict
6671 // through: an undeclared `filterable` attribute is the EXISTING typed
6672 // `InvalidFilter`, never collapsed to `Storage`. (This path takes
6673 // `filter = None`, so it never fires here, but the match stays total.)
6674 Err(SearchReaderError::InvalidFilter(reason)) => {
6675 return Err(EngineError::InvalidFilter { reason });
6676 }
6677 Err(SearchReaderError::Sqlite(err)) => {
6678 self.emit_sqlite_internal_error(&err);
6679 return Err(EngineError::Storage);
6680 }
6681 };
6682 Ok(SearchResult { projection_cursor: cursor, soft_fallback, results, explanation })
6683 }
6684
6685 /// Search one declared `searchable→FTS` projection without invoking body
6686 /// search, vector search, score fusion, or a fallback arm. Results carry the
6687 /// ordinary text branch shape and are ordered by property-FTS bm25 ascending
6688 /// then write cursor ascending.
6689 pub fn search_projected_text(
6690 &self,
6691 query: &str,
6692 name: &str,
6693 filter: Option<SearchFilter>,
6694 view: &ReadView,
6695 ) -> Result<SearchResult, EngineError> {
6696 self.search_projected_text_with_limit(
6697 query,
6698 name,
6699 filter,
6700 view,
6701 DEFAULT_SEARCH_RESULT_LIMIT,
6702 )
6703 }
6704
6705 /// Search one declared property-FTS projection with an explicit ranked-result limit.
6706 pub fn search_projected_text_with_limit(
6707 &self,
6708 query: &str,
6709 name: &str,
6710 filter: Option<SearchFilter>,
6711 view: &ReadView,
6712 limit: usize,
6713 ) -> Result<SearchResult, EngineError> {
6714 let limit = validate_search_result_limit(limit)?;
6715 self.ensure_open()?;
6716 view.reject_existence_relaxation_on_search()?;
6717 if query.trim().is_empty() {
6718 return Err(EngineError::WriteValidation);
6719 }
6720
6721 let (response_tx, response_rx) = mpsc::sync_channel(1);
6722 let request = ReaderRequest::SearchProjectedText {
6723 query: query.to_string(),
6724 name: name.to_string(),
6725 filter: filter.map(Box::new),
6726 limit,
6727 view: *view,
6728 respond: response_tx,
6729 };
6730 if self.reader_pool.dispatch(request).is_err() {
6731 return Err(EngineError::Closing);
6732 }
6733 match response_rx.recv().map_err(|_| EngineError::Storage)? {
6734 Ok(result) => Ok(result),
6735 Err(SearchReaderError::InvalidFilter(reason)) => {
6736 Err(EngineError::InvalidFilter { reason })
6737 }
6738 Err(SearchReaderError::Sqlite(err)) => {
6739 self.emit_sqlite_internal_error(&err);
6740 Err(EngineError::Storage)
6741 }
6742 }
6743 }
6744
6745 /// 0.8.18 Slice 5 (R-VEQ-6) — degraded-open observability accessor. `true` iff
6746 /// the open-time #5 self-check found a vector-equivalence divergence and every
6747 /// vector-dependent arm is refusing. Mirrors `OpenReport.dense_disabled`; read
6748 /// lock-free.
6749 #[must_use]
6750 pub fn dense_disabled(&self) -> bool {
6751 self.dense_disabled.load(Ordering::Acquire)
6752 }
6753
6754 /// 0.8.18 Slice 5 (R-VEQ-6) — the human-readable reason for the degraded state
6755 /// (which representation tripped), or `None` when dense is healthy.
6756 #[must_use]
6757 pub fn dense_disabled_reason(&self) -> Option<String> {
6758 self.dense_disabled_reason.lock().ok().and_then(|g| g.clone())
6759 }
6760
6761 /// 0.8.18 Slice 5 (R-VEQ-6) — telemetry counter: number of query-time
6762 /// vector-dependent-arm refusals raised because the engine opened degraded.
6763 /// Observable pre/post-query.
6764 #[must_use]
6765 pub fn vector_equivalence_refusal_count(&self) -> u64 {
6766 self.vector_equivalence_refusals.load(Ordering::Relaxed)
6767 }
6768
6769 /// Shared event-wrapped body for [`search_reranked`][Engine::search_reranked]
6770 /// (`explain=false`) and [`search_explained`][Engine::search_explained]
6771 /// (`explain=true`). Keeps the Started/Finished/Failed lifecycle emissions +
6772 /// slow detection in one place.
6773 #[allow(clippy::too_many_arguments)] // mirrors search_reranked + the explain flag
6774 fn search_reranked_with_explain(
6775 &self,
6776 query: &str,
6777 filter: Option<SearchFilter>,
6778 rerank_depth: usize,
6779 use_graph_arm: bool,
6780 alpha: f64,
6781 pool_n: usize,
6782 explain: bool,
6783 view: ReadView,
6784 limit: usize,
6785 ) -> Result<SearchResult, EngineError> {
6786 // fix-2: refuse an existence-relaxing view BEFORE any work (and before the
6787 // Started event), so the refusal is a pure argument error rather than a
6788 // half-emitted query lifecycle.
6789 view.reject_existence_relaxation_on_search()?;
6790 self.emit_event(lifecycle::Phase::Started, lifecycle::EventCategory::Search, None);
6791 let started = Instant::now();
6792 let outcome = self.search_inner(
6793 query,
6794 filter,
6795 rerank_depth,
6796 use_graph_arm,
6797 alpha,
6798 pool_n,
6799 explain,
6800 view,
6801 limit,
6802 );
6803 self.detect_slow(started, lifecycle::EventCategory::Search);
6804 match outcome {
6805 Ok(result) => {
6806 self.counters.record_query();
6807 // 0.8.8 Slice 15 (OPP-9) — opt-in telemetry capture. No-op + no
6808 // allocation when telemetry is OFF (the default).
6809 self.capture_telemetry(query, &result);
6810 self.emit_event(lifecycle::Phase::Finished, lifecycle::EventCategory::Search, None);
6811 Ok(result)
6812 }
6813 Err(err) => {
6814 let code = err.stable_code();
6815 self.counters.record_error(code);
6816 self.emit_event(
6817 lifecycle::Phase::Failed,
6818 lifecycle::EventCategory::Search,
6819 Some(code),
6820 );
6821 self.emit_event(
6822 lifecycle::Phase::Failed,
6823 lifecycle::EventCategory::Error,
6824 Some(code),
6825 );
6826 Err(err)
6827 }
6828 }
6829 }
6830
6831 /// 0.8.8 Slice 15 (OPP-9) — enable opt-in telemetry capture to a local JSONL
6832 /// `sink_path` (append-only). Off by default; once enabled, each `search`
6833 /// records a query→result event and `record_feedback` appends agent labels.
6834 /// Local file only — no network/egress. `query_id` + `ts_monotonic_ms` are
6835 /// reset deterministically on enable. Idempotent re-enable resets the seq.
6836 pub fn enable_telemetry(&self, sink_path: &str) -> Result<(), EngineError> {
6837 // Touch the sink (create + validate writable) before arming capture, so a
6838 // bad path fails loudly here rather than silently dropping events.
6839 std::fs::OpenOptions::new()
6840 .create(true)
6841 .append(true)
6842 .open(sink_path)
6843 .map_err(|_| EngineError::Storage)?;
6844 let mut guard = self.telemetry.lock().map_err(|_| EngineError::Storage)?;
6845 *guard = Some(TelemetrySink {
6846 path: PathBuf::from(sink_path),
6847 base: Instant::now(),
6848 nonce: 0,
6849 seq: 0,
6850 last_query_id: None,
6851 });
6852 // Arm the fast OFF-path guard LAST (after the sink is installed) so a
6853 // concurrent search either sees telemetry fully off or fully on.
6854 self.telemetry_enabled.store(true, Ordering::Release);
6855 Ok(())
6856 }
6857
6858 /// 0.8.8 Slice 15 — the most-recent captured `query_id` (for `record_feedback`).
6859 /// `None` when telemetry is off or no query has been captured yet.
6860 pub fn last_telemetry_query_id(&self) -> Option<String> {
6861 self.telemetry.lock().ok()?.as_ref().and_then(|s| s.last_query_id.clone())
6862 }
6863
6864 /// 0.8.8 Slice 15 — capture a query→result telemetry event. No-op (no alloc,
6865 /// no I/O) when telemetry is off (the default). Best-effort: a sink write error
6866 /// never fails the search. Captures ONLY ids, arms, and the query LENGTH —
6867 /// never the query text or `source_id` (privacy, ADR §C).
6868 ///
6869 /// ID-SPACES (Cause-A, 0.8.11.2 — honest record). `result_ids` is the interim
6870 /// `SearchHit.id` == `write_cursor`: within-session consistent but NOT
6871 /// cross-session-stable (reassigned on re-projection/re-ingest). `arm_of` is
6872 /// keyed by that same `write_cursor`. Cause-A adds a NEW PARALLEL field
6873 /// `result_stable_ids` carrying the cross-session-stable id
6874 /// ([`SearchHit::stable_id`], `logical_id` / content-hash) in the SAME order as
6875 /// `result_ids`; the existing `write_cursor` keys are RETAINED unchanged so
6876 /// pre-Cause-A gold and sink byte-output stay valid (the F-8a `id_space` flip
6877 /// is a separate, conscious step — see
6878 /// `dev/plans/runs/NOTE-0.8.8-to-steward-id-contract.md`).
6879 fn capture_telemetry(&self, query: &str, result: &SearchResult) {
6880 // Fast OFF path (codex §9 P2): a single atomic load when telemetry has
6881 // never been enabled — NO mutex acquisition, NO contention with the search
6882 // hot path.
6883 if !self.telemetry_enabled.load(Ordering::Acquire) {
6884 return;
6885 }
6886 let Ok(mut guard) = self.telemetry.lock() else { return };
6887 let Some(sink) = guard.as_mut() else { return };
6888 let query_id = format!("q{}-{}", sink.nonce, sink.seq);
6889 let ts_monotonic_ms = sink.base.elapsed().as_millis() as u64;
6890 let mut arm_of = serde_json::Map::new();
6891 for h in &result.results {
6892 // Keyed on the engine-internal positional cursor (the pre-C-2
6893 // `SearchHit.id` == `write_cursor`), byte-unchanged so `record_feedback`
6894 // + the gold pipeline keep keying on the same `result_ids` space.
6895 arm_of
6896 .insert(h.write_cursor.to_string(), serde_json::Value::from(branch_str(h.branch)));
6897 }
6898 let event = serde_json::json!({
6899 "type": "event",
6900 "schema_version": 1,
6901 "ts_monotonic_ms": ts_monotonic_ms,
6902 "query_id": query_id,
6903 "query_chars": query.chars().count() as u64,
6904 "result_ids": result.results.iter().map(|h| h.write_cursor).collect::<Vec<u64>>(),
6905 // Cause-A / C-2: parallel cross-session-stable ids, SAME order as
6906 // result_ids. Post-C-2 the stable id lives on `SearchHit.id` (its
6907 // prefixed form == the pre-swap `stable_id` value byte-for-byte), so
6908 // the emitted bytes are unchanged and the `write_cursor` result_ids
6909 // keys are retained unchanged (pre-Cause-A gold stays valid).
6910 "result_stable_ids": result
6911 .results
6912 .iter()
6913 .map(|h| h.id.to_prefixed())
6914 .collect::<Vec<String>>(),
6915 "arm_of": arm_of,
6916 });
6917 let _ = append_jsonl(&sink.path, &event);
6918 sink.seq += 1;
6919 sink.last_query_id = Some(query_id);
6920 }
6921
6922 /// 0.8.8 Slice 15 — append an agent-supplied relevance-label record for a
6923 /// previously-captured `query_id`. `label_source` is the only exogenous string
6924 /// (caller-declared, e.g. `"agent:hermes"`).
6925 ///
6926 /// ID-SPACE (Cause-A, 0.8.11.2 — honest record). `relevant_ids` /
6927 /// `irrelevant_ids` are the interim `SearchHit.id` == `write_cursor` (the same
6928 /// space as the captured event's `result_ids`), NOT `logical_id`. The
6929 /// signature is left byte-stable: the gold pipeline maps these `write_cursor`
6930 /// keys to the cross-session-stable id via the capture event's parallel
6931 /// `result_ids` ↔ `result_stable_ids` arrays (`eval/gold_capture.py`), so no
6932 /// new feedback parameter — and no binding-signature churn — is required.
6933 /// Errors if telemetry is off.
6934 pub fn record_feedback(
6935 &self,
6936 query_id: &str,
6937 relevant_ids: &[u64],
6938 irrelevant_ids: &[u64],
6939 label_source: &str,
6940 ) -> Result<(), EngineError> {
6941 let guard = self.telemetry.lock().map_err(|_| EngineError::Storage)?;
6942 let sink = guard
6943 .as_ref()
6944 .ok_or(EngineError::InvalidArgument { msg: "telemetry is not enabled".to_string() })?;
6945 // codex §9 [P1] (privacy): `query_id` is an exogenous caller string. Only a
6946 // deterministic id that `capture_telemetry` has ALREADY emitted may be
6947 // persisted — otherwise a caller could smuggle query text / a `source_id`
6948 // into the sink under the `query_id` key. Require the canonical
6949 // `q{nonce}-{seq}` form with `nonce == sink.nonce` AND `seq < sink.seq`
6950 // (a seq the capture path has issued). Reject (writing nothing) otherwise.
6951 let is_issued_id = query_id
6952 .strip_prefix('q')
6953 .and_then(|rest| rest.split_once('-'))
6954 .and_then(|(nonce, seq)| Some((nonce.parse::<u64>().ok()?, seq.parse::<u64>().ok()?)))
6955 .is_some_and(|(nonce, seq)| nonce == sink.nonce && seq < sink.seq);
6956 if !is_issued_id {
6957 return Err(EngineError::InvalidArgument { msg: "unknown query_id".to_string() });
6958 }
6959 let record = serde_json::json!({
6960 "type": "feedback",
6961 "schema_version": 1,
6962 "query_id": query_id,
6963 "relevant_ids": relevant_ids,
6964 "irrelevant_ids": irrelevant_ids,
6965 "label_source": label_source,
6966 });
6967 append_jsonl(&sink.path, &record).map_err(|_| EngineError::Storage)
6968 }
6969
6970 fn detect_slow(&self, started: Instant, category: lifecycle::EventCategory) {
6971 let elapsed = started.elapsed();
6972 let threshold = self.slow_threshold_ms.load(Ordering::Relaxed);
6973 let threshold_duration = std::time::Duration::from_millis(threshold);
6974 if elapsed > threshold_duration {
6975 // `dev/design/lifecycle.md` § Slow and heartbeat policy: a slow
6976 // operation produces TWO correlated facts. The
6977 // statement-level slow-statement signal is dispatched by the
6978 // sqlite3_profile callback (`profile_callback_trampoline`).
6979 // This site emits the lifecycle `Phase::Slow` event for the
6980 // outer operation envelope (AC-008).
6981 self.emit_event(lifecycle::Phase::Slow, category, None);
6982 }
6983 }
6984
6985 fn emit_event(
6986 &self,
6987 phase: lifecycle::Phase,
6988 category: lifecycle::EventCategory,
6989 code: Option<&'static str>,
6990 ) {
6991 let event =
6992 lifecycle::Event { phase, source: lifecycle::EventSource::Engine, category, code };
6993 self.subscribers.dispatch(&event);
6994 }
6995
6996 /// Emit a `(SqliteInternal, Error, code: <SQLITE_*>)` lifecycle
6997 /// event for a rusqlite error. Per `dev/design/lifecycle.md`
6998 /// § Diagnostic source and category, SQLite-originated diagnostics
6999 /// route through the same host subscriber as engine-originated
7000 /// events with `source` preserved. AC-021 dispatches on
7001 /// `code == "SQLITE_SCHEMA"`.
7002 fn emit_sqlite_internal_error(&self, err: &rusqlite::Error) {
7003 if let Some(code) = sqlite_extended_code_name(err) {
7004 let event = lifecycle::Event {
7005 phase: lifecycle::Phase::Failed,
7006 source: lifecycle::EventSource::SqliteInternal,
7007 category: lifecycle::EventCategory::Error,
7008 code: Some(code),
7009 };
7010 self.subscribers.dispatch(&event);
7011 }
7012 }
7013
7014 /// Thin wrapper: the production search path that discards the G0 Phase-2
7015 /// frontier meter (it never reaches `SearchResult` / the governed surface).
7016 #[allow(clippy::too_many_arguments)] // mirrors search_reranked + the explain flag
7017 fn search_inner(
7018 &self,
7019 query: &str,
7020 filter: Option<SearchFilter>,
7021 rerank_depth: usize,
7022 use_graph_arm: bool,
7023 alpha: f64,
7024 pool_n: usize,
7025 explain: bool,
7026 view: ReadView,
7027 result_limit: usize,
7028 ) -> Result<SearchResult, EngineError> {
7029 self.search_inner_with_stats(
7030 query,
7031 filter,
7032 rerank_depth,
7033 use_graph_arm,
7034 alpha,
7035 pool_n,
7036 explain,
7037 view,
7038 result_limit,
7039 )
7040 .map(|(result, _stats)| result)
7041 }
7042
7043 /// G0 Phase-2: the search body, additionally returning the graph-arm frontier
7044 /// meter. Only the `_graph_frontier_stats_for_test` seam consumes the stats;
7045 /// `search_inner` (and thus `search_reranked` / `search`) drops them.
7046 #[allow(clippy::too_many_arguments)] // mirrors search_reranked + the explain flag
7047 fn search_inner_with_stats(
7048 &self,
7049 query: &str,
7050 filter: Option<SearchFilter>,
7051 rerank_depth: usize,
7052 use_graph_arm: bool,
7053 alpha: f64,
7054 pool_n: usize,
7055 explain: bool,
7056 view: ReadView,
7057 result_limit: usize,
7058 ) -> Result<(SearchResult, GraphFrontierStats), EngineError> {
7059 self.ensure_open()?;
7060 // 0.8.18 Slice 5 (#5 vector-equivalence probe, R-VEQ-4) — the SINGLE
7061 // vector-dependent choke point. If the open-time self-check found a
7062 // divergence beyond the D4 floor, refuse EVERY vector-dependent arm
7063 // (search / search_expand / explain-rerank / graph-arm all funnel here)
7064 // BEFORE any embedding / vector SQL / graph seeding / CE rerank — no
7065 // silent partial results. The text-only/FTS-only path
7066 // (`search_text_only`) does NOT route through here, so FTS stays
7067 // serviceable in degraded mode.
7068 if self.dense_disabled.load(Ordering::Acquire) {
7069 self.vector_equivalence_refusals.fetch_add(1, Ordering::Relaxed);
7070 let reason =
7071 self.dense_disabled_reason.lock().ok().and_then(|g| g.clone()).unwrap_or_else(
7072 || "open-time #5 vector-equivalence self-check failed".to_string(),
7073 );
7074 return Err(EngineError::VectorEquivalenceMismatch { reason });
7075 }
7076 if query.trim().is_empty() {
7077 return Err(EngineError::WriteValidation);
7078 }
7079
7080 // 0.8.20 Slice 15e fix-2 finding 1 [P2] — every filter attribute name is
7081 // validated against the declared `filterable` registry set BEFORE any arm
7082 // runs, so an UNDECLARED name is a typed `InvalidFilter` rejection instead
7083 // of an opaque `no such column` `Storage` crash (vector arm) or a silent
7084 // no-match (FTS arm). ADR-0.8.11 D3: every filter term has a DEFINED outcome
7085 // ("compiles" or "typed rejection") IDENTICAL across arms.
7086 //
7087 // keystone closeout fix-3 (codex §9 [P2], TOCTOU): that validation is NO
7088 // LONGER performed here on `self.connection` before dispatch. fix-2 checked
7089 // the registry on the WRITER connection and then let the reader prepare the
7090 // vec0 query on a DIFFERENT connection/snapshot — a `configure_projections`
7091 // DROP landing in the window between the check and the reader snapshot could
7092 // still make the `attr_<hex>` column vanish AFTER validation passed, i.e. the
7093 // exact untyped `Storage` failure fix-2 meant to prevent. The check now runs
7094 // INSIDE the reader's deferred transaction (see
7095 // `validate_filter_attributes_on_snapshot`, called from `read_search_in_tx`),
7096 // so the registry it reads and the vec0 columns the query compiles against are
7097 // ONE snapshot — the race is closed and BOTH arms still see the same typed
7098 // `InvalidFilter`. Moving it there also removes a per-filtered-search writer
7099 // lock and the fix-2 concurrent-ADD false-reject (the reader snapshot sees a
7100 // freshly-added declaration and accepts).
7101 let compiled = compile_text_query(query);
7102 // REQ-013 / AC-059b / REQ-055: the cursor returned with a search
7103 // MUST be derived from the same WAL snapshot the data was read
7104 // from. Loading `next_cursor` from the writer-side atomic before
7105 // the reader transaction acquires its snapshot races against
7106 // concurrent writers — see `dev/design/engine.md` § Cursor
7107 // contract. Run cursor probe + body query inside one read tx
7108 // (BEGIN DEFERRED on a `query_only=ON` connection in WAL mode is
7109 // a snapshot-stable read).
7110 // EU-5a2 mean-centering apply path (query side). `query_vector`
7111 // is ALWAYS un-centered (used by the f32 vec_distance_l2 rerank
7112 // in phase 2). `query_vector_bin` is the (possibly centered) f32
7113 // fed to `vec_quantize_binary` in phase 1. The centering decision
7114 // mirrors the write path: identity must be MC-required AND a
7115 // mean_vec must be pinned. NoopEmbedder collapses to
7116 // `query_vector_bin == query_vector` until EU-5b.
7117 let raw_query_vector =
7118 self.runtime_embedder.as_ref().and_then(|embedder| embedder.embed(query).ok());
7119 let query_vector_bin = match raw_query_vector.as_ref() {
7120 Some(vector) if identity_requires_mean_centering(&self.runtime_embedder_identity) => {
7121 let pinned = {
7122 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7123 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7124 read_pinned_mean_vec(connection, self.runtime_embedder_identity.dimension)?
7125 };
7126 match pinned {
7127 Some(mean) => serde_json::to_string(&subtract_mean(vector, &mean)).ok(),
7128 None => serde_json::to_string(vector).ok(),
7129 }
7130 }
7131 Some(vector) => serde_json::to_string(vector).ok(),
7132 None => None,
7133 };
7134 let query_vector = raw_query_vector.and_then(|vector| serde_json::to_string(&vector).ok());
7135 // The public result limit is independent of the test-only vector
7136 // candidate fanout. The seam may raise this fanout for recall tests,
7137 // but the reader still truncates visible results to `result_limit`.
7138 let candidate_limit = self
7139 .projection_runtime
7140 .shared
7141 .search_limit_override
7142 .load(Ordering::SeqCst)
7143 .max(result_limit);
7144 let recency_enabled =
7145 self.projection_runtime.shared.recency_reweight_enabled.load(Ordering::SeqCst);
7146 let importance_enabled =
7147 self.projection_runtime.shared.importance_reweight_enabled.load(Ordering::SeqCst);
7148 let vector_stage_only =
7149 self.projection_runtime.shared.vector_stage_only_for_test.load(Ordering::SeqCst);
7150 let (response_tx, response_rx) = mpsc::sync_channel::<ReaderResponse>(1);
7151 let request = ReaderRequest::Search {
7152 compiled,
7153 query_vector,
7154 query_vector_bin,
7155 result_limit,
7156 candidate_limit,
7157 direct_text_candidate_limit: None,
7158 filter: filter.map(Box::new),
7159 recency_enabled,
7160 importance_enabled,
7161 vector_stage_only,
7162 raw_query: Box::from(query), // FIX-4: Box<str> (16B) not String (24B)
7163 rerank_depth,
7164 use_graph_arm,
7165 alpha,
7166 pool_n,
7167 explain,
7168 view,
7169 respond: response_tx,
7170 };
7171 if self.reader_pool.dispatch(request).is_err() {
7172 return Err(EngineError::Closing);
7173 }
7174 let search_result = response_rx.recv().map_err(|_| EngineError::Storage)?;
7175 let (cursor, soft_fallback, results, graph_stats, explanation) = match search_result {
7176 Ok(result) => result,
7177 // fix-3 (codex §9 [P2]) — an undeclared `filterable` attribute is caught
7178 // on the reader's OWN snapshot (validate + vec0 query = one transaction),
7179 // so it surfaces as the EXISTING typed `InvalidFilter` and can never be
7180 // the opaque `no such column` `Storage` error the TOCTOU race produced.
7181 Err(SearchReaderError::InvalidFilter(reason)) => {
7182 return Err(EngineError::InvalidFilter { reason });
7183 }
7184 Err(SearchReaderError::Sqlite(err)) => {
7185 self.emit_sqlite_internal_error(&err);
7186 return Err(EngineError::Storage);
7187 }
7188 };
7189
7190 // The worker (`read_search_in_tx`) has no embedder identity; fill the
7191 // trace's `embedder_id` here, where `self.runtime_embedder_identity` is in
7192 // scope. Only on the explain path (`explanation` is `Some`).
7193 let explanation = explanation.map(|mut exp| {
7194 let id = &self.runtime_embedder_identity;
7195 exp.trace.embedder_id = format!("{}@{} (dim={})", id.name, id.revision, id.dimension);
7196 exp
7197 });
7198
7199 Ok((
7200 SearchResult { projection_cursor: cursor, soft_fallback, results, explanation },
7201 graph_stats,
7202 ))
7203 }
7204
7205 /// G0 Phase-2 (BLOCK-1) test seam — runs the graph-arm retrieval path and
7206 /// returns the frontier meter (`GraphFrontierStats`) for `query`. Mirrors the
7207 /// sanctioned `set_vector_stage_only_for_test` / `_configure_vector_kind_for_test`
7208 /// pattern: kept OFF the governed surface (test/eval-only), so the meter never
7209 /// appears on `SearchResult`. Used by the recall harness to prove the
7210 /// doc-seeded frontier is empty (`resolved_seed_rate == 0.0`) and, post-C1, the
7211 /// 0→>0 flip.
7212 pub fn _graph_frontier_stats_for_test(
7213 &self,
7214 query: &str,
7215 ) -> Result<GraphFrontierStats, EngineError> {
7216 self.search_inner_with_stats(
7217 query,
7218 None,
7219 0,
7220 true,
7221 0.3,
7222 0,
7223 false,
7224 ReadView::default(),
7225 DEFAULT_SEARCH_RESULT_LIMIT,
7226 )
7227 .map(|(_result, stats)| stats)
7228 }
7229
7230 /// Slice 30 (G2) — `read.get`: active-only point lookup by `logical_id`.
7231 /// Delegates to [`Engine::read_get_many`]; returns the single slot. A
7232 /// missing/superseded id is `None` (a normal absence, not an error). Reads
7233 /// ride the ReaderWorkerPool DEFERRED-tx path (never the writer lock).
7234 pub fn read_get(
7235 &self,
7236 logical_id: &str,
7237 view: &ReadView,
7238 ) -> Result<Option<NodeRecord>, EngineError> {
7239 let ids = [logical_id.to_string()];
7240 let rows = self.read_get_many(&ids, view)?;
7241 Ok(rows.into_iter().next().flatten())
7242 }
7243
7244 /// Slice 30 (G2) — `read.get_many`: active-only point lookup over many
7245 /// `logical_id`s. Returns one slot per requested id in REQUEST ORDER, `None`
7246 /// where no active row carries that id (partial, never all-or-nothing).
7247 pub fn read_get_many(
7248 &self,
7249 logical_ids: &[String],
7250 view: &ReadView,
7251 ) -> Result<Vec<Option<NodeRecord>>, EngineError> {
7252 self.ensure_open()?;
7253 if logical_ids.is_empty() {
7254 return Ok(Vec::new());
7255 }
7256 let (response_tx, response_rx) = mpsc::sync_channel(1);
7257 let request = ReaderRequest::GetById {
7258 logical_ids: logical_ids.to_vec(),
7259 view: *view,
7260 respond: response_tx,
7261 };
7262 if self.reader_pool.dispatch(request).is_err() {
7263 return Err(EngineError::Closing);
7264 }
7265 match response_rx.recv().map_err(|_| EngineError::Storage)? {
7266 Ok(rows) => Ok(rows),
7267 Err(err) => {
7268 self.emit_sqlite_internal_error(&err);
7269 Err(EngineError::Storage)
7270 }
7271 }
7272 }
7273
7274 /// Slice 20 (G5) — `read.neighbors`: bounded BFS from `root_logical_id`
7275 /// over `canonical_edges`. Returns nodes reachable within `depth` hops
7276 /// (`1..=3`) in the given `direction`, excluding the root itself.
7277 ///
7278 /// Hard cap: 50 results (engine-enforced `LIMIT 50`).
7279 /// Traversal filter: `superseded_at IS NULL AND (t_invalid IS NULL OR t_invalid > now)`.
7280 ///
7281 /// Returns `Err(EngineError::InvalidArgument)` for `depth > 3`.
7282 /// Returns `Ok(vec![])` for an unknown/superseded root.
7283 /// Reads ride the `ReaderWorkerPool` DEFERRED-tx path.
7284 pub fn graph_neighbors(
7285 &self,
7286 root_logical_id: &str,
7287 depth: u32,
7288 direction: TraversalDirection,
7289 view: &ReadView,
7290 ) -> Result<Vec<NodeRecord>, EngineError> {
7291 self.ensure_open()?;
7292 if depth == 0 || depth > 3 {
7293 return Err(EngineError::InvalidArgument {
7294 msg: format!("traversal depth {depth} is out of range; must be 1, 2, or 3"),
7295 });
7296 }
7297 let (response_tx, response_rx) = mpsc::sync_channel(1);
7298 let request = ReaderRequest::GraphNeighbors {
7299 root_logical_id: root_logical_id.to_string(),
7300 depth,
7301 direction,
7302 view: *view,
7303 respond: response_tx,
7304 };
7305 if self.reader_pool.dispatch(request).is_err() {
7306 return Err(EngineError::Closing);
7307 }
7308 match response_rx.recv().map_err(|_| EngineError::Storage)? {
7309 Ok(nodes) => Ok(nodes),
7310 Err(err) => {
7311 self.emit_sqlite_internal_error(&err);
7312 Err(EngineError::Storage)
7313 }
7314 }
7315 }
7316
7317 /// Slice 20 (G6) — `search_expand`: hybrid search (`G1+G9`) followed by
7318 /// bounded BFS expansion (`G5`) of each search hit. Returns the original
7319 /// search hits (with RRF scores) plus nodes reachable from any hit via
7320 /// up to `depth` hops that are NOT already in the search hit set.
7321 ///
7322 /// Returns `Err(EngineError::InvalidArgument)` for `depth > 3`.
7323 /// A `depth = 0` call returns search hits with their logical_ids resolved
7324 /// but no BFS expansion. Reads ride the `ReaderWorkerPool` DEFERRED-tx path.
7325 ///
7326 /// **Snapshot note:** the search phase (`search_inner`) and the expansion
7327 /// phase (`SearchExpand` reader request) run in separate DEFERRED reader
7328 /// transactions; a write that lands between them is visible to expansion
7329 /// but not search (or vice-versa). In practice the window is negligible for
7330 /// single-process embedded use. The expansion phase mitigates drift by
7331 /// filtering `search_hits` to only include hits whose `write_cursor` is
7332 /// still active in the expansion snapshot (superseded hits are dropped from
7333 /// the result rather than surfaced with stale data).
7334 pub fn search_expand(
7335 &self,
7336 query: &str,
7337 filter: Option<SearchFilter>,
7338 depth: u32,
7339 ) -> Result<SearchExpandResult, EngineError> {
7340 self.search_expand_with_limit(query, filter, depth, DEFAULT_SEARCH_RESULT_LIMIT)
7341 }
7342
7343 /// Hybrid search followed by graph expansion, with an explicit limit for
7344 /// the initial ranked `search_hits` set.
7345 pub fn search_expand_with_limit(
7346 &self,
7347 query: &str,
7348 filter: Option<SearchFilter>,
7349 depth: u32,
7350 limit: usize,
7351 ) -> Result<SearchExpandResult, EngineError> {
7352 let limit = validate_search_result_limit(limit)?;
7353 self.ensure_open()?;
7354 if depth > 3 {
7355 return Err(EngineError::InvalidArgument {
7356 msg: format!("traversal depth {depth} exceeds the SDK ceiling of 3"),
7357 });
7358 }
7359 // Step 1: run the hybrid search to get initial hits (no CE reranking in expand).
7360 // 0.8.5: depth=0 → no rerank, so α/pool_n (0.3, 0) are inert here.
7361 let search_result =
7362 self.search_inner(query, filter, 0, false, 0.3, 0, false, ReadView::default(), limit)?;
7363 if search_result.results.is_empty() {
7364 return Ok(SearchExpandResult {
7365 search_hits: Vec::new(),
7366 expanded: Vec::new(),
7367 all_logical_ids: Vec::new(),
7368 });
7369 }
7370 // Step 2: dispatch to the reader pool to resolve logical_ids and run BFS.
7371 // depth=0 is forwarded to the reader so it can populate all_logical_ids
7372 // (the union of search-hit logical_ids), even with no expansion.
7373 let (response_tx, response_rx) = mpsc::sync_channel(1);
7374 let request = ReaderRequest::SearchExpand {
7375 search_hits: search_result.results,
7376 depth,
7377 respond: response_tx,
7378 };
7379 if self.reader_pool.dispatch(request).is_err() {
7380 return Err(EngineError::Closing);
7381 }
7382 match response_rx.recv().map_err(|_| EngineError::Storage)? {
7383 Ok(result) => Ok(result),
7384 Err(err) => {
7385 self.emit_sqlite_internal_error(&err);
7386 Err(EngineError::Storage)
7387 }
7388 }
7389 }
7390
7391 /// Slice 20 test seam — run `EXPLAIN QUERY PLAN` on the BFS CTE SQL and
7392 /// return the plan detail lines. Used by `explain_plan_uses_indexes`.
7393 #[doc(hidden)]
7394 pub fn explain_graph_neighbors_for_test(
7395 &self,
7396 root_logical_id: &str,
7397 depth: u32,
7398 direction: TraversalDirection,
7399 ) -> Result<Vec<String>, EngineError> {
7400 self.ensure_open()?;
7401 let (response_tx, response_rx) = mpsc::sync_channel(1);
7402 let request = ReaderRequest::ExplainGraphNeighbors {
7403 root_logical_id: root_logical_id.to_string(),
7404 depth,
7405 direction,
7406 respond: response_tx,
7407 };
7408 if self.reader_pool.dispatch(request).is_err() {
7409 return Err(EngineError::Closing);
7410 }
7411 match response_rx.recv().map_err(|_| EngineError::Storage)? {
7412 Ok(plan) => Ok(plan),
7413 Err(err) => {
7414 self.emit_sqlite_internal_error(&err);
7415 Err(EngineError::Storage)
7416 }
7417 }
7418 }
7419
7420 /// Slice 30 (G3) — `read.collection`: paginated op-store read-back over
7421 /// `operational_mutations` for `collection`, `ORDER BY id`. `limit` is
7422 /// MANDATORY (clamped to the ~1M cap); `after_id` is the exclusive cursor.
7423 /// Reads ride the ReaderWorkerPool DEFERRED-tx path.
7424 pub fn read_collection(
7425 &self,
7426 collection: &str,
7427 after_id: Option<i64>,
7428 limit: usize,
7429 ) -> Result<Vec<OpStoreRow>, EngineError> {
7430 self.read_collection_dispatch(collection, after_id, limit)
7431 }
7432
7433 /// Slice 30 (G3) — `read.mutations`: the mutation-log-oriented alias surface
7434 /// over the SAME op-store read-back as [`Engine::read_collection`].
7435 pub fn read_mutations(
7436 &self,
7437 collection: &str,
7438 after_id: Option<i64>,
7439 limit: usize,
7440 ) -> Result<Vec<OpStoreRow>, EngineError> {
7441 self.read_collection_dispatch(collection, after_id, limit)
7442 }
7443
7444 fn read_collection_dispatch(
7445 &self,
7446 collection: &str,
7447 after_id: Option<i64>,
7448 limit: usize,
7449 ) -> Result<Vec<OpStoreRow>, EngineError> {
7450 self.ensure_open()?;
7451 let (response_tx, response_rx) = mpsc::sync_channel(1);
7452 let request = ReaderRequest::ReadCollection {
7453 collection: collection.to_string(),
7454 after_id,
7455 limit,
7456 respond: response_tx,
7457 };
7458 if self.reader_pool.dispatch(request).is_err() {
7459 return Err(EngineError::Closing);
7460 }
7461 match response_rx.recv().map_err(|_| EngineError::Storage)? {
7462 Ok(rows) => Ok(rows),
7463 Err(err) => {
7464 self.emit_sqlite_internal_error(&err);
7465 Err(EngineError::Storage)
7466 }
7467 }
7468 }
7469
7470 /// Slice 35 (G4) — `read.list`: list active `canonical_nodes` of a given
7471 /// `kind`, optionally filtered by a closed [`Predicate`] set, up to `limit`
7472 /// rows. Returns `Vec<NodeRecord>` (active only; `superseded_at IS NULL`).
7473 ///
7474 /// Multiple predicates are combined as AND (D-F5). An empty predicate slice
7475 /// returns all active nodes of the given kind up to `limit` (unfiltered path).
7476 /// Compilation target: `json_extract(body, '$.field') <op> ?` with bound
7477 /// parameters (injection-safe per D-F4). See `dev/adr/ADR-0.8.0-filter-grammar.md`.
7478 ///
7479 /// Path validation happens at [`Predicate`] construction time; `read_list`
7480 /// revalidates as defense-in-depth (enum variants are `pub`, so direct
7481 /// struct-literal construction could bypass the constructors).
7482 pub fn read_list(
7483 &self,
7484 kind: &str,
7485 predicates: &[Predicate],
7486 limit: usize,
7487 view: &ReadView,
7488 ) -> Result<Vec<NodeRecord>, EngineError> {
7489 self.ensure_open()?;
7490 // Defense-in-depth: revalidate paths even if the caller bypassed the
7491 // validated constructors by constructing enum variants directly.
7492 for pred in predicates {
7493 let path = pred.path();
7494 if !PREDICATE_PATH_ALLOWLIST.contains(&path) {
7495 return Err(EngineError::InvalidFilter {
7496 reason: format!("path '{path}' is not in the predicate path allowlist"),
7497 });
7498 }
7499 }
7500 let (response_tx, response_rx) = mpsc::sync_channel(1);
7501 let request = ReaderRequest::ReadList {
7502 kind: kind.to_string(),
7503 predicates: predicates.to_vec(),
7504 limit,
7505 view: *view,
7506 respond: response_tx,
7507 };
7508 if self.reader_pool.dispatch(request).is_err() {
7509 return Err(EngineError::Closing);
7510 }
7511 match response_rx.recv().map_err(|_| EngineError::Storage)? {
7512 Ok(rows) => Ok(rows),
7513 Err(err) => {
7514 self.emit_sqlite_internal_error(&err);
7515 Err(EngineError::Storage)
7516 }
7517 }
7518 }
7519
7520 /// 0.8.11 Slice 40 (#17) — unified-`Filter` entry point for the
7521 /// canonical_nodes `read.list` backend. Accepts the **full** [`FilterTerm`]
7522 /// set (D3): `Json` runs the shipped allowlisted `json_extract` path;
7523 /// `Status`/`CreatedAfter` lower to allowlisted json-paths; `Kind`/`SourceType`
7524 /// **constant-fold** against the partition `kind` (a guaranteed-empty fold
7525 /// returns an empty `Vec` without touching SQL). Dispatches to the same
7526 /// [`Engine::read_list`] machinery the shipped `Predicate` surface uses, so
7527 /// every inherited invariant (`superseded_at IS NULL`, `json_valid(body)`,
7528 /// the `canonical_nodes(kind)` index, parameterized binds) is preserved.
7529 pub fn read_list_filter(
7530 &self,
7531 kind: &str,
7532 filter: &Filter,
7533 limit: usize,
7534 view: &ReadView,
7535 ) -> Result<Vec<NodeRecord>, EngineError> {
7536 self.ensure_open()?;
7537 match filter.lower_for_read_list(kind)? {
7538 None => Ok(Vec::new()),
7539 Some(preds) => self.read_list(kind, &preds, limit, view),
7540 }
7541 }
7542
7543 /// 0.8.20 Slice 10b (R-20-NV) — the **validity-boundary hook**: which nodes
7544 /// crossed a `[valid_from, valid_until)` boundary in the half-open interval
7545 /// `(since, as_of]`?
7546 ///
7547 /// `since` and the resolved upper bound are INTEGER epoch SECONDS. The upper
7548 /// bound is the view's own instant (`view.valid_as_of`, defaulting to now),
7549 /// so one instant governs both the boundary interval and the view — and, as
7550 /// everywhere else on this path, it is BOUND, never a `datetime('now')`
7551 /// literal, so the answer is deterministic for a fixed `(since, as_of)`.
7552 ///
7553 /// A node appears once, carrying whichever of the two boundaries it crossed;
7554 /// a window that both opened AND closed inside the interval reports both.
7555 /// Rows with an unbounded window on a side cannot cross that side, so a
7556 /// NULL/NULL row (every row predating schema step 22) never appears.
7557 ///
7558 /// The view's EXISTENCE flags still apply (so by default only current,
7559 /// active rows are considered), but its validity predicate does NOT: the
7560 /// question is about boundary crossings, not about being valid right now.
7561 ///
7562 /// When the view relaxes validity entirely (`include_out_of_window`), the
7563 /// interval is unbounded above.
7564 ///
7565 /// This is world-time only. There is deliberately no transaction-time
7566 /// (`history_as_of`) counterpart.
7567 pub fn crossed_boundary_since(
7568 &self,
7569 since: i64,
7570 view: &ReadView,
7571 ) -> Result<Vec<BoundaryCrossing>, EngineError> {
7572 self.ensure_open()?;
7573 let (response_tx, response_rx) = mpsc::sync_channel(1);
7574 let request =
7575 ReaderRequest::CrossedBoundarySince { since, view: *view, respond: response_tx };
7576 if self.reader_pool.dispatch(request).is_err() {
7577 return Err(EngineError::Closing);
7578 }
7579 match response_rx.recv().map_err(|_| EngineError::Storage)? {
7580 Ok(rows) => Ok(rows),
7581 Err(err) => {
7582 self.emit_sqlite_internal_error(&err);
7583 Err(EngineError::Storage)
7584 }
7585 }
7586 }
7587
7588 pub fn close(&self) -> Result<(), EngineError> {
7589 self.closed.store(true, Ordering::SeqCst);
7590 self.projection_runtime.stop();
7591 // Uninstall profile callbacks before dropping the connections so
7592 // SQLite cannot fire one last callback against a profile context
7593 // whose Box is about to free. Per `dev/design/engine.md` § Close
7594 // path step 6, readers drain before the writer connection so
7595 // SQLite's last-handle checkpointer runs on the writer. Each
7596 // reader worker uninstalls its own callback inside
7597 // `reader_worker_loop` before dropping its connection, then
7598 // exits — `shutdown` joins those threads here.
7599 self.reader_pool.shutdown();
7600 if let Ok(mut connection) = self.connection.lock() {
7601 if let Some(conn) = connection.as_ref() {
7602 uninstall_profile_callback(conn);
7603 }
7604 connection.take();
7605 }
7606 if let Ok(mut contexts) = self.profile_contexts.lock() {
7607 contexts.clear();
7608 }
7609 if let Ok(mut lock) = self.lock.lock() {
7610 lock.take();
7611 }
7612 Ok(())
7613 }
7614
7615 /// Block until in-flight writes drain or `timeout_ms` elapses.
7616 ///
7617 /// Surface owned by `dev/interfaces/rust.md` § Engine-attached
7618 /// instrumentation; semantics are owned by `dev/design/lifecycle.md`.
7619 pub fn drain(&self, timeout_ms: u64) -> Result<(), EngineError> {
7620 self.ensure_open()?;
7621 if self.projection_runtime.wait_for_idle(timeout_ms) {
7622 Ok(())
7623 } else {
7624 Err(EngineError::Scheduler)
7625 }
7626 }
7627
7628 /// Snapshot of engine-internal counters.
7629 ///
7630 /// Field set owned by `dev/design/lifecycle.md`.
7631 #[must_use]
7632 pub fn counters(&self) -> CounterSnapshot {
7633 self.counters.snapshot()
7634 }
7635
7636 /// Toggle response-cycle profiling.
7637 ///
7638 /// Per `dev/design/lifecycle.md` § Per-statement profiling, profiling
7639 /// is an opt-in surface that is independently toggleable on a running
7640 /// engine without restart. AC-005a locks runtime toggleability.
7641 pub fn set_profiling(&self, enabled: bool) -> Result<(), EngineError> {
7642 self.profiling_enabled.store(enabled, Ordering::Relaxed);
7643 Ok(())
7644 }
7645
7646 /// Set the threshold above which an operation is reported as slow.
7647 ///
7648 /// Per `dev/design/lifecycle.md` § Slow and heartbeat policy, the
7649 /// threshold is runtime-configurable; mutating it changes detection
7650 /// behavior on subsequent statements without restart (AC-007b).
7651 pub fn set_slow_threshold_ms(&self, value: u64) -> Result<(), EngineError> {
7652 self.slow_threshold_ms.store(value, Ordering::Relaxed);
7653 Ok(())
7654 }
7655
7656 /// Attach a host subscriber to engine events.
7657 ///
7658 /// Dropping the returned [`Subscription`] detaches the subscriber.
7659 /// Payload shape owned by `dev/design/lifecycle.md` and
7660 /// `dev/design/migrations.md`.
7661 #[must_use]
7662 pub fn subscribe(&self, subscriber: Arc<dyn lifecycle::Subscriber>) -> Subscription {
7663 self.subscribers.attach(subscriber)
7664 }
7665
7666 #[cfg(debug_assertions)]
7667 #[doc(hidden)]
7668 pub fn reader_worker_count_for_test(&self) -> usize {
7669 self.reader_pool.worker_count()
7670 }
7671
7672 #[cfg(debug_assertions)]
7673 #[doc(hidden)]
7674 pub fn live_reader_worker_count_for_test(&self) -> usize {
7675 self.reader_pool.live_count()
7676 }
7677
7678 /// Pack 6.G G.1 — return the `sqlite3_db_config(LOOKASIDE)` rc
7679 /// captured for each reader worker at open time, in worker index
7680 /// order. SQLITE_OK (= 0) means the lookaside was configured
7681 /// before any allocation happened on the connection.
7682 #[cfg(debug_assertions)]
7683 #[doc(hidden)]
7684 pub fn reader_lookaside_config_rcs_for_test(&self) -> Vec<i32> {
7685 self.reader_lookaside_rcs.clone()
7686 }
7687
7688 /// Pack 6.G G.1 — query each reader worker's
7689 /// `SQLITE_DBSTATUS_LOOKASIDE_USED` counter. A value > 0 means at
7690 /// least one allocation was satisfied from the per-connection
7691 /// lookaside arena (proof the configuration was honored before the
7692 /// first prepare).
7693 #[cfg(debug_assertions)]
7694 #[doc(hidden)]
7695 pub fn reader_lookaside_used_per_worker_for_test(&self) -> Vec<i32> {
7696 self.reader_pool.lookaside_used_per_worker()
7697 }
7698
7699 /// Pack 6.G G.3.5 — broadcast a debug-only `CacheStatus` request to
7700 /// every reader worker and collect per-worker
7701 /// `SQLITE_DBSTATUS_CACHE_HIT` / `_CACHE_MISS` / `_CACHE_USED`
7702 /// values. Counters are monotonic (reset flag = 0); callers compute
7703 /// pre/post deltas explicitly.
7704 #[cfg(debug_assertions)]
7705 #[doc(hidden)]
7706 pub fn cache_status_per_worker_for_test(&self, label: &str) -> Vec<CacheStatusReply> {
7707 self.reader_pool.cache_status_per_worker(label)
7708 }
7709
7710 #[cfg(debug_assertions)]
7711 #[doc(hidden)]
7712 pub fn force_next_commit_failure_for_test(&self) {
7713 self.force_next_commit_failure.store(true, Ordering::SeqCst);
7714 }
7715
7716 /// Force the next background projection terminal commit to fail with a
7717 /// synthetic SQLite busy error. Test-only seam for TC-91 rollback and
7718 /// redispatch coverage; it does not affect the caller's write transaction.
7719 #[cfg(debug_assertions)]
7720 #[doc(hidden)]
7721 pub fn force_next_projection_commit_failure_for_test(&self) {
7722 self.projection_runtime.force_next_projection_commit_failure_for_test();
7723 }
7724
7725 /// Force the next background projection terminal commit to fail with a
7726 /// rusqlite-layer storage error. Test-only TC-91 diagnostic classifier seam.
7727 #[cfg(debug_assertions)]
7728 #[doc(hidden)]
7729 pub fn force_next_projection_storage_failure_for_test(&self) {
7730 self.projection_runtime.force_next_projection_storage_failure_for_test();
7731 }
7732
7733 /// Pause a worker after a forced projection-commit error was reported and
7734 /// before its state cleanup. TC-91 test-only shutdown/reopen rendezvous.
7735 #[cfg(debug_assertions)]
7736 #[doc(hidden)]
7737 pub fn pause_projection_commit_failure_cleanup_for_test(
7738 &self,
7739 reported: Arc<Barrier>,
7740 release: Arc<Barrier>,
7741 ) {
7742 self.projection_runtime.pause_projection_commit_failure_cleanup_for_test(reported, release);
7743 }
7744
7745 /// Acknowledge after `Engine::close` marks the projection runtime stopping
7746 /// and before it joins workers. TC-91 test-only shutdown rendezvous.
7747 #[cfg(debug_assertions)]
7748 #[doc(hidden)]
7749 pub fn acknowledge_projection_stop_for_test(&self, acknowledged: Arc<Barrier>) {
7750 self.projection_runtime.acknowledge_projection_stop_for_test(acknowledged);
7751 }
7752
7753 /// Execute an arbitrary SQL statement on the writer connection through
7754 /// the same wall-clock + slow-detect path as `write` / `search`.
7755 ///
7756 /// Test-only helper for the deterministic-slow-cte fixture used by
7757 /// AC-007a / AC-007b. Not part of the public 0.6.0 surface; gated on
7758 /// `debug_assertions` so release builds do not expose it.
7759 #[cfg(debug_assertions)]
7760 #[doc(hidden)]
7761 pub fn execute_for_test(&self, sql: &str) -> Result<(), EngineError> {
7762 self.ensure_open()?;
7763 let started = Instant::now();
7764 {
7765 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7766 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
7767 connection.execute_batch(sql).map_err(|_| EngineError::Storage)?;
7768 }
7769 self.detect_slow(started, lifecycle::EventCategory::Search);
7770 Ok(())
7771 }
7772
7773 /// One-thread-poison robustness fixture (AC-009).
7774 ///
7775 /// Spawns four reader threads + one writer thread that all make
7776 /// forward progress (single canonical write + repeated searches),
7777 /// plus one designated poison thread that runs an empty-batch write
7778 /// — a deterministic `EngineError::WriteValidation`. The captured
7779 /// poison failure is dispatched as a `StressFailureContext` whose
7780 /// `last_error_chain` is `[EngineError::stable_code(),
7781 /// engine_error.to_string()]` per the lifecycle § Stress-failure
7782 /// context payload contract.
7783 #[doc(hidden)]
7784 #[cfg(debug_assertions)]
7785 pub fn run_one_thread_poison_for_test(&self) -> Result<(), EngineError> {
7786 self.ensure_open()?;
7787
7788 // Forward-progress writer seeds a row so readers + the poison
7789 // thread share a non-trivial canonical state.
7790 self.write(&[PreparedWrite::Node {
7791 kind: "doc".to_string(),
7792 body: "poison-fixture-seed".to_string(),
7793 source_id: SourceId::engine_derived("poison-fixture"),
7794 logical_id: None,
7795 state: InitialState::Active,
7796 reason: None,
7797 valid_from: None,
7798 valid_until: None,
7799 }])?;
7800
7801 let poison_outcome: Mutex<Option<EngineError>> = Mutex::new(None);
7802 let poison_thread_id: AtomicU64 = AtomicU64::new(0);
7803
7804 thread::scope(|scope| {
7805 // N=4 reader threads make forward progress.
7806 for _ in 0..4 {
7807 scope.spawn(|| {
7808 for _ in 0..4 {
7809 let _ = self.search("poison-fixture-seed");
7810 }
7811 });
7812 }
7813 // One forward-progress writer thread.
7814 scope.spawn(|| {
7815 let _ = self.write(&[PreparedWrite::Node {
7816 kind: "doc".to_string(),
7817 body: "writer-progress".to_string(),
7818 source_id: SourceId::engine_derived("poison-fixture"),
7819 logical_id: None,
7820 state: InitialState::Active,
7821 reason: None,
7822 valid_from: None,
7823 valid_until: None,
7824 }]);
7825 });
7826 // One poison thread — empty batch is a deterministic
7827 // WriteValidation failure.
7828 scope.spawn(|| {
7829 // Use a non-zero, deterministic group id so subscribers
7830 // see a stable identifier across runs of the fixture.
7831 poison_thread_id.store(1, Ordering::SeqCst);
7832 if let Err(err) = self.write(&[]) {
7833 *poison_outcome.lock().expect("poison_outcome lock") = Some(err);
7834 }
7835 });
7836 });
7837
7838 let err = poison_outcome
7839 .into_inner()
7840 .expect("poison_outcome lock")
7841 .expect("poison thread must produce a deterministic error");
7842
7843 let projection_state = match self.projection_status_for_test("doc") {
7844 Ok(lifecycle::ProjectionStatus::Pending) => "Pending",
7845 Ok(lifecycle::ProjectionStatus::Failed) => "Failed",
7846 Ok(lifecycle::ProjectionStatus::UpToDate) => "UpToDate",
7847 // Default to UpToDate when projection status is unobservable
7848 // (e.g. embedder not configured for the seed kind). The
7849 // value is still one of the documented enum stringifications
7850 // per AC-010.
7851 Err(_) => "UpToDate",
7852 };
7853
7854 let context = lifecycle::StressFailureContext {
7855 thread_group_id: poison_thread_id.load(Ordering::SeqCst),
7856 op_kind: "write".to_string(),
7857 last_error_chain: vec![err.stable_code().to_string(), err.to_string()],
7858 projection_state: projection_state.to_string(),
7859 };
7860 self.subscribers.dispatch_stress_failure(&context);
7861 Ok(())
7862 }
7863
7864 #[doc(hidden)]
7865 pub fn set_projection_scheduler_frozen_for_test(&self, frozen: bool) {
7866 self.projection_runtime.set_frozen(frozen);
7867 }
7868
7869 /// Test-only snapshot of whether the dispatcher has a scan wake pending.
7870 ///
7871 /// This exists to prove pure observers do not notify the scheduler. It is
7872 /// deliberately narrower than a scheduler control or diagnostic surface.
7873 #[doc(hidden)]
7874 pub fn projection_scheduler_pending_scan_for_test(&self) -> bool {
7875 self.projection_runtime.pending_scan_for_test()
7876 }
7877
7878 #[doc(hidden)]
7879 pub fn set_projection_retry_delays_for_test(&self, delays_ms: &[u64]) {
7880 self.projection_runtime.set_retry_delays_for_test(delays_ms);
7881 }
7882
7883 /// PR-9 — lower the ADR-0.6.0 Invariant 5 per-`embed()` watchdog deadline
7884 /// for tests (production default is `DEFAULT_EMBED_TIMEOUT_MS` = 30s).
7885 #[doc(hidden)]
7886 pub fn set_embed_timeout_ms_for_test(&self, timeout_ms: u64) {
7887 self.projection_runtime.set_embed_timeout_ms_for_test(timeout_ms);
7888 }
7889
7890 /// PR-9 — lower the embed circuit-breaker threshold for tests (production
7891 /// default `DEFAULT_EMBED_CIRCUIT_THRESHOLD`); 0 disables the breaker.
7892 #[doc(hidden)]
7893 pub fn set_embed_circuit_threshold_for_test(&self, threshold: u64) {
7894 self.projection_runtime.set_embed_circuit_threshold_for_test(threshold);
7895 }
7896
7897 /// PR-9 — whether the embed circuit breaker has latched open.
7898 #[doc(hidden)]
7899 pub fn embed_circuit_open_for_test(&self) -> bool {
7900 self.projection_runtime.embed_circuit_open_for_test()
7901 }
7902
7903 #[doc(hidden)]
7904 pub fn projection_status_for_test(
7905 &self,
7906 kind: &str,
7907 ) -> Result<lifecycle::ProjectionStatus, EngineError> {
7908 self.ensure_open()?;
7909 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7910 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7911 projection_status(connection, kind)
7912 }
7913
7914 #[doc(hidden)]
7915 pub fn has_vector_for_cursor_for_test(&self, cursor: u64) -> Result<bool, EngineError> {
7916 self.ensure_open()?;
7917 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7918 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7919 terminal_state_for_cursor(connection, cursor)
7920 .map(|state| matches!(state.as_deref(), Some("up_to_date")))
7921 .map_err(|_| EngineError::Storage)
7922 }
7923
7924 #[doc(hidden)]
7925 pub fn projection_failure_count_for_test(&self, cursor: u64) -> Result<u64, EngineError> {
7926 self.ensure_open()?;
7927 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7928 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7929 connection
7930 .query_row(
7931 "SELECT COUNT(*) FROM operational_mutations
7932 WHERE collection_name = 'projection_failures'
7933 AND record_key = ?1",
7934 [cursor.to_string()],
7935 |row| row.get::<_, u64>(0),
7936 )
7937 .map_err(|_| EngineError::Storage)
7938 }
7939
7940 #[doc(hidden)]
7941 pub fn set_provenance_row_cap_for_test(&self, cap: Option<u64>) {
7942 self.provenance_row_cap.store(cap.unwrap_or(0), Ordering::Relaxed);
7943 }
7944
7945 #[doc(hidden)]
7946 pub fn provenance_row_count_for_test(&self) -> Result<u64, EngineError> {
7947 self.ensure_open()?;
7948 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7949 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7950 connection
7951 .query_row("SELECT COUNT(*) FROM operational_mutations", [], |row| row.get::<_, u64>(0))
7952 .map_err(|_| EngineError::Storage)
7953 }
7954
7955 #[doc(hidden)]
7956 pub fn oldest_provenance_record_key_for_test(
7957 &self,
7958 collection: &str,
7959 ) -> Result<Option<String>, EngineError> {
7960 self.ensure_open()?;
7961 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7962 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7963 connection
7964 .query_row(
7965 "SELECT record_key FROM operational_mutations
7966 WHERE collection_name = ?1
7967 ORDER BY id
7968 LIMIT 1",
7969 [collection],
7970 |row| row.get::<_, String>(0),
7971 )
7972 .map(Some)
7973 .or_else(|err| match err {
7974 rusqlite::Error::QueryReturnedNoRows => Ok(None),
7975 _ => Err(EngineError::Storage),
7976 })
7977 }
7978
7979 #[doc(hidden)]
7980 pub fn configure_vector_kind_for_test(&self, kind: &str) -> Result<(), EngineError> {
7981 self.ensure_open()?;
7982 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7983 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
7984 connection
7985 .execute(
7986 "INSERT OR REPLACE INTO _fathomdb_vector_kinds(kind, profile, created_at)
7987 VALUES(?1, ?2, 0)",
7988 params![kind, DEFAULT_VECTOR_PROFILE],
7989 )
7990 .map_err(|_| EngineError::Storage)?;
7991 Ok(())
7992 }
7993
7994 /// OPP-12 Phase-1 (0.8.19 Slice 10) — read the writer connection's
7995 /// `PRAGMA secure_delete` (design §3 gap-4). `true` iff the standing
7996 /// connection-open PRAGMA is in effect, so `purge` freelist erasure is
7997 /// complete without a per-purge `VACUUM`.
7998 #[doc(hidden)]
7999 pub fn secure_delete_enabled_for_test(&self) -> Result<bool, EngineError> {
8000 self.ensure_open()?;
8001 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8002 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8003 let value: i64 = connection
8004 .query_row("PRAGMA secure_delete", [], |r| r.get(0))
8005 .map_err(|_| EngineError::Storage)?;
8006 Ok(value != 0)
8007 }
8008
8009 /// OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — `true` iff EVERY
8010 /// reader-pool connection reports `PRAGMA secure_delete = ON`. Broadcasts a
8011 /// per-worker probe; proves the standing flag is set on the non-writer
8012 /// connections (which perform projection/vector-rewrite DELETEs), closing
8013 /// the GDPR-erasure leak codex flagged.
8014 #[cfg(debug_assertions)]
8015 #[doc(hidden)]
8016 pub fn reader_secure_delete_enabled_for_test(&self) -> Result<bool, EngineError> {
8017 self.ensure_open()?;
8018 let per_worker = self.reader_pool.secure_delete_per_worker();
8019 if per_worker.is_empty() {
8020 return Err(EngineError::Storage);
8021 }
8022 Ok(per_worker.iter().all(|&v| v == 1))
8023 }
8024
8025 /// OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — `true` iff a freshly
8026 /// opened projection/runtime connection (`open_runtime_connection`) reports
8027 /// `PRAGMA secure_delete = ON`. The runtime connection performs the
8028 /// vector-rewrite/projection DELETEs, so its freed pages must be scrubbed too.
8029 #[doc(hidden)]
8030 pub fn runtime_secure_delete_enabled_for_test(&self) -> Result<bool, EngineError> {
8031 self.ensure_open()?;
8032 let connection = open_runtime_connection(&self.path).map_err(|_| EngineError::Storage)?;
8033 let value: i64 = connection
8034 .query_row("PRAGMA secure_delete", [], |r| r.get(0))
8035 .map_err(|_| EngineError::Storage)?;
8036 Ok(value != 0)
8037 }
8038
8039 /// EXP-S (0.8.14 Slice 5, D1) — write one canonical node row carrying an
8040 /// explicit structural `row_kind` (leaf/coverage/graph), routing the index
8041 /// projection through the SAME `row_kind -> index-target` dispatch seam
8042 /// (`project_canonical_node_row`) as the production `leaf` write path.
8043 ///
8044 /// This is the internal-only writer for `coverage`/`graph` rows (there is no
8045 /// public SDK surface for `row_kind` in 0.8.14). Cursor assignment preserves
8046 /// the `rowid == write_cursor == cursor` determinism identity. When the row
8047 /// projects into an async vector index, the worker pool is notified so the
8048 /// embed is scheduled exactly as for a normal write.
8049 #[doc(hidden)]
8050 pub fn write_canonical_row_with_kind_for_test(
8051 &self,
8052 kind: &str,
8053 body: &str,
8054 row_kind: RowKind,
8055 ) -> Result<WriteReceipt, EngineError> {
8056 self.ensure_open()?;
8057 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8058 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8059
8060 // R-20-E3 / design §4 item 6 — this writer BYPASSES `PreparedWrite`, so
8061 // the `SourceId` newtype cannot reach it; before 0.8.20 it inserted a
8062 // literal NULL `source_id` and produced a row that no `excise_source`
8063 // call could reach. Engine-derived rows instead take a reserved
8064 // `_engine:*` provenance, keyed by the structural role that produced
8065 // them, so they are both erasable and distinguishable from caller data.
8066 let engine_provenance = SourceId::engine_derived(row_kind.as_str());
8067
8068 // 0.8.20 Slice 20c — same late enrolment the governed write path takes
8069 // (`Engine::enrol_batch_vector_kinds`), so this internal writer does not
8070 // silently diverge into the false-ready barrier for `coverage` rows. The
8071 // live-embedder precondition is checked here, as that caller does; the
8072 // `row_kind` gate keeps `graph` rows out of the vector registry.
8073 //
8074 // fix-2 (codex §9 [P2]) — including the un-stranding half, so this door
8075 // cannot diverge from the other one either. fix-5 (codex §9 round 4 [P2])
8076 // — and both halves commit as ONE transaction, via the same shared
8077 // `enrol_and_unstrand`.
8078 let unstranded = if self.usable_dense_runtime()
8079 && self.vector_kind_needs_enrolment(connection, kind, row_kind)?
8080 {
8081 self.enrol_and_unstrand(connection, &[kind])?
8082 } else {
8083 false
8084 };
8085
8086 let cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
8087 let enqueued = {
8088 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8089 // 0.8.20 Slice 15b (TC-34) — this writer takes NO validity window, and
8090 // that is deliberate rather than an oversight. It is a `#[doc(hidden)]`
8091 // test-only writer for the internal `coverage`/`graph` row kinds, which
8092 // have no public SDK surface at all (see the doc comment above); the
8093 // caller-facing authoring path is `PreparedWrite::Node`, handled in
8094 // `commit_batch`. Omitting the columns binds NULL — the migration
8095 // step-22 default and the UNBOUNDED reading — so engine-derived rows
8096 // stay valid at every instant, which is the only correct answer for a
8097 // structural row that no caller can address a window to.
8098 tx.execute(
8099 "INSERT INTO canonical_nodes(write_cursor, kind, body, source_id, logical_id, row_kind)
8100 VALUES(?1, ?2, ?3, ?4, NULL, ?5)",
8101 params![cursor, kind, body, engine_provenance.as_str(), row_kind.as_str()],
8102 )
8103 .map_err(|_| EngineError::Storage)?;
8104 let enqueued = project_canonical_node_row(
8105 &tx,
8106 cursor,
8107 kind,
8108 body,
8109 row_kind,
8110 ProjectionPass::Write,
8111 // This #[doc(hidden)] writer inserts with the column DEFAULT
8112 // `state = 'active'` (no state column in its INSERT), so the row
8113 // is always active and its attributes project.
8114 true,
8115 )
8116 .map_err(|_| EngineError::Storage)?;
8117 advance_projection_cursor(&tx).map_err(|_| EngineError::Storage)?;
8118 tx.commit().map_err(|_| EngineError::Storage)?;
8119 enqueued
8120 };
8121 self.next_cursor.store(cursor, Ordering::SeqCst);
8122 if enqueued || unstranded {
8123 self.projection_runtime.notify_new_work();
8124 }
8125 Ok(WriteReceipt { cursor, row_cursors: vec![cursor], dangling_edge_endpoints: 0 })
8126 }
8127
8128 /// EXP-S (0.8.14 Slice 5, D1) — select the active canonical rows carrying a
8129 /// given `row_kind`, returning their `write_cursor`s in cursor order. Proves
8130 /// the engine can query/select rows by the structural `row_kind` axis.
8131 #[doc(hidden)]
8132 pub fn canonical_rows_with_row_kind_for_test(
8133 &self,
8134 row_kind: RowKind,
8135 ) -> Result<Vec<u64>, EngineError> {
8136 self.ensure_open()?;
8137 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8138 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8139 let mut stmt = connection
8140 .prepare(
8141 "SELECT write_cursor FROM canonical_nodes
8142 WHERE row_kind = ?1 AND superseded_at IS NULL
8143 ORDER BY write_cursor",
8144 )
8145 .map_err(|_| EngineError::Storage)?;
8146 let cursors = stmt
8147 .query_map(params![row_kind.as_str()], |row| row.get::<_, u64>(0))
8148 .map_err(|_| EngineError::Storage)?
8149 .collect::<rusqlite::Result<Vec<u64>>>()
8150 .map_err(|_| EngineError::Storage)?;
8151 Ok(cursors)
8152 }
8153
8154 /// F5 (0.8.14 Slice 10) — the fielded BM25F lexical arm over
8155 /// `search_index_v2`. Recalls candidate rows through the FTS5 index
8156 /// (`search_index_v2 MATCH`) and scores them with a textbook BM25F using the
8157 /// plan's tunable per-field `weights` and tunable `b`/`k1`, returning
8158 /// `(write_cursor, score)` in descending score order (write_cursor asc as the
8159 /// deterministic tiebreak). Superseded node versions are excluded (join to
8160 /// `canonical_nodes WHERE superseded_at IS NULL`).
8161 ///
8162 /// This is the engine-internal `BM25fQueryPlan` compiler path (`ADR-0.8.1`
8163 /// §3.2); there is no public Py/TS SDK surface this release. The score is
8164 /// computed in-engine (not via SQLite's `bm25()`, which cannot express a
8165 /// tunable `b`); the FTS5 index remains load-bearing for candidate recall.
8166 #[doc(hidden)]
8167 pub fn bm25f_search(
8168 &self,
8169 query: &str,
8170 plan: &Bm25fQueryPlan,
8171 ) -> Result<Vec<(u64, f64)>, EngineError> {
8172 self.ensure_open()?;
8173 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8174 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8175 bm25f_search_inner(connection, query, plan).map_err(|_| EngineError::Storage)
8176 }
8177
8178 /// Embed arbitrary text with the engine's configured runtime embedder,
8179 /// returning the raw (un-centered) vector.
8180 ///
8181 /// This is the read-path embed primitive: it mirrors the search
8182 /// query-embedding path — a single, direct [`Embedder::embed`] call. The
8183 /// per-`embed()` watchdog/circuit-breaker guards only the bulk
8184 /// projection/write path (many embeds, fault isolation), not single
8185 /// read-side embeds, so a direct call is consistent with how a query is
8186 /// embedded. Callers get vectors under the engine's *pinned* embedder
8187 /// identity (`fathomdb-bge-small-en-v1.5` by default) rather than a
8188 /// parallel, possibly-divergent embedder.
8189 ///
8190 /// Returns [`EngineError::EmbedderNotConfigured`] if the engine was opened
8191 /// without an embedder (`use_default_embedder = false`).
8192 pub fn embed_text(&self, text: &str) -> Result<Vec<f32>, EngineError> {
8193 self.ensure_open()?;
8194 let embedder =
8195 self.runtime_embedder.as_ref().cloned().ok_or(EngineError::EmbedderNotConfigured)?;
8196 embedder.embed(text).map_err(map_runtime_embedder_error)
8197 }
8198
8199 #[doc(hidden)]
8200 pub fn write_vector_for_test(
8201 &self,
8202 kind: &str,
8203 text: &str,
8204 ) -> Result<WriteReceipt, EngineError> {
8205 self.ensure_open()?;
8206 let embedder =
8207 self.runtime_embedder.as_ref().cloned().ok_or(EngineError::EmbedderNotConfigured)?;
8208
8209 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8210 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8211 if !kind_is_vector_indexed(connection, kind)? {
8212 return Err(EngineError::KindNotVectorIndexed);
8213 }
8214
8215 let expected = default_profile_dimension(connection)?;
8216 ensure_vector_partition(connection, expected).map_err(|_| EngineError::Storage)?;
8217 let vector = embedder.embed(text).map_err(map_runtime_embedder_error)?;
8218 let actual = u32::try_from(vector.len()).unwrap_or(u32::MAX);
8219 if actual != expected {
8220 return Err(EngineError::EmbedderDimensionMismatch { expected, actual });
8221 }
8222
8223 let cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
8224 // EU-5a2 mean-centering apply path (write side). f32 BLOB stored
8225 // is ALWAYS un-centered; the sign-quant input is the centered
8226 // vector iff the identity is MC-required AND a `mean_vec` is
8227 // pinned. NoopEmbedder identity (the only EU-5a2 live one) is
8228 // NOT MC-required, so this is a no-op until EU-5b's flip.
8229 let blob = encode_vector_blob(&vector);
8230 let bin_blob = if identity_requires_mean_centering(&self.runtime_embedder_identity) {
8231 match read_pinned_mean_vec(connection, self.runtime_embedder_identity.dimension)? {
8232 Some(mean) => encode_vector_blob(&subtract_mean(&vector, &mean)),
8233 None => blob.clone(),
8234 }
8235 } else {
8236 blob.clone()
8237 };
8238 let source_type = resolve_source_type(kind)?;
8239 let now_unix =
8240 SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
8241
8242 // EU-5b — feed the streaming mean accumulator (if live) and detect
8243 // a threshold-crossing pin. The mean materialization, pre-pin
8244 // re-quantize, and `MeanVecPinned` event emission all happen in
8245 // the SAME SQLite transaction as the row INSERT.
8246 let pin_event = {
8247 let runtime = &self.projection_runtime.shared;
8248 let mut accumulator =
8249 runtime.mean_accumulator.lock().map_err(|_| EngineError::Storage)?;
8250 if let Some(acc) = accumulator.as_mut() {
8251 acc.add(&vector);
8252 if acc.count() >= MEAN_VEC_PIN_THRESHOLD {
8253 let mean = acc.materialize();
8254 *accumulator = None;
8255 Some(mean)
8256 } else {
8257 None
8258 }
8259 } else {
8260 None
8261 }
8262 };
8263
8264 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8265 tx.execute(
8266 "INSERT INTO _fathomdb_vector_rows(rowid, kind, write_cursor) VALUES(?1, ?2, ?3)",
8267 params![cursor, kind, cursor],
8268 )
8269 .map_err(|_| EngineError::Storage)?;
8270 // Slice 10 / G10 — `status` ships an empty-string sentinel only: vec0 TEXT
8271 // metadata columns are NOT NULL-able ("Expected text for TEXT metadata
8272 // column"), so the "no real population yet" state is `''`, not NULL.
8273 //
8274 // 0.8.20 Slice 15e — this test helper carries no JSON body, so every live
8275 // `filterable` `attr_<hex>` column binds the `''` sentinel (an empty body
8276 // extracts nothing). When the table has no attr columns the statement is
8277 // byte-identical to the shipped form.
8278 let (cols_sql, ph_sql, attr_vals) =
8279 vector_attr_insert_fragments(&tx, "", 7).map_err(|_| EngineError::Storage)?;
8280 let sql = format!(
8281 "INSERT INTO vector_default(
8282 rowid, embedding, embedding_bin, source_type, kind, created_at, status{cols_sql}
8283 ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, ''{ph_sql})"
8284 );
8285 let mut pv: Vec<rusqlite::types::Value> = vec![
8286 rusqlite::types::Value::Integer(cursor as i64),
8287 rusqlite::types::Value::Blob(blob.clone()),
8288 rusqlite::types::Value::Blob(bin_blob.clone()),
8289 rusqlite::types::Value::Text(source_type.to_string()),
8290 rusqlite::types::Value::Text(kind.to_string()),
8291 rusqlite::types::Value::Integer(now_unix),
8292 ];
8293 pv.extend(attr_vals);
8294 tx.execute(&sql, rusqlite::params_from_iter(pv.iter()))
8295 .map_err(|_| EngineError::Storage)?;
8296
8297 let mut emitted_event: Option<EmbedderEvent> = None;
8298 if let Some(mean_vec) = pin_event {
8299 let mean_bytes = encode_vector_blob(&mean_vec);
8300 tx.execute(
8301 "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
8302 params![mean_bytes],
8303 )
8304 .map_err(|_| EngineError::Storage)?;
8305 // Read all pre-pin (rowid, embedding) and re-quantize within
8306 // the same tx. The just-inserted row above is also covered.
8307 let rows: Vec<(i64, Vec<u8>)> = {
8308 let mut statement = tx
8309 .prepare("SELECT rowid, embedding FROM vector_default ORDER BY rowid")
8310 .map_err(|_| EngineError::Storage)?;
8311 let mapped = statement
8312 .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)))
8313 .map_err(|_| EngineError::Storage)?;
8314 let mut out = Vec::new();
8315 for r in mapped {
8316 out.push(r.map_err(|_| EngineError::Storage)?);
8317 }
8318 out
8319 };
8320 let (doc_count, _) = run_pin_and_requantize_pass(&tx, &rows, &mean_vec)?;
8321 emitted_event = Some(EmbedderEvent::MeanVecPinned {
8322 dim: u32::try_from(mean_vec.len()).unwrap_or(u32::MAX),
8323 doc_count,
8324 });
8325 }
8326
8327 tx.commit().map_err(|_| EngineError::Storage)?;
8328
8329 if let Some(ev) = emitted_event {
8330 if let Ok(mut events) = self.projection_runtime.shared.pending_events.lock() {
8331 events.push(ev);
8332 }
8333 }
8334
8335 self.next_cursor.store(cursor, Ordering::SeqCst);
8336 // G8 — this path (embedder-profile pin) commits no canonical edges, so
8337 // no endpoint can dangle.
8338 Ok(WriteReceipt { cursor, row_cursors: vec![cursor], dangling_edge_endpoints: 0 })
8339 }
8340
8341 /// EU-5b test seam — drain MeanVecPinned events queued by the
8342 /// projection-commit pin transaction since the last drain. Production
8343 /// callers consume these via `OpenReport.embedder_events`; this seam
8344 /// exists so the EU-5b RED test can observe the live emission.
8345 #[doc(hidden)]
8346 pub fn drain_mean_centering_events_for_test(&self) -> Result<Vec<EmbedderEvent>, EngineError> {
8347 self.ensure_open()?;
8348 let mut events = self
8349 .projection_runtime
8350 .shared
8351 .pending_events
8352 .lock()
8353 .map_err(|_| EngineError::Storage)?;
8354 let out = std::mem::take(&mut *events);
8355 Ok(out)
8356 }
8357
8358 /// 0.7.2 PR-2b — NON-test observation seam. Drains and returns every
8359 /// `EmbedderEvent` queued since the last drain (mean pin, manual mean
8360 /// recompute). Production callers use
8361 /// this to observe the synchronous recompute work; events are queued
8362 /// only AFTER the recompute transaction is durable, so a rolled-back
8363 /// recompute never surfaces. Mirrors the at-open
8364 /// `OpenReport.embedder_events` channel for the steady-state path.
8365 pub fn drain_embedder_events(&self) -> Result<Vec<EmbedderEvent>, EngineError> {
8366 self.ensure_open()?;
8367 let mut events = self
8368 .projection_runtime
8369 .shared
8370 .pending_events
8371 .lock()
8372 .map_err(|_| EngineError::Storage)?;
8373 Ok(std::mem::take(&mut *events))
8374 }
8375
8376 /// 0.7.2 PR-2b — explicit `doctor recompute-mean` path. Re-derives the
8377 /// pinned corpus mean from the current `vector_default` rows and
8378 /// re-quantizes every row, SYNCHRONOUSLY in one transaction. ALWAYS
8379 /// allowed at any corpus size — this is the ONLY mean-refresh path as of
8380 /// 0.7.2 (the automatic in-ingest drift detector was carved out / deferred
8381 /// to 0.8.x; see `dev/design/embedder.md` §0.3).
8382 ///
8383 /// Serializes against the projection workers via `commit_gate` so the
8384 /// re-quantize sees a totally-ordered history, exactly like the at-pin
8385 /// commit. Publishes a `MeanVecRecomputed { trigger: Manual }` event
8386 /// only after the transaction is durable. No-op-safe on a non-MC
8387 /// identity (returns `EmbedderNotConfigured` rather than corrupting an
8388 /// un-centered workspace).
8389 #[cfg(feature = "operator")]
8390 pub fn recompute_mean(&self) -> Result<MeanRecomputeReport, EngineError> {
8391 self.ensure_open()?;
8392 let identity = self.runtime_embedder_identity.clone();
8393 if !identity_requires_mean_centering(&identity) {
8394 return Err(EngineError::EmbedderNotConfigured);
8395 }
8396 let report = {
8397 // Hold the commit gate for the whole recompute so no projection
8398 // worker commit interleaves with the re-quantize.
8399 let _gate = self
8400 .projection_runtime
8401 .shared
8402 .commit_gate
8403 .lock()
8404 .unwrap_or_else(|p| p.into_inner());
8405 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8406 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8407 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8408 #[cfg(debug_assertions)]
8409 let fail = self
8410 .projection_runtime
8411 .shared
8412 .force_recompute_failure
8413 .swap(false, Ordering::SeqCst);
8414 #[cfg(not(debug_assertions))]
8415 let fail = false;
8416 let report = recompute_mean_in_tx_inner(&tx, &identity, fail)?;
8417 tx.commit().map_err(|_| EngineError::Storage)?;
8418 report
8419 };
8420 // Post-durable-commit publish.
8421 if let Ok(mut events) = self.projection_runtime.shared.pending_events.lock() {
8422 events.push(EmbedderEvent::MeanVecRecomputed {
8423 dim: report.dim,
8424 doc_count: report.doc_count_requantized,
8425 trigger: MeanRecomputeTrigger::Manual,
8426 });
8427 }
8428 Ok(report)
8429 }
8430
8431 /// Test seam that raises vector-candidate fanout for recall tests.
8432 ///
8433 /// This does not alter the caller-requested final result limit or the
8434 /// caller-visible result cardinality. Production uses the default and
8435 /// never consults an environment variable.
8436 #[doc(hidden)]
8437 pub fn set_search_limit_for_test(&self, limit: usize) {
8438 self.projection_runtime.shared.search_limit_override.store(limit, Ordering::SeqCst);
8439 }
8440
8441 /// Slice 10 / G12-recency test seam — flip the dedicated recency-reweight
8442 /// flag (off by default). The reweight runs AFTER bit-KNN on the fused hits;
8443 /// it is never a vec0 predicate and is NOT `fusion_mode`.
8444 #[doc(hidden)]
8445 pub fn set_recency_reweight_enabled_for_test(&self, enabled: bool) {
8446 self.projection_runtime.shared.recency_reweight_enabled.store(enabled, Ordering::SeqCst);
8447 }
8448
8449 /// 0.8.16 Slice 5 / F9 test seam — flip the dedicated importance/confidence
8450 /// reweight flag (off by default). The reweight runs AFTER bit-KNN + RRF on
8451 /// the fused hits (multiplicative-on-fused, `NULL ⇒ neutral`); it is never a
8452 /// vec0 predicate and is NOT `fusion_mode`. Mirrors
8453 /// `set_recency_reweight_enabled_for_test`.
8454 #[doc(hidden)]
8455 pub fn set_importance_reweight_enabled_for_test(&self, enabled: bool) {
8456 self.projection_runtime.shared.importance_reweight_enabled.store(enabled, Ordering::SeqCst);
8457 }
8458
8459 /// 0.8.16 Slice 5 / F9 (R-F9-1) — set the caller-supplied `importance` ranking
8460 /// scalar on the `canonical_nodes` row identified by `write_cursor` (the
8461 /// interim id `SearchHit.id` carries). Validates `importance ∈ [0.0, 1.0]`,
8462 /// mirroring the existing `canonical_edges.confidence` write-path check —
8463 /// an out-of-range value is a deterministic [`EngineError::WriteValidation`].
8464 ///
8465 /// The 3-way sentinel: NOT calling this leaves the column `NULL` (never
8466 /// assigned = graceful-absent, ranks NEUTRAL); `0.0` is the explicit floor;
8467 /// `(0.0, 1.0]` is an explicit importance. Importance is a caller-supplied
8468 /// scalar — the engine does NOT compute graph-centrality importance (ADR §4
8469 /// non-goal). Engine-internal minimal surface for this keystone; SDK (Py/TS)
8470 /// exposure is a Slice-40 concern.
8471 pub fn write_node_importance(
8472 &self,
8473 write_cursor: u64,
8474 importance: f64,
8475 ) -> Result<(), EngineError> {
8476 if !importance.is_finite() || !(0.0..=1.0).contains(&importance) {
8477 return Err(EngineError::WriteValidation);
8478 }
8479 self.ensure_open()?;
8480 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8481 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8482 connection
8483 .execute(
8484 "UPDATE canonical_nodes SET importance = ?1 WHERE write_cursor = ?2",
8485 params![importance, write_cursor],
8486 )
8487 .map_err(|_| EngineError::Storage)?;
8488 Ok(())
8489 }
8490
8491 /// 0.8.16 Slice 5 / F9 (R-F9-1) — read back the `importance` scalar for the
8492 /// `canonical_nodes` row identified by `write_cursor`. `None` = SQL `NULL` =
8493 /// never assigned (graceful-absent). The reciprocal read for
8494 /// [`Engine::write_node_importance`].
8495 pub fn node_importance(&self, write_cursor: u64) -> Result<Option<f64>, EngineError> {
8496 self.ensure_open()?;
8497 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8498 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8499 connection
8500 .query_row(
8501 "SELECT importance FROM canonical_nodes WHERE write_cursor = ?1 LIMIT 1",
8502 params![write_cursor],
8503 |r| r.get::<_, Option<f64>>(0),
8504 )
8505 .map_err(|_| EngineError::Storage)
8506 }
8507
8508 /// GA-2 / Slice-40 (◆ B-1) measurement seam — make `search()` return the
8509 /// pre-fusion VECTOR-branch ranking (the ANN+ bit-KNN K=192 + f32 rerank
8510 /// signal) instead of the unconditional RRF-fused result, so the eu7 recall
8511 /// gate (AC-075) can measure ANN-quantization FIDELITY — vector top-10 vs
8512 /// the exact-f32 VECTOR top-10 ground truth — in isolation. Off by default;
8513 /// never set on any production path. This is NOT a `fusion_mode` knob:
8514 /// production RRF fusion stays unconditional and `fuse_rrf`/`rerank_fused`/
8515 /// recency are unchanged. Mirrors `set_recency_reweight_enabled_for_test`
8516 /// (release-available, since eu7 runs in `--release`).
8517 #[doc(hidden)]
8518 pub fn set_vector_stage_only_for_test(&self, enabled: bool) {
8519 self.projection_runtime.shared.vector_stage_only_for_test.store(enabled, Ordering::SeqCst);
8520 }
8521
8522 /// 0.7.2 PR-2b test seam — arm a one-shot fault inside the NEXT
8523 /// `recompute_mean` so it errors after the `mean_vec` UPDATE but before
8524 /// the re-quantize completes. Proves the recompute tx rolls back whole.
8525 #[doc(hidden)]
8526 #[cfg(debug_assertions)]
8527 pub fn force_next_recompute_failure_for_test(&self) {
8528 self.projection_runtime.shared.force_recompute_failure.store(true, Ordering::SeqCst);
8529 }
8530
8531 #[doc(hidden)]
8532 pub fn vector_row_count_for_test(&self) -> Result<u64, EngineError> {
8533 self.ensure_open()?;
8534 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8535 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8536 connection
8537 .query_row("SELECT COUNT(*) FROM vector_default", [], |row| row.get::<_, u64>(0))
8538 .map_err(|_| EngineError::Storage)
8539 }
8540
8541 #[doc(hidden)]
8542 pub fn read_vector_blob_for_test(&self, rowid: i64) -> Result<Vec<u8>, EngineError> {
8543 self.ensure_open()?;
8544 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8545 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8546 connection
8547 .query_row("SELECT embedding FROM vector_default WHERE rowid = ?1", [rowid], |row| {
8548 row.get::<_, Vec<u8>>(0)
8549 })
8550 .map_err(|_| EngineError::Storage)
8551 }
8552
8553 /// 0.8.20 Slice 15e — read a row's raw `embedding_bin` blob bytes (the
8554 /// sign-quantized vector). Used to prove the non-destructive reshape copies the
8555 /// bits VERBATIM (condition #4): the pre-reshape and post-reshape bytes must be
8556 /// byte-identical.
8557 #[doc(hidden)]
8558 pub fn read_vector_bin_for_test(&self, rowid: i64) -> Result<Vec<u8>, EngineError> {
8559 self.ensure_open()?;
8560 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8561 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8562 connection
8563 .query_row(
8564 "SELECT embedding_bin FROM vector_default WHERE rowid = ?1",
8565 [rowid],
8566 |row| row.get::<_, Vec<u8>>(0),
8567 )
8568 .map_err(|_| EngineError::Storage)
8569 }
8570
8571 /// 0.8.20 Slice 15e — run an arbitrary read-only SELECT on the ENGINE
8572 /// connection (which has the vec0 extension loaded, unlike a bare
8573 /// `Connection::open`) and collect column 0 as `i64`. Lets a test run a
8574 /// phase-1-style KNN `MATCH ... {attr clause}` and observe which `rowid`s
8575 /// survive the pre-KNN filter.
8576 #[doc(hidden)]
8577 pub fn query_i64_col_for_test(&self, sql: &str) -> Result<Vec<i64>, EngineError> {
8578 self.ensure_open()?;
8579 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8580 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8581 let mut stmt = connection.prepare(sql).map_err(|_| EngineError::Storage)?;
8582 let rows =
8583 stmt.query_map([], |row| row.get::<_, i64>(0)).map_err(|_| EngineError::Storage)?;
8584 rows.collect::<rusqlite::Result<Vec<i64>>>().map_err(|_| EngineError::Storage)
8585 }
8586
8587 /// 0.8.20 Slice 15e — as [`query_i64_col_for_test`] but collects column 0 as
8588 /// `String` (e.g. an `attr_<hex>` metadata column's stored value).
8589 #[doc(hidden)]
8590 pub fn query_text_col_for_test(&self, sql: &str) -> Result<Vec<String>, EngineError> {
8591 self.ensure_open()?;
8592 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8593 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8594 let mut stmt = connection.prepare(sql).map_err(|_| EngineError::Storage)?;
8595 let rows =
8596 stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|_| EngineError::Storage)?;
8597 rows.collect::<rusqlite::Result<Vec<String>>>().map_err(|_| EngineError::Storage)
8598 }
8599
8600 #[doc(hidden)]
8601 pub fn default_embedder_profile_for_test(&self) -> Result<EmbedderIdentity, EngineError> {
8602 self.ensure_open()?;
8603 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8604 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8605 load_default_profile(connection).map_err(|_| EngineError::Storage)
8606 }
8607
8608 /// Doctor read-only integrity report. Three-section output per
8609 /// AC-043a/b. `opts.full` adds `PRAGMA integrity_check`. `quick` and
8610 /// `round_trip` are accepted but treated as default for 0.6.0.
8611 #[cfg(feature = "operator")]
8612 pub fn check_integrity(
8613 &self,
8614 opts: CheckIntegrityOpts,
8615 ) -> Result<IntegrityReport, EngineError> {
8616 self.ensure_open()?;
8617 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8618 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8619 Ok(IntegrityReport {
8620 physical: physical_section(connection, opts.full),
8621 logical: logical_section(connection),
8622 semantic: semantic_section(connection),
8623 })
8624 }
8625
8626 /// Doctor bit-preserving export. Runs `VACUUM INTO` to produce a
8627 /// self-contained SQLite file at `out`, computes SHA-256 of the
8628 /// resulting bytes, and writes a JSON manifest at `manifest`. Per
8629 /// AC-039a/b.
8630 #[cfg(feature = "operator")]
8631 pub fn safe_export(
8632 &self,
8633 out: &Path,
8634 manifest: &Path,
8635 ) -> Result<SafeExportArtifact, EngineError> {
8636 self.ensure_open()?;
8637 {
8638 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8639 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8640 let target = out.to_string_lossy().to_string();
8641 connection
8642 .execute("VACUUM INTO ?1", params![target])
8643 .map_err(|_| EngineError::Storage)?;
8644 }
8645 let bytes = std::fs::read(out).map_err(|_| EngineError::Storage)?;
8646 let digest = sha2::Sha256::digest(&bytes);
8647 let sha256_hex = hex_encode(digest.as_slice());
8648 let export_abs = out.canonicalize().unwrap_or_else(|_| out.to_path_buf());
8649 let manifest_json = serde_json::json!({
8650 "export_path": export_abs.to_string_lossy(),
8651 "sha256": sha256_hex,
8652 "byte_count": bytes.len() as u64,
8653 });
8654 let manifest_bytes =
8655 serde_json::to_vec_pretty(&manifest_json).map_err(|_| EngineError::Storage)?;
8656 std::fs::write(manifest, &manifest_bytes).map_err(|_| EngineError::Storage)?;
8657 Ok(SafeExportArtifact {
8658 export_path: out.to_path_buf(),
8659 manifest_path: manifest.to_path_buf(),
8660 manifest_sha256: sha256_hex,
8661 })
8662 }
8663
8664 /// Operator regenerate workflow per `dev/design/projections.md`
8665 /// § Regenerate workflow. Drains in-flight projection work, then
8666 /// truncates FTS5 + vec0 shadow rows, resets the projection cursor,
8667 /// and lets the scheduler re-enqueue every canonical row. Durable
8668 /// `projection_failures` audit rows are preserved per design. AC-044
8669 /// + AC-063c.
8670 #[cfg(feature = "operator")]
8671 pub fn rebuild_projections(&self) -> Result<RebuildReport, EngineError> {
8672 self.ensure_open()?;
8673 self.run_rebuild(true, RebuildKind::Projections)
8674 }
8675
8676 /// Vec0-only variant of [`Engine::rebuild_projections`]. Leaves
8677 /// FTS5 shadow content untouched; per recovery design,
8678 /// `recover --rebuild-vec0` is the surface for vec0-only repair.
8679 #[cfg(feature = "operator")]
8680 pub fn rebuild_vec0(&self) -> Result<RebuildReport, EngineError> {
8681 self.ensure_open()?;
8682 self.run_rebuild(false, RebuildKind::Vec0)
8683 }
8684
8685 /// Phase 9 Pack B / AC-042 source trace. Returns the canonical-row
8686 /// id set produced by `source_id`, ordered by `write_cursor`. Empty
8687 /// string is not a valid `source_id`; rows with NULL `source_id`
8688 /// are excluded from every result.
8689 #[cfg(feature = "operator")]
8690 pub fn trace_source_ref(&self, source_id: &str) -> Result<TraceReport, EngineError> {
8691 self.ensure_open()?;
8692 if source_id.is_empty() {
8693 return Err(EngineError::WriteValidation);
8694 }
8695 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8696 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8697
8698 let mut events: Vec<TraceEvent> = Vec::new();
8699 let mut nodes = connection
8700 .prepare(
8701 "SELECT write_cursor, kind FROM canonical_nodes WHERE source_id = ?1
8702 ORDER BY write_cursor",
8703 )
8704 .map_err(|_| EngineError::Storage)?;
8705 let node_rows = nodes
8706 .query_map([source_id], |row| {
8707 Ok(TraceEvent {
8708 write_cursor: row.get::<_, i64>(0)? as u64,
8709 kind: row.get::<_, String>(1)?,
8710 table: "canonical_nodes",
8711 })
8712 })
8713 .map_err(|_| EngineError::Storage)?;
8714 for row in node_rows {
8715 events.push(row.map_err(|_| EngineError::Storage)?);
8716 }
8717
8718 let mut edges = connection
8719 .prepare(
8720 "SELECT write_cursor, kind FROM canonical_edges WHERE source_id = ?1
8721 ORDER BY write_cursor",
8722 )
8723 .map_err(|_| EngineError::Storage)?;
8724 let edge_rows = edges
8725 .query_map([source_id], |row| {
8726 Ok(TraceEvent {
8727 write_cursor: row.get::<_, i64>(0)? as u64,
8728 kind: row.get::<_, String>(1)?,
8729 table: "canonical_edges",
8730 })
8731 })
8732 .map_err(|_| EngineError::Storage)?;
8733 for row in edge_rows {
8734 events.push(row.map_err(|_| EngineError::Storage)?);
8735 }
8736
8737 events.sort_by_key(|e| e.write_cursor);
8738 Ok(TraceReport { source_ref: source_id.to_string(), events })
8739 }
8740
8741 /// OPP-12 Phase-1 (0.8.19 Slice 10) — resolve a lifecycle-verb id argument to
8742 /// the BARE `logical_id` it addresses, enforcing `Logical`(`l:`)-only
8743 /// addressability (design §3). An untagged string is taken as a bare
8744 /// `logical_id` (the `l:` form); an explicit `l:`-prefixed string is stripped
8745 /// to its value; a `Content`(`h:`) or `Passage`(`p:`) id is a typed
8746 /// [`EngineError::NotLifecycleAddressable`] refusal (never a panic / no-op).
8747 fn resolve_lifecycle_target(id: &str) -> Result<String, EngineError> {
8748 match IdSpace::parse(id) {
8749 Some(parsed) => match parsed.space {
8750 IdSpaceKind::Logical => Ok(parsed.value),
8751 other => Err(EngineError::NotLifecycleAddressable { id_space: other }),
8752 },
8753 // Untagged — no id-space prefix; treat as a bare logical_id (l: space).
8754 None => Ok(id.to_string()),
8755 }
8756 }
8757
8758 /// OPP-12 Phase-1 (0.8.19 Slice 10, R-TR-1/2) — move a governed node between
8759 /// existence states per the engine-enforced legal-transition table (design
8760 /// §2): promote `pending→active`, reject `pending→deleted`, soft-delete
8761 /// `active→deleted`, undelete `deleted→active`. `to_state` is a full
8762 /// [`LifecycleState`], but `Pending` (create-time only) and `Purged`
8763 /// (`purge`-only) are never legal `transition` targets, nor are self-loops or
8764 /// any move from a non-existent/`purged` row — each returns a typed
8765 /// [`EngineError::IllegalTransition`] enumerating the legal targets.
8766 ///
8767 /// `reason` semantics (design §3 gap-6): promote/undelete CLEAR `reason` to
8768 /// `NULL` (the row is admitted; no standing cause); reject/soft-delete SET
8769 /// `reason` to the supplied value (`NULL` allowed but the delete-family
8770 /// expects it). `reason` is advisory — the engine never interprets it.
8771 ///
8772 /// Keys on the BARE `logical_id` (`l:` space only); a `Content`(`h:`) or
8773 /// `Passage`(`p:`) id raises [`EngineError::NotLifecycleAddressable`].
8774 /// The state flip mutates the single active (`superseded_at IS NULL`) row; a
8775 /// `deleted` row STAYS node-FTS / vector indexed (gap-5) — only the
8776 /// `state='active'` default filter excludes those shadows, so an undelete
8777 /// needs no re-projection there.
8778 ///
8779 /// 0.8.20 Slice 15d fix-2 [P2] — the row-owned ATTRIBUTE projection
8780 /// (`canonical_attributes` / `property_search_index`) is the exception: it has
8781 /// NO read-side lifecycle filter (the property-FTS5 table cannot carry one), so
8782 /// it is maintained AT REST to track the backfill's set
8783 /// (projected ⟺ active ∧ non-superseded). Promote/undelete PROJECT the declared
8784 /// attributes; soft-delete PURGES them; reject is a no-op.
8785 pub fn transition(
8786 &self,
8787 logical_id: &str,
8788 to_state: LifecycleState,
8789 reason: Option<String>,
8790 ) -> Result<(), EngineError> {
8791 self.ensure_open()?;
8792 let lid = Self::resolve_lifecycle_target(logical_id)?;
8793
8794 // Settle in-flight projection work first: the async projection worker
8795 // commits vector/FTS shadows on its OWN connection, so a state flip issued
8796 // while a worker holds the write lock would SQLITE_BUSY. Draining
8797 // (unfrozen so any unprojected row completes) leaves the worker idle; a
8798 // bare state flip enqueues no new projection work.
8799 //
8800 // Slice 40 B3 aligns the worker with `commit_batch`:
8801 // `commit_projection_outcomes` acquires `BEGIN IMMEDIATE` before its reads.
8802 // This drain remains load-bearing because the worker owns a separate
8803 // connection while this state flip still reads before its own write; it
8804 // keeps that deferred transaction out of the worker's write window.
8805 self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS)?;
8806
8807 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8808 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8809 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8810
8811 // The lifecycle state lives on the single active (superseded_at IS NULL)
8812 // version; a `deleted` row is still that active version, just flagged.
8813 // fix-2 [P2] — also read its `write_cursor` + `body` so the row-owned
8814 // attribute projection can be maintained after the state flip.
8815 let current: Option<(String, i64, String)> = tx
8816 .query_row(
8817 "SELECT state, write_cursor, body FROM canonical_nodes \
8818 WHERE logical_id = ?1 AND superseded_at IS NULL",
8819 params![lid],
8820 |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, String>(2)?)),
8821 )
8822 .optional()
8823 .map_err(|_| EngineError::Storage)?;
8824
8825 // A missing active row is an absent/purged node — the terminal `Purged`
8826 // state for legality purposes (nothing is a legal target from there).
8827 let from_state = match ¤t {
8828 Some((s, _, _)) => LifecycleState::from_str_opt(s).ok_or(EngineError::Storage)?,
8829 None => LifecycleState::Purged,
8830 };
8831
8832 if !is_legal_transition_move(from_state, to_state) {
8833 return Err(EngineError::IllegalTransition {
8834 from_state,
8835 to_state,
8836 legal: from_state.legal_next_states(),
8837 });
8838 }
8839
8840 if matches!(to_state, LifecycleState::Active) {
8841 if let Some((_, _, body)) = ¤t {
8842 validate_nested_projection_sources_for_body(&tx, body)?;
8843 }
8844 }
8845
8846 // Admit (promote/undelete) → clear reason; exclude (reject/soft-delete) →
8847 // set the supplied reason. `to_state` is Active or Deleted here.
8848 let new_reason: Option<String> = match to_state {
8849 LifecycleState::Active => None,
8850 _ => reason,
8851 };
8852 tx.execute(
8853 "UPDATE canonical_nodes SET state = ?1, reason = ?2 \
8854 WHERE logical_id = ?3 AND superseded_at IS NULL",
8855 params![to_state.as_str(), new_reason, lid],
8856 )
8857 .map_err(|_| EngineError::Storage)?;
8858
8859 // fix-2 [P2] — maintain the row-owned attribute projection so it keeps
8860 // tracking the backfill's set (projected ⟺ active ∧ non-superseded). The
8861 // transitioned row is the single non-superseded version, so the invariant
8862 // reduces to `projected ⟺ to_state == Active`. We PURGE unconditionally
8863 // (idempotent — a no-op on the never-projected pending / already-purged
8864 // deleted arms) then RE-PROJECT when landing `Active`. This covers every
8865 // legal move: promote (pending→active) projects the withheld attributes;
8866 // soft-delete (active→deleted) purges; undelete (deleted→active)
8867 // re-projects; reject (pending→deleted) is a no-op. The property tables
8868 // (`canonical_attributes` / `property_search_index`) carry NO read-side
8869 // lifecycle filter — unlike node-FTS / vector shadows, which the canonical
8870 // read path already excludes when non-active — so they MUST be maintained
8871 // at rest, the same rationale as fix-1's purge-on-supersede. Node-FTS /
8872 // vector shadows are deliberately left intact (gap-5: a deleted row STAYS
8873 // indexed; only the `state='active'` default read filter hides it).
8874 if let Some((cursor, body)) = current.as_ref().map(|(_, c, b)| (*c, b.as_str())) {
8875 purge_row_projections_for_cursor_in(
8876 &tx,
8877 cursor,
8878 &[ProjectionClass::Attribute, ProjectionClass::PropertyFts],
8879 )
8880 .map_err(|_| EngineError::Storage)?;
8881 if matches!(to_state, LifecycleState::Active) {
8882 project_node_attributes(&tx, cursor, body).map_err(|_| EngineError::Storage)?;
8883 refresh_vector_attr_values_for_row(&tx, cursor, body)
8884 .map_err(|_| EngineError::Storage)?;
8885 }
8886 }
8887 tx.commit().map_err(|_| EngineError::Storage)?;
8888 self.counters.record_admin();
8889 Ok(())
8890 }
8891
8892 /// 0.8.20 Slice 15d (R-20-PR / C-1) — the projection registry as a
8893 /// DECLARATIVE, IDEMPOTENT apply. The engine is the SOLE projection authority
8894 /// (Q3): it diffs the supplied `specs` against the durable registry and
8895 /// backfills the difference in ONE transaction. Cheap projections
8896 /// (`filterable`, `searchable→FTS`) are built same-transaction; `rankable`
8897 /// and the `searchable→vector` sub-target are PERSISTED but deferred (F9 /
8898 /// Slice 20) — declaring them never errors (graceful-absent, Q6a).
8899 ///
8900 /// 0.8.20 Slice 23 (`R-20-SV`) — **SPEC VALIDATION.** A spec that carries an
8901 /// `fts` or `vector` sub-object WITHOUT [`ProjectionRole::Searchable`] is an
8902 /// INVALID SPEC and is refused with [`EngineError::WriteValidation`] (HITL
8903 /// 2026-07-24; see [`apply_projection_config`] for the full rationale). A
8904 /// rejected request is a TOTAL no-op. `read_projections` is unaffected — it
8905 /// is a pure read — so a LEGACY row in that shape still reports verbatim but
8906 /// can no longer be re-applied.
8907 ///
8908 /// `drop` is EXPLICIT (C3, `api-surface.md:27`): omission of a live
8909 /// projection from `specs` does NOT drop it; removal requires naming it in
8910 /// `drop`. An incompatible/destructive change to a live projection that is
8911 /// NOT in `drop` is refused with [`EngineError::ProjectionDestructive`], the
8912 /// destructive delta surfaced — never silent data loss. Re-applying an
8913 /// unchanged spec diffs to a no-op ([`ProjectionDelta::unchanged`]).
8914 ///
8915 /// Pair with [`Engine::read_projections`] to see current state before
8916 /// applying.
8917 pub fn configure_projections(
8918 &self,
8919 specs: &[ProjectionSpec],
8920 drop: &[String],
8921 ) -> Result<ProjectionDelta, EngineError> {
8922 self.ensure_open()?;
8923 // Settle in-flight async projection work first. The worker commits on its
8924 // own connection with `BEGIN IMMEDIATE`; a backfill issued in that write
8925 // window would SQLITE_BUSY.
8926 self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS)?;
8927
8928 // The backfill is gated on a usable dense runtime. Without one, the
8929 // declaration persists and defers rather than queueing unsafe work. A
8930 // later approved open grafts it; idempotent apply remains a repair door.
8931 let dense_arm_live = self.usable_dense_runtime();
8932 let (delta, enqueued_backfill) = {
8933 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8934 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8935 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8936 let applied = apply_projection_config(&tx, specs, drop, dense_arm_live)?;
8937 tx.commit().map_err(|_| EngineError::Storage)?;
8938 applied
8939 };
8940 // 0.8.20 Slice 20c (R-20-DR remainder) — the C4 rider's second half. The
8941 // enrolment + terminal-clear committed above; now WAKE the dispatcher, or
8942 // it sleeps on `pending_scan == false` and the very next `drain` burns its
8943 // whole timeout waiting for work nobody scheduled. `drain` itself stays
8944 // PASSIVE (a barrier, never a trigger) — the notify belongs here, on the
8945 // enqueue side. Deliberately after the connection guard is dropped: the
8946 // dispatcher immediately opens its own connection to scan.
8947 if enqueued_backfill {
8948 self.projection_runtime.notify_new_work();
8949 }
8950 self.counters.record_admin();
8951 Ok(delta)
8952 }
8953
8954 /// 0.8.20 Slice 15d (R-20-PR) — read the current projection registry (C5
8955 /// introspection: `read.projections`). Returns every declared
8956 /// [`ProjectionSpec`] sorted by name, so a caller can inspect current state
8957 /// (and the destructive delta a change would cause) BEFORE applying. Pure
8958 /// read; never mutates.
8959 ///
8960 /// 0.8.20 Slice 20 (R-20-DR) — this is ALSO the surface that populates the
8961 /// engine-set [`ProjectionVector::dense_readiness`] READ METADATA. It is
8962 /// derived here, on the way out (see [`derive_dense_readiness`]); the durable
8963 /// registry stores no readiness. Only a spec that declares the
8964 /// `searchable→vector` sub-object carries one — `filterable` and
8965 /// `searchable→FTS` are same-transaction and have no readiness axis.
8966 pub fn read_projections(&self) -> Result<Vec<ProjectionSpec>, EngineError> {
8967 self.ensure_open()?;
8968 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8969 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8970 let registry = load_projection_registry(connection).map_err(|_| EngineError::Storage)?;
8971 // Derived ONCE per call, so every vector projection in one read reports a
8972 // consistent readiness (they are all served by the one vector pipeline).
8973 // Skipped entirely when no vector projection is declared, keeping the
8974 // no-vector default path free of the extra probe.
8975 let mut readiness: Option<DenseReadiness> = None;
8976 let mut specs: Vec<ProjectionSpec> =
8977 registry.iter().map(|(name, stored)| stored.to_spec(name)).collect();
8978 for spec in &mut specs {
8979 if let Some(vector) = spec.vector.as_mut() {
8980 let value = match readiness {
8981 Some(value) => value,
8982 None => {
8983 let value =
8984 derive_dense_readiness(connection, self.usable_dense_runtime())?;
8985 readiness = Some(value);
8986 value
8987 }
8988 };
8989 vector.dense_readiness = Some(value);
8990 }
8991 }
8992 Ok(specs)
8993 }
8994
8995 /// Read the current projection-runtime status without changing the engine.
8996 ///
8997 /// Unlike [`read_projections`][Self::read_projections], this returns a
8998 /// purpose-built status facade rather than a decorated caller declaration.
8999 /// It reads the durable registry plus current session facts only: it does
9000 /// not configure projections, enroll kinds, enqueue work, call `drain`, or
9001 /// notify the projection scheduler. It uses the ordinarily opened engine
9002 /// connection and may take its lock; it is not a `ReaderWorkerPool` request
9003 /// and does not open a separately read-only SQLite connection. A legacy
9004 /// stored vector sub-object that is not `searchable` is `NotDeclared`,
9005 /// because the effective-arm predicate is the engine's
9006 /// `StoredProjection::wants_vector` predicate.
9007 pub fn read_projection_status(&self) -> Result<ProjectionRuntimeStatus, EngineError> {
9008 self.ensure_open()?;
9009 let runtime_embedder_available = self.usable_dense_runtime();
9010 let runtime_unavailability_reason = if runtime_embedder_available {
9011 ProjectionRuntimeUnavailabilityReason::None
9012 } else if self.dense_disabled.load(Ordering::Acquire) {
9013 ProjectionRuntimeUnavailabilityReason::VectorEquivalenceDisabled
9014 } else {
9015 ProjectionRuntimeUnavailabilityReason::NoRuntime
9016 };
9017
9018 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9019 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9020 let registry = load_projection_registry(connection).map_err(|_| EngineError::Storage)?;
9021 let has_effective_vector_arm = registry.values().any(StoredProjection::wants_vector);
9022 let effective_dense_readiness = if has_effective_vector_arm {
9023 match derive_dense_readiness(connection, runtime_embedder_available)? {
9024 DenseReadiness::Unavailable => ProjectionStatusDenseReadiness::Unavailable,
9025 DenseReadiness::Embedding => ProjectionStatusDenseReadiness::Embedding,
9026 DenseReadiness::Ready => ProjectionStatusDenseReadiness::Ready,
9027 }
9028 } else {
9029 ProjectionStatusDenseReadiness::NotDeclared
9030 };
9031 let projections = registry
9032 .into_iter()
9033 .map(|(name, stored)| {
9034 let dense_readiness = if stored.wants_vector() {
9035 effective_dense_readiness
9036 } else {
9037 ProjectionStatusDenseReadiness::NotDeclared
9038 };
9039 ProjectionRuntimeStatusEntry { name, dense_readiness }
9040 })
9041 .collect();
9042 let vector_unsupported_kinds = if has_effective_vector_arm {
9043 unsupported_vector_kinds(connection).map_err(|_| EngineError::Storage)?
9044 } else {
9045 Vec::new()
9046 };
9047
9048 Ok(ProjectionRuntimeStatus {
9049 runtime_embedder_available,
9050 runtime_unavailability_reason,
9051 projections,
9052 vector_unsupported_kinds,
9053 })
9054 }
9055
9056 /// OPP-12 Phase-1 (0.8.19 Slice 10, R-PG-1/2) — irreversibly hard-erase a
9057 /// governed node. A SEPARATE verb from [`Engine::transition`] (NOT on the
9058 /// `recovery_denylist`). Precondition: DELETED-FIRST — legal only from
9059 /// `deleted` (else a typed [`EngineError::IllegalTransition`] to `purged`);
9060 /// IDEMPOTENT — purging an already-absent/already-purged id is a no-op
9061 /// success. Keys on the bare `logical_id` (`l:` only); `h:`/`p:` →
9062 /// [`EngineError::NotLifecycleAddressable`].
9063 ///
9064 /// In ONE transaction, physically erases every ROW-OWNED target for the node
9065 /// (design §3 / gap-3): all `canonical_nodes` versions; its `search_index`,
9066 /// `search_index_edges`, `search_index_v2` FTS rows; its `vector_default`
9067 /// (vec0) + `_fathomdb_vector_rows` vectors; its `_fathomdb_projection_terminal`
9068 /// bookkeeping; and — CASCADE-REMOVE, no content-free stubs — every
9069 /// `canonical_edges` row touching it (`from_id`/`to_id`) plus those edges'
9070 /// projection shadows. The global/kind-level registries
9071 /// `_fathomdb_projection_state` and `_fathomdb_vector_kinds` are NOT keyed to
9072 /// a node id and are DELIBERATELY untouched.
9073 ///
9074 /// Erasure completeness relies on the standing `PRAGMA secure_delete=ON`
9075 /// (design §3 gap-4) which zeroes every freed page — so no per-purge `VACUUM`.
9076 /// (Freelist content written on a pre-20 DB before `secure_delete` was on is a
9077 /// documented residual; there is no forced migration-time `VACUUM`.)
9078 pub fn purge(&self, logical_id: &str) -> Result<(), EngineError> {
9079 self.ensure_open()?;
9080 let lid = Self::resolve_lifecycle_target(logical_id)?;
9081
9082 // Drain in-flight projection work before the erase, exactly as
9083 // `excise_source` does: SQLite-WAL would otherwise let a worker that
9084 // already dequeued a job for a purged cursor commit its vec0 /
9085 // `_fathomdb_vector_rows` INSERT after our DELETE releases the writer
9086 // lock, leaving residue that defeats the erasure sweep.
9087 // Settle every pending projection FIRST (unfrozen) so no unprojected row
9088 // is left behind that a subsequent freeze would wedge `drain` on, and so
9089 // the async worker is idle. THEN freeze the scanner (no new work is queued
9090 // while we erase), confirm idle, and erase in one writer transaction.
9091 // Freezing before the first drain would stall projection of any
9092 // just-written row → `database_has_pending_projection_work` never clears →
9093 // `drain` times out into `Scheduler`.
9094 self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS)?;
9095 self.projection_runtime.set_frozen(true);
9096 let outcome = self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS).and_then(|()| self.purge_inner(&lid));
9097 self.projection_runtime.set_frozen(false);
9098 // 0.8.20 Slice 5b (R-20-E5/E6) — the rows are gone from the tables; now
9099 // finish the erasure AT REST (telemetry sink + `-wal` bytes) before
9100 // reporting success. Runs after the connection guard inside
9101 // `purge_inner` has been dropped: `complete_erasure_at_rest` re-acquires
9102 // it for the checkpoint.
9103 outcome?;
9104 self.complete_erasure_at_rest("purge")
9105 }
9106
9107 /// The erased rows' prefixed stable ids ([`IdSpace::to_prefixed`]) are NOT
9108 /// returned: they are enqueued for redaction inside this transaction (see
9109 /// [`enqueue_pending_redaction`]), because a caller-held vector is lost on the
9110 /// retry path that codex §9 P2 found.
9111 fn purge_inner(&self, lid: &str) -> Result<(), EngineError> {
9112 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9113 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
9114 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
9115
9116 // Precondition on the active row's state. Absent (never-created or
9117 // already-purged) → idempotent no-op success.
9118 let current: Option<String> = tx
9119 .query_row(
9120 "SELECT state FROM canonical_nodes \
9121 WHERE logical_id = ?1 AND superseded_at IS NULL",
9122 params![lid],
9123 |r| r.get::<_, String>(0),
9124 )
9125 .optional()
9126 .map_err(|_| EngineError::Storage)?;
9127 let from_state = match current {
9128 None => {
9129 // Idempotent: nothing to erase.
9130 tx.commit().map_err(|_| EngineError::Storage)?;
9131 return Ok(());
9132 }
9133 Some(s) => LifecycleState::from_str_opt(&s).ok_or(EngineError::Storage)?,
9134 };
9135 if from_state != LifecycleState::Deleted {
9136 // Deleted-first precondition. Dropping `tx` rolls back (no-op read).
9137 return Err(EngineError::IllegalTransition {
9138 from_state,
9139 to_state: LifecycleState::Purged,
9140 legal: from_state.legal_next_states(),
9141 });
9142 }
9143
9144 // Collect every version cursor for the node, plus every cursor of an edge
9145 // that touches it (either endpoint), across ALL versions — the projection
9146 // shadow tables are keyed by these per-row `write_cursor`s.
9147 let node_cursors: Vec<i64> = {
9148 let mut stmt = tx
9149 .prepare("SELECT write_cursor FROM canonical_nodes WHERE logical_id = ?1")
9150 .map_err(|_| EngineError::Storage)?;
9151 let rows = stmt
9152 .query_map(params![lid], |row| row.get::<_, i64>(0))
9153 .map_err(|_| EngineError::Storage)?;
9154 rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
9155 };
9156 let edge_cursors: Vec<i64> = {
9157 let mut stmt = tx
9158 .prepare(
9159 "SELECT write_cursor FROM canonical_edges \
9160 WHERE from_id = ?1 OR to_id = ?1",
9161 )
9162 .map_err(|_| EngineError::Storage)?;
9163 let rows = stmt
9164 .query_map(params![lid], |row| row.get::<_, i64>(0))
9165 .map_err(|_| EngineError::Storage)?;
9166 rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
9167 };
9168
9169 // 0.8.20 Slice 5b (R-20-E6) — the stable ids the telemetry sink may have
9170 // persisted for these rows, collected BEFORE the DELETEs.
9171 let erased_stable_ids = collect_erased_stable_ids(
9172 &tx,
9173 "SELECT logical_id, body FROM canonical_nodes WHERE logical_id = ?1",
9174 "SELECT logical_id, body FROM canonical_edges WHERE from_id = ?1 OR to_id = ?1",
9175 lid,
9176 )?;
9177
9178 // Erase the row-owned projection shadows for every collected cursor.
9179 // 0.8.20 Slice 5a (R-20-E1): registry-driven — the hand-rolled delete
9180 // list is gone, so a newly registered projection table is erased here
9181 // without touching this site. vec0 rowid == the canonical row's
9182 // write_cursor (see `_fathomdb_vector_rows`).
9183 for cursor in node_cursors.iter().chain(edge_cursors.iter()) {
9184 erase_row_projections(&tx, *cursor).map_err(|_| EngineError::Storage)?;
9185 }
9186
9187 // Erase the canonical rows: all node versions + all touching edges
9188 // (gap-3 CASCADE-REMOVE — no content-free stubs in Phase-1).
9189 tx.execute("DELETE FROM canonical_nodes WHERE logical_id = ?1", params![lid])
9190 .map_err(|_| EngineError::Storage)?;
9191 tx.execute("DELETE FROM canonical_edges WHERE from_id = ?1 OR to_id = ?1", params![lid])
9192 .map_err(|_| EngineError::Storage)?;
9193
9194 // 0.8.20 Slice 5 fix-1 (codex §9 P2) — durably record the redaction this
9195 // erasure now owes, atomically with the deletes above. Only when a sink
9196 // is attached: with telemetry never enabled there is no file the ids
9197 // could have leaked into, so there is nothing to owe.
9198 let pending_cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
9199 let enqueued =
9200 self.telemetry_enabled.load(Ordering::Acquire) && !erased_stable_ids.is_empty();
9201 if enqueued {
9202 enqueue_pending_redaction(&tx, "purge", &erased_stable_ids, pending_cursor)?;
9203 }
9204
9205 tx.commit().map_err(|_| EngineError::Storage)?;
9206 if enqueued {
9207 self.next_cursor.store(pending_cursor, Ordering::SeqCst);
9208 }
9209 self.counters.record_admin();
9210 Ok(())
9211 }
9212
9213 /// 0.8.20 Slice 5d (R-20-E4, design §4 item 9b) — the **governed SDK
9214 /// erasure verb**. Deletes every canonical row attributable to `source_id`,
9215 /// plus its row-owned projections, and finishes the erasure at rest.
9216 ///
9217 /// This is NOT `operator`-gated: erasing content a consumer wrote is an
9218 /// application obligation, not a recovery workflow. Before this slice the
9219 /// only erasure path was [`Engine::excise_source`], which lives behind the
9220 /// operator feature (i.e. the CLI), so an SDK-only consumer holding a
9221 /// deletion obligation over ANONYMOUS content — content with no
9222 /// `logical_id`, therefore not reachable by [`Engine::purge`] — had no way
9223 /// to discharge it at all. That gap is what R-20-E4 closes.
9224 ///
9225 /// **One engine path.** `erase_source` and `excise_source` are the SAME
9226 /// operation: both delegate to [`Engine::erase_source_shared`]. They are
9227 /// not competing implementations, and no behaviour is duplicated.
9228 ///
9229 /// **Validation differs, deliberately.** `erase_source` admits only ids
9230 /// [`SourceId::new`] would admit, so a caller cannot aim the governed verb
9231 /// at the engine's reserved `_`-prefixed namespace (`_engine:*` substrate,
9232 /// or the `_legacy:pre-0.8.20` cohort migration step 21 back-filled — a
9233 /// single call against which would erase every pre-0.8.20 anonymous row).
9234 /// `excise_source` stays permissive precisely BECAUSE it is the recovery
9235 /// seam: R-20-E8 requires an operator to be able to excise `_legacy:`.
9236 ///
9237 /// **Not a recovery verb.** `erase_source` carries no REQ-054
9238 /// recovery-denylist name (`{recover, restore, repair, fix, rebuild}`); it
9239 /// is a lifecycle verb alongside `transition`/`purge`. AC-041 is unaffected.
9240 ///
9241 /// # Errors
9242 ///
9243 /// [`EngineError::WriteValidation`] for an empty, whitespace-only or
9244 /// reserved `source_id`; [`EngineError::ErasureIncomplete`] if the erasure
9245 /// could not be completed at rest (see [`Engine::complete_erasure_at_rest`]).
9246 pub fn erase_source(&self, source_id: &str) -> Result<ExciseReport, EngineError> {
9247 // Construct-to-validate: reuse the newtype's rule rather than restating
9248 // it, so the erasure boundary and the write boundary cannot drift.
9249 let _validated = SourceId::new(source_id)?;
9250 self.erase_source_shared("erase_source", source_id)
9251 }
9252
9253 /// Phase 9 Pack B / AC-028a/b/c source excise — the **operator/recovery**
9254 /// spelling of [`Engine::erase_source`], sharing one engine path with it.
9255 ///
9256 /// Kept `operator`-gated and kept permissive about reserved ids: this is
9257 /// the seam an operator uses to excise `_legacy:pre-0.8.20` (R-20-E8) or
9258 /// `_engine:*` substrate, which the governed SDK verb refuses.
9259 #[cfg(feature = "operator")]
9260 pub fn excise_source(&self, source_id: &str) -> Result<ExciseReport, EngineError> {
9261 if source_id.is_empty() {
9262 self.ensure_open()?;
9263 return Err(EngineError::WriteValidation);
9264 }
9265 self.erase_source_shared("excise_source", source_id)
9266 }
9267
9268 /// The single erasure implementation behind [`Engine::erase_source`] and
9269 /// [`Engine::excise_source`]. `verb` names the caller for the telemetry
9270 /// redaction record only; the deletion semantics are identical.
9271 ///
9272 /// Non-perturbation: rows from other sources (and rows with NULL
9273 /// `source_id`) are untouched; the projection cursor is NOT reset
9274 /// and no blanket projection rebuild is issued.
9275 fn erase_source_shared(
9276 &self,
9277 verb: &'static str,
9278 source_id: &str,
9279 ) -> Result<ExciseReport, EngineError> {
9280 self.ensure_open()?;
9281
9282 // Drain MUST succeed before the excise transaction. SQLite-WAL
9283 // would otherwise allow a worker that already dequeued a job
9284 // for an excised cursor to commit its INSERT into vec0 /
9285 // _fathomdb_vector_rows after our DELETE releases the writer
9286 // lock, leaving residue and breaking AC-028b. Surface the
9287 // timeout instead of swallowing it (Pack A pattern).
9288 //
9289 // ORDER IS LOAD-BEARING, exactly as in `purge`: settle every pending
9290 // projection FIRST (UNFROZEN), and only THEN freeze the scanner and
9291 // confirm idle. Freezing first parks the dispatcher, so a row written
9292 // moments ago can never be scanned and enqueued — while `drain` ->
9293 // `wait_for_idle` keeps seeing it via
9294 // `database_has_pending_projection_work`, which reads the DATABASE and
9295 // not the queue. The result is that the ordinary sequence "write a
9296 // vector-indexed row, then erase it" stalls for the whole
9297 // LIFECYCLE_DRAIN_TIMEOUT_MS and fails with `Scheduler`.
9298 // (codex §9 [P2]; `erase_source_drains_before_freezing`.)
9299 self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS)?;
9300 self.projection_runtime.set_frozen(true);
9301 let drain_result = self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS);
9302 let outcome = drain_result.and_then(|()| self.excise_source_inner(verb, source_id));
9303 self.projection_runtime.set_frozen(false);
9304 // 0.8.20 Slice 5b (R-20-E5/E6) — finish the erasure AT REST before
9305 // reporting success: redact the erased stable ids out of the telemetry
9306 // sink, then truncate the `-wal` so the erased bytes are not still
9307 // readable on disk. On persistent checkpoint BUSY this returns
9308 // `ErasureIncomplete` rather than an `ExciseReport`.
9309 let report = outcome?;
9310 self.complete_erasure_at_rest(verb)?;
9311 Ok(report)
9312 }
9313
9314 /// Doctor `verify-embedder` seam (AC-040a). Compares the
9315 /// `_fathomdb_embedder_profiles` row to the operator-supplied
9316 /// `name:revision` identity + dimension; never raises on mismatch.
9317 #[cfg(feature = "operator")]
9318 pub fn verify_embedder(
9319 &self,
9320 supplied_identity: &str,
9321 supplied_dimension: u32,
9322 ) -> Result<VerifyEmbedderReport, EngineError> {
9323 self.ensure_open()?;
9324 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9325 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9326 let stored = load_default_profile(connection).map_err(|_| EngineError::Storage)?;
9327 let stored_identity = format!("{}:{}", stored.name, stored.revision);
9328 let identity_match = stored_identity == supplied_identity;
9329 let dimension_match = stored.dimension == supplied_dimension;
9330 let status = match (identity_match, dimension_match) {
9331 (true, true) => VerifyEmbedderStatus::Match,
9332 (false, true) => VerifyEmbedderStatus::IdentityMismatch,
9333 (true, false) => VerifyEmbedderStatus::DimensionMismatch,
9334 (false, false) => VerifyEmbedderStatus::BothMismatch,
9335 };
9336 Ok(VerifyEmbedderReport {
9337 stored_identity,
9338 stored_dimension: stored.dimension,
9339 supplied_identity: supplied_identity.to_string(),
9340 supplied_dimension,
9341 status,
9342 })
9343 }
9344
9345 /// Doctor `dump-schema` seam (AC-040a). Returns the
9346 /// `PRAGMA user_version` sentinel plus the table + index inventory
9347 /// from `sqlite_schema`, excluding `sqlite_*` internal rows.
9348 /// Canonical tables appear first per [`CANONICAL_TABLES`].
9349 #[cfg(feature = "operator")]
9350 pub fn dump_schema(&self) -> Result<DumpSchemaReport, EngineError> {
9351 self.ensure_open()?;
9352 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9353 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9354 let user_version: u32 = connection
9355 .query_row("PRAGMA user_version", [], |row| row.get(0))
9356 .map_err(|_| EngineError::Storage)?;
9357 let tables = read_schema_objects(connection, "table")?;
9358 let indexes = read_schema_objects(connection, "index")?;
9359 Ok(DumpSchemaReport { user_version, tables: order_canonical_first(tables), indexes })
9360 }
9361
9362 /// Doctor `dump-row-counts` seam (AC-040a). Emits canonical-table
9363 /// counts only; projection / FTS / vec0 shadow tables are excluded.
9364 #[cfg(feature = "operator")]
9365 pub fn dump_row_counts(&self) -> Result<DumpRowCountsReport, EngineError> {
9366 self.ensure_open()?;
9367 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9368 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9369 let mut counts = Vec::with_capacity(CANONICAL_TABLES.len());
9370 for name in CANONICAL_TABLES {
9371 let rows: u64 = connection
9372 .query_row(&format!("SELECT COUNT(*) FROM {name}"), [], |row| row.get(0))
9373 .map_err(|_| EngineError::Storage)?;
9374 counts.push(TableRowCount { name: (*name).to_string(), rows });
9375 }
9376 Ok(DumpRowCountsReport { counts })
9377 }
9378
9379 /// 0.8.20 Slice 5d (R-20-E8, design §4 item 11) — doctor
9380 /// `orphan-provenance` seam: a **read-only** per-`source_id` census over
9381 /// `canonical_nodes` + `canonical_edges`.
9382 ///
9383 /// Answers the operator question the erasure work made askable: *"for this
9384 /// database, is every row actually reachable by some erasure verb?"* A row
9385 /// is reachable by `erase_source` / `excise_source` via `source_id`, or —
9386 /// **if it is a NODE** — by `purge` via `logical_id`. A row with neither is
9387 /// un-erasable, and is counted into
9388 /// [`OrphanProvenanceReport::unerasable_rows`].
9389 ///
9390 /// The node/edge asymmetry is load-bearing and mirrors migration step 21:
9391 /// an EDGE's `logical_id` is a supersession identity only and confers no
9392 /// purge-addressability, so a NULL-`source_id` edge is un-erasable however
9393 /// governed it looks. See the query comment below.
9394 ///
9395 /// CLI-only (no SDK parity), matching the `dump-*` diagnostic family.
9396 ///
9397 /// Read-only by construction: this method issues SELECTs exclusively and
9398 /// opens no transaction.
9399 #[cfg(feature = "operator")]
9400 pub fn orphan_provenance(&self) -> Result<OrphanProvenanceReport, EngineError> {
9401 self.ensure_open()?;
9402 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9403 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9404
9405 // One UNION ALL over both canonical tables so a source that spans nodes
9406 // AND edges reports as a single bucket.
9407 //
9408 // TWO DIFFERENT SUMS, and the difference is the whole point:
9409 //
9410 // * `governed` counts `logical_id` carriers — a reporting figure;
9411 // * `purge_addressable` counts rows that `purge` can actually reach,
9412 // and it is NODE-ONLY (the edge arm contributes a literal 0).
9413 //
9414 // This is the same node/edge asymmetry migration step 21 carries, for
9415 // the same reason, and the two must stay in step: `purge_inner`
9416 // resolves its target exclusively through `canonical_nodes` (`SELECT
9417 // state FROM canonical_nodes WHERE logical_id = ?1`) and then erases
9418 // edges by ENDPOINT (`from_id`/`to_id`). It NEVER resolves an edge by
9419 // edge `logical_id` — an edge `logical_id` is only a SUPERSESSION
9420 // identity and confers no purge-addressability whatsoever.
9421 //
9422 // Crediting an edge's `logical_id` here made the diagnostic subtract
9423 // exactly the rows it exists to find: a NULL-`source_id` edge is
9424 // reachable by no erasure verb at all, yet `orphan-provenance` would
9425 // exit CLEAN on precisely the legacy/corrupt shape step 21 closes.
9426 // False assurance from a governance verb is worse than no verb.
9427 // (codex §9 [P2]; `null_source_governed_edge_counts_as_unerasable`.)
9428 let mut stmt = connection
9429 .prepare(
9430 "SELECT source_id,
9431 COUNT(*) AS rows_total,
9432 SUM(CASE WHEN logical_id IS NOT NULL THEN 1 ELSE 0 END) AS governed,
9433 SUM(purge_addressable) AS purge_addressable
9434 FROM (SELECT source_id,
9435 logical_id,
9436 CASE WHEN logical_id IS NOT NULL THEN 1 ELSE 0 END
9437 AS purge_addressable
9438 FROM canonical_nodes
9439 UNION ALL
9440 SELECT source_id, logical_id, 0 AS purge_addressable
9441 FROM canonical_edges)
9442 GROUP BY source_id
9443 ORDER BY rows_total DESC, source_id",
9444 )
9445 .map_err(|_| EngineError::Storage)?;
9446
9447 let rows = stmt
9448 .query_map([], |row| {
9449 let source_id: Option<String> = row.get(0)?;
9450 let rows: i64 = row.get(1)?;
9451 let governed: i64 = row.get(2)?;
9452 let purge_addressable: i64 = row.get(3)?;
9453 Ok((source_id, rows, governed, purge_addressable))
9454 })
9455 .map_err(|_| EngineError::Storage)?;
9456
9457 let mut sources = Vec::new();
9458 let mut total_rows: u64 = 0;
9459 let mut unerasable_rows: u64 = 0;
9460 for row in rows {
9461 let (source_id, rows, governed, purge_addressable) =
9462 row.map_err(|_| EngineError::Storage)?;
9463 let rows = u64::try_from(rows).unwrap_or(0);
9464 let governed_rows = u64::try_from(governed).unwrap_or(0);
9465 let purge_addressable = u64::try_from(purge_addressable).unwrap_or(0);
9466 total_rows = total_rows.saturating_add(rows);
9467 if source_id.is_none() {
9468 // No provenance: only the PURGE-ADDRESSABLE subset (governed
9469 // NODES) is reachable. The remainder — including every governed
9470 // EDGE, whose `logical_id` reaches nothing — is reachable by no
9471 // erasure verb at all.
9472 unerasable_rows =
9473 unerasable_rows.saturating_add(rows - purge_addressable.min(rows));
9474 }
9475 let reserved = source_id.as_deref().is_some_and(|s| s.starts_with('_'));
9476 sources.push(OrphanProvenanceSource { source_id, rows, governed_rows, reserved });
9477 }
9478
9479 Ok(OrphanProvenanceReport { sources, total_rows, unerasable_rows })
9480 }
9481
9482 /// Doctor `dump-profile` seam (AC-040a). Returns the stored
9483 /// embedder identity + dimension plus the registered vectorized
9484 /// kinds from `_fathomdb_vector_kinds`.
9485 #[cfg(feature = "operator")]
9486 pub fn dump_profile(&self) -> Result<DumpProfileReport, EngineError> {
9487 self.ensure_open()?;
9488 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9489 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9490 let stored = load_default_profile(connection).map_err(|_| EngineError::Storage)?;
9491 let mut stmt = connection
9492 .prepare("SELECT kind FROM _fathomdb_vector_kinds ORDER BY kind")
9493 .map_err(|_| EngineError::Storage)?;
9494 let rows =
9495 stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|_| EngineError::Storage)?;
9496 let mut vectorized_kinds = Vec::new();
9497 for row in rows {
9498 vectorized_kinds.push(row.map_err(|_| EngineError::Storage)?);
9499 }
9500 Ok(DumpProfileReport {
9501 embedder_identity: format!("{}:{}", stored.name, stored.revision),
9502 embedder_dimension: stored.dimension,
9503 vectorized_kinds,
9504 })
9505 }
9506
9507 /// Recover `--truncate-wal` seam. Runs
9508 /// `PRAGMA wal_checkpoint(TRUNCATE)` and returns the three counters
9509 /// SQLite reports. `status = Busy` when SQLite signalled a blocked
9510 /// checkpoint (`busy != 0`); the WAL may still be partially
9511 /// checkpointed in that case.
9512 #[cfg(feature = "operator")]
9513 pub fn truncate_wal(&self) -> Result<TruncateWalReport, EngineError> {
9514 self.ensure_open()?;
9515 // The operator verb keeps SQLite's own busy handler: `recover
9516 // --truncate-wal` is an explicit, foreground operator act, so waiting out
9517 // a transient reader is the helpful behaviour.
9518 self.wal_checkpoint_truncate_once(true)
9519 }
9520
9521 /// One `PRAGMA wal_checkpoint(TRUNCATE)` on the writer connection.
9522 ///
9523 /// NOT operator-gated: the erasure verbs (`purge` is a default-feature verb)
9524 /// need it too, and a `#[cfg(feature = "operator")]` helper would break the
9525 /// default build. Acquires the connection mutex, so callers must NOT already
9526 /// hold it — every erasure verb calls this AFTER its transaction has
9527 /// committed and the guard has been dropped.
9528 ///
9529 /// `honor_busy_timeout = false` suppresses SQLite's busy handler for the
9530 /// duration of the checkpoint. rusqlite installs a **5 s** default
9531 /// `busy_timeout`, so a blocked checkpoint sits for 5 s before reporting
9532 /// `busy` — under the erasure verbs' bounded retry that compounds to a ~25 s
9533 /// stall on a verb that is supposed to fail fast. The erasure path therefore
9534 /// takes the immediate `busy` answer and runs its OWN short backoff; the
9535 /// prior value is restored before returning, on every path.
9536 fn wal_checkpoint_truncate_once(
9537 &self,
9538 honor_busy_timeout: bool,
9539 ) -> Result<TruncateWalReport, EngineError> {
9540 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9541 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9542
9543 let restore_timeout_ms: Option<i64> = if honor_busy_timeout {
9544 None
9545 } else {
9546 let previous: i64 = connection
9547 .query_row("PRAGMA busy_timeout", [], |row| row.get(0))
9548 .map_err(|_| EngineError::Storage)?;
9549 connection.busy_timeout(Duration::ZERO).map_err(|_| EngineError::Storage)?;
9550 Some(previous)
9551 };
9552
9553 let checkpoint: rusqlite::Result<(i64, i64, i64)> =
9554 connection.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
9555 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
9556 });
9557
9558 if let Some(previous) = restore_timeout_ms {
9559 let previous = u64::try_from(previous.max(0)).unwrap_or(0);
9560 connection
9561 .busy_timeout(Duration::from_millis(previous))
9562 .map_err(|_| EngineError::Storage)?;
9563 }
9564
9565 let (busy, log_frames, checkpointed_frames) =
9566 checkpoint.map_err(|_| EngineError::Storage)?;
9567 let status = if busy == 0 { TruncateWalStatus::Done } else { TruncateWalStatus::Busy };
9568 Ok(TruncateWalReport {
9569 status,
9570 busy: busy.max(0) as u32,
9571 log_frames: log_frames.max(0) as u32,
9572 checkpointed_frames: checkpointed_frames.max(0) as u32,
9573 })
9574 }
9575
9576 /// 0.8.20 Slice 5b (R-20-E5) — complete an erasure **at rest** after the
9577 /// erasing transaction has committed. Two obligations, in order:
9578 ///
9579 /// 1. **Telemetry redaction** — drop the erased stable ids out of the opt-in
9580 /// telemetry sink. Driven from the DURABLE pending queue
9581 /// ([`Engine::discharge_pending_redactions`]), NOT from the ids the caller
9582 /// happens to be holding, so a retry after a failed redaction still knows
9583 /// what it owes.
9584 /// 2. **WAL truncation** — `wal_checkpoint(TRUNCATE)` with a BOUNDED retry.
9585 /// `PRAGMA secure_delete=ON` zeroes pages freed inside the database file,
9586 /// but the erased content also lives in the write-ahead log as committed
9587 /// frames from the ORIGINAL insert; the erasure DELETE appends new frames
9588 /// rather than rewriting old ones, so without a truncating checkpoint the
9589 /// erased body stays `grep`-able in `<db>-wal`.
9590 ///
9591 /// A concurrent reader pinning a WAL snapshot makes the checkpoint report
9592 /// `busy`. After [`ERASURE_WAL_TRUNCATE_ATTEMPTS`] tries the verb raises
9593 /// [`EngineError::ErasureIncomplete`] — **an erasure verb must never report
9594 /// success on an incomplete erasure.** The retry budget is deliberately small
9595 /// (~100 ms total): the caller retries the verb, the verb does not block.
9596 fn complete_erasure_at_rest(&self, verb: &'static str) -> Result<(), EngineError> {
9597 // The ids are NOT passed in: they were persisted inside the erasing
9598 // transaction, and this drains that queue. The WAL truncation below then
9599 // runs AFTER the pending rows have been deleted, so the freed pages
9600 // holding them (zeroed by `secure_delete=ON`) are checkpointed out too.
9601 self.discharge_pending_redactions(verb)?;
9602
9603 let mut last: Option<TruncateWalReport> = None;
9604 for attempt in 0..ERASURE_WAL_TRUNCATE_ATTEMPTS {
9605 let report = self.wal_checkpoint_truncate_once(false)?;
9606 if report.status == TruncateWalStatus::Done {
9607 return Ok(());
9608 }
9609 last = Some(report);
9610 if attempt + 1 < ERASURE_WAL_TRUNCATE_ATTEMPTS {
9611 std::thread::sleep(Duration::from_millis(ERASURE_WAL_TRUNCATE_BACKOFF_MS));
9612 }
9613 }
9614 let frames = last.map_or(0, |r| r.log_frames);
9615 Err(EngineError::ErasureIncomplete {
9616 stage: "wal_checkpoint".to_string(),
9617 detail: format!(
9618 "`{verb}` deleted its rows, but `wal_checkpoint(TRUNCATE)` reported BUSY on all \
9619 {ERASURE_WAL_TRUNCATE_ATTEMPTS} attempts ({frames} frames still in the log) — a \
9620 concurrent reader is pinning a WAL snapshot, so the erased bytes remain readable \
9621 in the `-wal` file. Retry once the reader has finished."
9622 ),
9623 })
9624 }
9625
9626 /// 0.8.20 Slice 5 fix-1 (codex §9 P2) — perform every telemetry redaction the
9627 /// engine still OWES, from the durable pending queue.
9628 ///
9629 /// **The defect this closes.** Redaction necessarily runs after the erasing
9630 /// transaction commits (the sink is a file, not a table, so it cannot join
9631 /// the transaction). When it failed, the verb correctly raised
9632 /// `ErasureIncomplete { stage: "telemetry_redaction" }` and told the operator
9633 /// to retry — but the retry recomputed the id set by querying the canonical
9634 /// tables, whose rows the FIRST call had already deleted. It therefore got an
9635 /// EMPTY set, hit the empty-id fast path in
9636 /// [`Engine::redact_telemetry_stable_ids`], and returned success while the
9637 /// leaked `l:`/`h:` ids were still sitting in the sink. An erasure verb
9638 /// reporting success on an incomplete erasure is precisely what R-20-E5
9639 /// forbids, and it is the worst failure mode available to this slice: silent,
9640 /// and indistinguishable from a real erasure.
9641 ///
9642 /// **The mechanism — an intent log.** The ids are captured BEFORE the deletes
9643 /// (they are derived from `logical_id`/`body`, which the deletes destroy) and
9644 /// written into [`ERASURE_PENDING_REDACTION_COLLECTION`] INSIDE the same
9645 /// transaction, so "the rows are gone" and "a redaction is owed for them"
9646 /// commit atomically. There is no window in which the rows are deleted and
9647 /// the obligation is unrecorded. A pending row is deleted only once its
9648 /// redaction has actually been performed, so the obligation survives process
9649 /// death, and the empty-id fast path is unreachable while one is outstanding:
9650 /// this drains the QUEUE, never the caller's id vector.
9651 ///
9652 /// The queue is drained by EVERY erasure verb, not just a retry of the one
9653 /// that failed — an outstanding obligation is the engine's, not one call's.
9654 ///
9655 /// **Honest refusal.** If a redaction is owed but no telemetry sink is
9656 /// attached to this `Engine` (only reachable if the process restarted between
9657 /// the failure and the retry without re-enabling telemetry), the ids really
9658 /// are still in the sink file and this returns `ErasureIncomplete` rather
9659 /// than guessing. Re-enable telemetry on the same sink and retry.
9660 ///
9661 /// **Exposure tradeoff, stated plainly.** A pending row holds the stable ids
9662 /// in the database for the window between the delete and the redaction. That
9663 /// is a strict improvement: those ids are, during exactly that window,
9664 /// already readable in the telemetry sink — which is the leak being closed —
9665 /// and the pending row is deleted the moment the sink is clean, on pages
9666 /// `secure_delete=ON` zeroes and the subsequent `TRUNCATE` checkpoint clears
9667 /// from the log.
9668 fn discharge_pending_redactions(&self, verb: &'static str) -> Result<(), EngineError> {
9669 let pending = self.load_pending_redactions()?;
9670 if pending.is_empty() {
9671 return Ok(());
9672 }
9673
9674 let mut ids: Vec<String> =
9675 pending.iter().flat_map(|(_, ids)| ids.iter().cloned()).collect();
9676 ids.sort_unstable();
9677 ids.dedup();
9678
9679 // A queue entry exists ⇒ a sink was attached when the rows were deleted ⇒
9680 // the ids are in that file. Never clear the queue without redacting.
9681 if !self.telemetry_enabled.load(Ordering::Acquire) {
9682 return Err(EngineError::ErasureIncomplete {
9683 stage: "telemetry_redaction".to_string(),
9684 detail: format!(
9685 "`{verb}` has {} outstanding telemetry redaction(s) covering {} erased \
9686 stable id(s), but no telemetry sink is attached to this engine — the ids \
9687 cannot be removed from the sink file. Re-enable telemetry on the same sink \
9688 path and retry.",
9689 pending.len(),
9690 ids.len()
9691 ),
9692 });
9693 }
9694
9695 // On failure the queue rows stay put and the error propagates: the verb
9696 // does not report success, and the next call retries the same obligation.
9697 self.redact_telemetry_stable_ids(verb, &ids)?;
9698
9699 let row_ids: Vec<i64> = pending.iter().map(|(row_id, _)| *row_id).collect();
9700 self.clear_pending_redactions(&row_ids)
9701 }
9702
9703 /// Read the outstanding redaction queue: `(operational_mutations.id, ids)`.
9704 fn load_pending_redactions(&self) -> Result<Vec<(i64, Vec<String>)>, EngineError> {
9705 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9706 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9707 let mut stmt = connection
9708 .prepare(
9709 "SELECT id, payload_json FROM operational_mutations \
9710 WHERE collection_name = ?1 ORDER BY id",
9711 )
9712 .map_err(|_| EngineError::Storage)?;
9713 let rows = stmt
9714 .query_map([ERASURE_PENDING_REDACTION_COLLECTION], |row| {
9715 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
9716 })
9717 .map_err(|_| EngineError::Storage)?;
9718 let mut pending = Vec::new();
9719 for row in rows {
9720 let (row_id, payload) = row.map_err(|_| EngineError::Storage)?;
9721 // A payload we cannot parse is an obligation we cannot discharge;
9722 // keeping it (empty) is safe — it never unblocks a false success,
9723 // and `discharge_pending_redactions` still refuses.
9724 let ids = serde_json::from_str::<serde_json::Value>(&payload)
9725 .ok()
9726 .and_then(|v| v.get("erased_stable_ids").cloned())
9727 .and_then(|v| serde_json::from_value::<Vec<String>>(v).ok())
9728 .unwrap_or_default();
9729 pending.push((row_id, ids));
9730 }
9731 Ok(pending)
9732 }
9733
9734 /// Retire queue entries whose redaction has been PERFORMED. Committed before
9735 /// the caller's WAL truncation so the freed pages are checkpointed out.
9736 fn clear_pending_redactions(&self, row_ids: &[i64]) -> Result<(), EngineError> {
9737 if row_ids.is_empty() {
9738 return Ok(());
9739 }
9740 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9741 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9742 let mut stmt = connection
9743 .prepare("DELETE FROM operational_mutations WHERE id = ?1")
9744 .map_err(|_| EngineError::Storage)?;
9745 for row_id in row_ids {
9746 stmt.execute([row_id]).map_err(|_| EngineError::Storage)?;
9747 }
9748 Ok(())
9749 }
9750
9751 /// 0.8.20 Slice 5b (R-20-E6) — SELECTIVE redaction of erased stable ids from
9752 /// the opt-in telemetry sink.
9753 ///
9754 /// `capture_telemetry` persists `result_stable_ids` — `l:`/`h:` prefixed ids
9755 /// — into a JSONL file that outlives the erased rows, and nothing in the
9756 /// engine could previously remove them. A retained `l:` id is not inert:
9757 /// [`derive_logical_id`] is `SHA256(lowercase(kind) + ":" + lowercase(name))`,
9758 /// and the case-folding of BOTH inputs shrinks the preimage space, so a
9759 /// surviving id is dictionary-attackable back to the natural key it was
9760 /// derived from. An `h:` id is a plain `SHA256(body)`, confirmable against a
9761 /// guessed body.
9762 ///
9763 /// **This MUST NOT truncate the sink.** `sink_path` is CALLER-SUPPLIED and
9764 /// may hold unrelated operator eval history that the erasure obligation never
9765 /// covered; the v3 truncation approach was rejected as unsafe. Only the
9766 /// matching `result_stable_ids` ELEMENTS are replaced with
9767 /// [`REDACTED_STABLE_ID`], preserving record count, record order and
9768 /// positional alignment with the parallel `result_ids` array. Lines that are
9769 /// not engine-authored JSON events are copied through verbatim.
9770 ///
9771 /// **Crash safety.** The rewrite is write-temp-then-`rename`: a sibling
9772 /// `.redact.tmp` is written and fsynced, then atomically renamed over the
9773 /// sink, so a crash leaves either the old file or the new one — never a
9774 /// half-rewritten sink. The telemetry mutex is held across the whole rewrite,
9775 /// so no in-process `capture_telemetry` can append into the window; an
9776 /// out-of-process appender is handled by re-reading and folding in the tail
9777 /// delta before the rename (bounded retry).
9778 ///
9779 /// The privacy contract is unchanged: query TEXT and `source_id` are never
9780 /// captured (ADR-0.8.8 §C), so there is nothing else in the sink to redact.
9781 /// The fast-OFF atomic guard is preserved — when telemetry was never enabled
9782 /// this is a single relaxed-ordering load and no mutex acquisition.
9783 fn redact_telemetry_stable_ids(
9784 &self,
9785 verb: &'static str,
9786 erased_stable_ids: &[String],
9787 ) -> Result<(), EngineError> {
9788 // Fast OFF path — mirrors `capture_telemetry`. No mutex, no I/O.
9789 if erased_stable_ids.is_empty() || !self.telemetry_enabled.load(Ordering::Acquire) {
9790 return Ok(());
9791 }
9792 let guard = self.telemetry.lock().map_err(|_| EngineError::Storage)?;
9793 let Some(sink) = guard.as_ref() else { return Ok(()) };
9794 let erased: std::collections::HashSet<&str> =
9795 erased_stable_ids.iter().map(String::as_str).collect();
9796
9797 match redact_jsonl_stable_ids(&sink.path, &erased) {
9798 Ok(()) => Ok(()),
9799 // 0.8.20 Slice 5 fix-3 (codex §9 round-3 P2) — `NotFound` is NOT a
9800 // discharge. It previously returned `Ok(())` ("the sink is gone,
9801 // nothing to redact"), which cleared the durable pending queue and
9802 // let the verb report success. That inference does not hold: a path
9803 // cannot distinguish `rm` from `mv`, and log rotation of a
9804 // caller-supplied sink is an ordinary operational event that leaves
9805 // the erased `l:`/`h:` ids fully readable under the rotated name.
9806 //
9807 // The burden of proof is on DISCHARGING the obligation, and the
9808 // engine cannot meet it here: `TelemetrySink` holds a PATH, not an
9809 // open handle, so there is no `nlink == 0` witness that the inode was
9810 // actually unlinked — and even that would not cover a copy taken
9811 // before the deletion. So there is no narrow provable case to carve
9812 // out, and `NotFound` fails closed.
9813 //
9814 // This cannot fire spuriously for a sink that never existed:
9815 // `enable_telemetry` CREATES the file before arming capture, so for
9816 // any engine with telemetry enabled the sink demonstrably existed and
9817 // `NotFound` means it existed and then vanished.
9818 Err(err) => Err(EngineError::ErasureIncomplete {
9819 stage: "telemetry_redaction".to_string(),
9820 detail: if err.kind() == std::io::ErrorKind::NotFound {
9821 format!(
9822 "`{verb}` deleted its rows, but the telemetry sink {} no longer exists, \
9823 so the erased stable ids could not be redacted from it. A missing path \
9824 does NOT prove the sink was deleted — if it was rotated or moved aside, \
9825 the erased ids are still readable under its new name. The pending \
9826 redaction is durable: restore the sink at this path and retry (if the \
9827 sink really was destroyed, an empty file at this path discharges the \
9828 obligation).",
9829 sink.path.display()
9830 )
9831 } else {
9832 format!(
9833 "`{verb}` deleted its rows, but the erased stable ids could not be \
9834 redacted from the telemetry sink {}: {err}",
9835 sink.path.display()
9836 )
9837 },
9838 }),
9839 }
9840 }
9841
9842 /// The erased rows' prefixed stable ids ([`IdSpace::to_prefixed`]) are NOT
9843 /// returned to the caller for redaction (R-20-E6). They are enqueued INSIDE
9844 /// this transaction via [`enqueue_pending_redaction`]: a caller-held vector
9845 /// is lost on the retry path, which is exactly the false-success codex §9 P2
9846 /// found. Only the report comes back.
9847 ///
9848 /// 0.8.20 Slice 5d (R-20-E4): no longer `operator`-gated — it is the shared
9849 /// body behind BOTH `erase_source` (governed SDK) and `excise_source`
9850 /// (operator seam). Still private; the gate that matters is on the two
9851 /// public spellings.
9852 fn excise_source_inner(
9853 &self,
9854 verb: &'static str,
9855 source_id: &str,
9856 ) -> Result<ExciseReport, EngineError> {
9857 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9858 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
9859 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
9860
9861 // Collect the cursor sets up-front so we can targeted-delete
9862 // shadow rows AND emit an accurate audit row in one txn.
9863 let node_cursors: Vec<i64> = {
9864 let mut stmt = tx
9865 .prepare("SELECT write_cursor FROM canonical_nodes WHERE source_id = ?1")
9866 .map_err(|_| EngineError::Storage)?;
9867 let rows = stmt
9868 .query_map([source_id], |row| row.get::<_, i64>(0))
9869 .map_err(|_| EngineError::Storage)?;
9870 rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
9871 };
9872 let edge_cursors: Vec<i64> = {
9873 let mut stmt = tx
9874 .prepare("SELECT write_cursor FROM canonical_edges WHERE source_id = ?1")
9875 .map_err(|_| EngineError::Storage)?;
9876 let rows = stmt
9877 .query_map([source_id], |row| row.get::<_, i64>(0))
9878 .map_err(|_| EngineError::Storage)?;
9879 rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
9880 };
9881
9882 // 0.8.20 Slice 5b (R-20-E6) — stable ids the telemetry sink may hold for
9883 // these rows, collected BEFORE the DELETEs.
9884 let erased_stable_ids = collect_erased_stable_ids(
9885 &tx,
9886 "SELECT logical_id, body FROM canonical_nodes WHERE source_id = ?1",
9887 "SELECT logical_id, body FROM canonical_edges WHERE source_id = ?1",
9888 source_id,
9889 )?;
9890
9891 // 0.8.20 Slice 5a (R-20-E1) — registry-driven erasure. The previous
9892 // hand-rolled list here OMITTED `search_index_v2`, a CONTENT-STORING
9893 // FTS5 table (no `content=''`) that keeps the document body verbatim:
9894 // after `excise_source` the erased body was still on disk, invisible to
9895 // every functional test because both v2 read paths discard candidates
9896 // lacking a live `canonical_nodes` row. `erase_row_projections` covers
9897 // every registered projection, so the omission cannot recur.
9898 let mut shadow_invalidated: u64 = 0;
9899 for cursor in node_cursors.iter().chain(edge_cursors.iter()) {
9900 shadow_invalidated = shadow_invalidated.saturating_add(
9901 erase_row_projections(&tx, *cursor).map_err(|_| EngineError::Storage)?,
9902 );
9903 }
9904
9905 let nodes_excised = tx
9906 .execute("DELETE FROM canonical_nodes WHERE source_id = ?1", [source_id])
9907 .map_err(|_| EngineError::Storage)? as u64;
9908 let edges_excised = tx
9909 .execute("DELETE FROM canonical_edges WHERE source_id = ?1", [source_id])
9910 .map_err(|_| EngineError::Storage)? as u64;
9911
9912 // AC-028a audit row: a single append on the
9913 // `excise_source_audit` collection naming the excised source.
9914 //
9915 // DURABILITY (0.8.20 Slice 5b, design v5 §2 defect D-A; HITL-ruled
9916 // 2026-07-19: *"there must be an auditable record of deletion event."*).
9917 // This row lands in `operational_mutations`, the same table the retention
9918 // sweep drains — and it is written BEFORE the workload that follows it,
9919 // so an oldest-`id`-first sweep evicted it FIRST. It is now protected:
9920 // `excise_source_audit` is in `ERASURE_AUDIT_COLLECTIONS`, which
9921 // `enforce_provenance_retention` excludes. The proof of erasure is no
9922 // longer destructible by ordinary retention pressure.
9923 //
9924 // NON-PII `source_id` (rationale corrected in this slice). v4 §3.6
9925 // justified the "`source_id` must not be PII" rule by claiming the audit
9926 // row retains it *permanently, by design*. That premise was FALSE — the
9927 // row was sweepable. The rule stands on a different and simpler footing:
9928 // this row persists the caller's raw `source_id` verbatim, and an
9929 // `excise_source` that erased the payload while keeping an identifying
9930 // source label would not be an erasure. The exemption above makes the
9931 // retention now genuinely indefinite, which makes the rule MORE
9932 // load-bearing, not less.
9933 //
9934 // `next_cursor` after a prior write holds the LAST committed cursor;
9935 // mirror the vec writer pattern (load + 1, then store post-commit)
9936 // so the audit row's `write_cursor` is strictly greater than every
9937 // canonical row that preceded it.
9938 let excised_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
9939 let payload = serde_json::json!({
9940 "source_id": source_id,
9941 "excised_at": excised_at,
9942 "nodes_excised": nodes_excised,
9943 "edges_excised": edges_excised,
9944 "projections_invalidated": shadow_invalidated,
9945 })
9946 .to_string();
9947 let audit_cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
9948 tx.execute(
9949 "INSERT INTO operational_mutations(
9950 collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
9951 ) VALUES('excise_source_audit', ?1, 'append', ?2, NULL, ?3)",
9952 params![source_id, payload, audit_cursor],
9953 )
9954 .map_err(|_| EngineError::Storage)?;
9955
9956 // 0.8.20 Slice 5 fix-1 (codex §9 P2) — durably record the redaction this
9957 // erasure owes, atomically with the deletes. Shares `audit_cursor`: one
9958 // erasure event, and this row is retired as soon as the sink is clean.
9959 if self.telemetry_enabled.load(Ordering::Acquire) {
9960 enqueue_pending_redaction(&tx, verb, &erased_stable_ids, audit_cursor)?;
9961 }
9962
9963 tx.commit().map_err(|_| EngineError::Storage)?;
9964 self.next_cursor.store(audit_cursor, Ordering::SeqCst);
9965 Ok(ExciseReport {
9966 source_ref: source_id.to_string(),
9967 nodes_excised,
9968 edges_excised,
9969 projections_invalidated: shadow_invalidated,
9970 })
9971 }
9972
9973 /// 0.8.20 Slice 5b (R-20-E7) — erase ONE op-store record, by collection and
9974 /// record key, from both op-store shapes: every `operational_mutations`
9975 /// version of the key (append-only-log collections) and its
9976 /// `operational_state` row (latest-state collections).
9977 ///
9978 /// **Refuses the engine's own erasure bookkeeping** (0.8.20 Slice 5 fix-3,
9979 /// codex §9 round-3 P1): a `collection` for which
9980 /// [`is_erasure_bookkeeping_collection`] holds raises
9981 /// [`EngineError::InvalidArgument`] before anything is deleted. Aimed at the
9982 /// pending-redaction queue this verb otherwise destroys an outstanding
9983 /// erasure obligation, after which the next erasure verb reports success with
9984 /// the erased ids still in the telemetry sink; aimed at the audit trail it
9985 /// destroys the auditable record of the deletion event.
9986 ///
9987 /// Before this slice the op-store had NO record-level delete at all:
9988 /// [`enforce_provenance_retention`] is a cap sweep, not an erasure verb, so a
9989 /// caller holding an erasure obligation over an op-store record had no way to
9990 /// discharge it. Idempotent — erasing an absent key is a zero-count success.
9991 ///
9992 /// Like the other erasure verbs this finishes at rest (telemetry is not
9993 /// involved — op-store record keys never reach the telemetry sink — but the
9994 /// `-wal` is), so it can return [`EngineError::ErasureIncomplete`].
9995 ///
9996 /// AUDIT (D-A). Appends a row to the retention-exempt `excise_record_audit`
9997 /// collection. Unlike `source_id`, a `record_key` carries NO non-PII rule:
9998 /// it is arbitrary caller-supplied text and may itself be the identifier
9999 /// being erased. The audit therefore records a SHA-256 digest of
10000 /// `collection` + `record_key`, never the key — enough to prove *that* a
10001 /// specific record was erased to anyone who already knows the key, and
10002 /// useless to anyone who does not.
10003 #[cfg(feature = "operator")]
10004 pub fn excise_collection_record(
10005 &self,
10006 collection: &str,
10007 record_key: &str,
10008 ) -> Result<ExciseRecordReport, EngineError> {
10009 self.ensure_open()?;
10010 if collection.is_empty() || record_key.is_empty() {
10011 return Err(EngineError::WriteValidation);
10012 }
10013 // 0.8.20 Slice 5 fix-3 (codex §9 round-3 P1) — the engine's own erasure
10014 // bookkeeping is not caller data and is not excisable. See
10015 // `is_erasure_bookkeeping_collection` for why each member is protected.
10016 // Checked BEFORE any deletion so the refusal is total, not partial.
10017 if is_erasure_bookkeeping_collection(collection) {
10018 return Err(EngineError::InvalidArgument {
10019 msg: format!(
10020 "`{collection}` is engine-internal erasure bookkeeping and cannot be excised \
10021 by `excise_collection_record`. The pending-redaction queue records an \
10022 erasure the engine still owes (deleting it would let a later verb report \
10023 success on an incomplete erasure, R-20-E5), and the erasure-audit \
10024 collections are the auditable record of the deletion event. Pending \
10025 redactions retire themselves once performed; retry the erasure verb instead."
10026 ),
10027 });
10028 }
10029 let report = self.excise_collection_record_inner(collection, record_key)?;
10030 self.complete_erasure_at_rest("excise_collection_record")?;
10031 Ok(report)
10032 }
10033
10034 #[cfg(feature = "operator")]
10035 fn excise_collection_record_inner(
10036 &self,
10037 collection: &str,
10038 record_key: &str,
10039 ) -> Result<ExciseRecordReport, EngineError> {
10040 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
10041 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
10042 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
10043
10044 let records_excised = tx
10045 .execute(
10046 "DELETE FROM operational_mutations
10047 WHERE collection_name = ?1 AND record_key = ?2",
10048 params![collection, record_key],
10049 )
10050 .map_err(|_| EngineError::Storage)? as u64;
10051 let state_rows_excised = tx
10052 .execute(
10053 "DELETE FROM operational_state
10054 WHERE collection_name = ?1 AND record_key = ?2",
10055 params![collection, record_key],
10056 )
10057 .map_err(|_| EngineError::Storage)? as u64;
10058
10059 let record_digest = digest_record_identity(collection, record_key);
10060 let excised_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
10061 let payload = serde_json::json!({
10062 "collection": collection,
10063 "record_digest": record_digest,
10064 "excised_at": excised_at,
10065 "records_excised": records_excised,
10066 "state_rows_excised": state_rows_excised,
10067 })
10068 .to_string();
10069 let audit_cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
10070 tx.execute(
10071 "INSERT INTO operational_mutations(
10072 collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
10073 ) VALUES('excise_record_audit', ?1, 'append', ?2, NULL, ?3)",
10074 params![record_digest, payload, audit_cursor],
10075 )
10076 .map_err(|_| EngineError::Storage)?;
10077
10078 tx.commit().map_err(|_| EngineError::Storage)?;
10079 self.next_cursor.store(audit_cursor, Ordering::SeqCst);
10080 self.counters.record_admin();
10081 Ok(ExciseRecordReport {
10082 collection: collection.to_string(),
10083 record_digest,
10084 records_excised,
10085 state_rows_excised,
10086 })
10087 }
10088
10089 #[cfg(feature = "operator")]
10090 fn run_rebuild(
10091 &self,
10092 include_fts: bool,
10093 kind: RebuildKind,
10094 ) -> Result<RebuildReport, EngineError> {
10095 self.projection_runtime.set_frozen(true);
10096 // Drain MUST succeed: rebuild_shadow_state truncates shadow rows,
10097 // and SQLite-WAL allows a worker that already dequeued a job to
10098 // commit its `INSERT OR IGNORE INTO _fathomdb_vector_rows / vec0`
10099 // after our truncate releases the writer lock, leaving stale
10100 // rows. Surfacing the timeout (instead of swallowing it) lets the
10101 // operator retry rather than silently corrupt the rebuild.
10102 let drain_result = self.drain(REBUILD_DRAIN_TIMEOUT_MS);
10103 let result = drain_result.and_then(|()| self.rebuild_shadow_state(include_fts, kind));
10104 self.projection_runtime.set_frozen(false);
10105 result
10106 }
10107
10108 #[cfg(feature = "operator")]
10109 fn rebuild_shadow_state(
10110 &self,
10111 include_fts: bool,
10112 kind: RebuildKind,
10113 ) -> Result<RebuildReport, EngineError> {
10114 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
10115 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
10116 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
10117 // 0.8.20 Slice 5a (R-20-E1) — registry-driven invalidation. A full
10118 // rebuild truncates EVERY row-owned projection (the previous hand-rolled
10119 // list omitted `search_index_v2`, so a rebuild neither dropped stale v2
10120 // rows nor repopulated the table); a vec0-only rebuild truncates the
10121 // vector + readiness classes exactly as before. Kind-owned watermark
10122 // state (`_fathomdb_projection_state`) is deliberately NOT truncated —
10123 // readiness is reset by rewinding the projection cursor below.
10124 let rows_invalidated = if include_fts {
10125 truncate_all_row_projections(&tx).map_err(|_| EngineError::Storage)?
10126 } else {
10127 truncate_row_projections_in(&tx, &[ProjectionClass::Vector, ProjectionClass::Readiness])
10128 .map_err(|_| EngineError::Storage)?
10129 };
10130 store_projection_cursor(&tx, 0).map_err(|_| EngineError::Storage)?;
10131 // 0.8.20 Slice 5a (R-20-E1, work item 1) — the replay runs through the
10132 // SAME two projectors the write path uses, so the rebuilt projections
10133 // are identical to what a re-write would have produced. `include_fts`
10134 // selects the pass: a vec0-only rebuild must not write FTS rows.
10135 let pass = if include_fts { ProjectionPass::Write } else { ProjectionPass::VectorOnly };
10136 let mut rows_rebuilt: u64 = 0;
10137 for row in canonical_node_rows(&tx).map_err(|_| EngineError::Storage)? {
10138 project_canonical_node_row(
10139 &tx,
10140 row.cursor,
10141 &row.kind,
10142 &row.body,
10143 row.row_kind,
10144 pass,
10145 // fix-2 [P2]: the attribute half of the replay tracks the backfill's
10146 // active-and-non-superseded row set; FTS / vector shadows still
10147 // rebuild for every row (read-side lifecycle filter, unchanged).
10148 row.attr_projected,
10149 )
10150 .map_err(|_| EngineError::Storage)?;
10151 if include_fts {
10152 rows_rebuilt = rows_rebuilt.saturating_add(1);
10153 }
10154 }
10155 // fix-26 [P2]: rebuild the edge shadows from active canonical_edges
10156 // (G11 search_index_edges).
10157 // 0.8.12 Slice A (R-CON-2 named default-ON blocker; Slice-20 codex
10158 // §9 [P2]): mirror the graph-traversal recency filter
10159 // (`edge_validity_sql`) here too, so a full rebuild does not re-surface
10160 // an edge that recency consolidation already invalidated.
10161 // 0.8.20 Slice 5a: body-less structural edges are now included in the
10162 // replay. They project no FTS/vector row, but the write path DOES record
10163 // their readiness terminal — which this rebuild truncated and (before
10164 // this slice) never restored, stalling `advance_projection_cursor`.
10165 // TC-33: the filter is generated by `edge_validity_sql` and `:now` is
10166 // bound (?1) rather than inlined as `datetime('now')`.
10167 let edge_rows: Vec<(i64, String, Option<String>)> = {
10168 let edge_sql = format!(
10169 "SELECT write_cursor, kind, body FROM canonical_edges \
10170 WHERE superseded_at IS NULL{}",
10171 edge_validity_sql("canonical_edges", 1)
10172 );
10173 let mut edge_stmt = tx.prepare(&edge_sql).map_err(|_| EngineError::Storage)?;
10174 let rows = edge_stmt
10175 .query_map(params![current_epoch_seconds()], |row| {
10176 Ok((
10177 row.get::<_, i64>(0)?,
10178 row.get::<_, String>(1)?,
10179 row.get::<_, Option<String>>(2)?,
10180 ))
10181 })
10182 .map_err(|_| EngineError::Storage)?
10183 .collect::<rusqlite::Result<_>>()
10184 .map_err(|_| EngineError::Storage)?;
10185 rows
10186 };
10187 for (cursor, kind, body) in edge_rows {
10188 let has_body = body.is_some();
10189 project_canonical_edge_row(&tx, cursor as u64, &kind, body.as_deref(), pass)
10190 .map_err(|_| EngineError::Storage)?;
10191 if include_fts && has_body {
10192 rows_rebuilt = rows_rebuilt.saturating_add(1);
10193 }
10194 }
10195 let projection_cursor_after =
10196 load_projection_cursor(&tx).map_err(|_| EngineError::Storage)?;
10197 tx.commit().map_err(|_| EngineError::Storage)?;
10198 Ok(RebuildReport { kind, rows_invalidated, rows_rebuilt, projection_cursor_after })
10199 }
10200
10201 fn ensure_open(&self) -> Result<(), EngineError> {
10202 if self.closed.load(Ordering::SeqCst) {
10203 return Err(EngineError::Closing);
10204 }
10205
10206 Ok(())
10207 }
10208}
10209
10210fn batch_is_admin(batch: &[PreparedWrite]) -> bool {
10211 !batch.is_empty() && batch.iter().all(|w| matches!(w, PreparedWrite::AdminSchema { .. }))
10212}
10213
10214// 0.7.0 Pack 2 (ADR-0.7.0-vector-binary-quant § 2; handoff § 2.2):
10215// bit-KNN candidate-set size for the two-phase read path. Tuned with
10216// the recall@10 floor in tests/perf_gates.rs::ac_013b_recall_at_10_floor.
10217//
10218// Bumped from 64 → 192 in EU-5a2 per the HITL 2026-05-29 fine-grained
10219// K-sweep result (dev/notes/0.7.1-default-embedder-research.md §5.4):
10220// K=192 sits above the recall-plateau knee for the default embedder.
10221// Public-visible so the EU-5a2 machinery test can assert the value.
10222pub const TOP_K_BIT_CANDIDATES: usize = 192;
10223
10224/// EU-5a2 — number of documents required before the workspace's
10225/// `_fathomdb_embedder_profiles.mean_vec` is pinned for the default
10226/// profile. Per `dev/design/embedder.md` §0.3 (compute-once-on-first-
10227/// ingest lifecycle). Public-visible so the EU-5a2 machinery test can
10228/// assert the value.
10229pub const MEAN_VEC_PIN_THRESHOLD: u64 = 256;
10230
10231/// Historical name for the public default ranked-result count (10).
10232///
10233/// Vector candidate fanout is separately at least the caller-requested result
10234/// limit and `TOP_K_BIT_CANDIDATES`; the test seam may raise that fanout without
10235/// changing visible result cardinality. There is no environment-variable
10236/// override on the hot path.
10237pub const SEARCH_RERANK_LIMIT: usize = 10;
10238
10239/// Default number of ranked hits returned by public retrieval APIs.
10240pub const DEFAULT_SEARCH_RESULT_LIMIT: usize = SEARCH_RERANK_LIMIT;
10241
10242/// Largest ranked-hit count a public retrieval request may select.
10243pub const MAX_SEARCH_RESULT_LIMIT: usize = 100;
10244
10245fn validate_search_result_limit(limit: usize) -> Result<usize, EngineError> {
10246 if !(1..=MAX_SEARCH_RESULT_LIMIT).contains(&limit) {
10247 return Err(EngineError::InvalidArgument {
10248 msg: format!(
10249 "search result limit must be an integer in 1..={MAX_SEARCH_RESULT_LIMIT}; got {limit}"
10250 ),
10251 });
10252 }
10253 Ok(limit)
10254}
10255
10256/// EU-5a2 — streaming f64 accumulator for the mean-centering pipeline,
10257/// per `dev/design/embedder.md` §0.3 (f64 chosen to bound numerical
10258/// drift across `MEAN_VEC_PIN_THRESHOLD` adds). Owned by the projection
10259/// worker; materialized into the schema column at the threshold cross.
10260#[derive(Clone, Debug)]
10261struct MeanAccumulator {
10262 sum: Vec<f64>,
10263 count: u64,
10264}
10265
10266impl MeanAccumulator {
10267 fn new(dim: usize) -> Self {
10268 Self { sum: vec![0.0; dim], count: 0 }
10269 }
10270
10271 fn add(&mut self, v: &[f32]) {
10272 debug_assert_eq!(v.len(), self.sum.len(), "accumulator dim mismatch");
10273 for (slot, value) in self.sum.iter_mut().zip(v.iter()) {
10274 *slot += f64::from(*value);
10275 }
10276 self.count = self.count.saturating_add(1);
10277 }
10278
10279 fn materialize(&self) -> Vec<f32> {
10280 if self.count == 0 {
10281 return vec![0.0; self.sum.len()];
10282 }
10283 let denom = self.count as f64;
10284 self.sum.iter().map(|s| (s / denom) as f32).collect()
10285 }
10286
10287 fn count(&self) -> u64 {
10288 self.count
10289 }
10290}
10291
10292/// 0.7.2 PR-2b — cosine similarity between two equal-length vectors.
10293/// Returns 1.0 for a pair with a zero-norm operand (treated as "no drift
10294/// signal"), so the detector never fires on a degenerate all-zero mean.
10295fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
10296 if a.len() != b.len() {
10297 return 1.0;
10298 }
10299 let mut dot = 0.0f64;
10300 let mut na = 0.0f64;
10301 let mut nb = 0.0f64;
10302 for (x, y) in a.iter().zip(b.iter()) {
10303 dot += f64::from(*x) * f64::from(*y);
10304 na += f64::from(*x) * f64::from(*x);
10305 nb += f64::from(*y) * f64::from(*y);
10306 }
10307 if na == 0.0 || nb == 0.0 {
10308 return 1.0;
10309 }
10310 (dot / (na.sqrt() * nb.sqrt())) as f32
10311}
10312
10313/// EU-5b — at-pin pin-and-requantize pass per `dev/design/embedder.md`
10314/// §0.5. Runs INSIDE the caller's SQLite transaction so the mean_vec
10315/// INSERT/UPDATE + the per-row sign-bit UPDATEs commit atomically.
10316///
10317/// For each pre-pin row, recomputes `bits' = sign_quantize(f32 - mean)`
10318/// via the SQL extension's `vec_quantize_binary`, then UPDATEs the
10319/// row's `embedding_bin` column.
10320fn run_pin_and_requantize_pass(
10321 tx: &rusqlite::Transaction<'_>,
10322 rows: &[(i64, Vec<u8>)],
10323 mean: &[f32],
10324) -> Result<(u64, Vec<EmbedderEvent>), EngineError> {
10325 let mut updated: u64 = 0;
10326 let dim = mean.len();
10327 // sqlite-vec's vec0 xUpdate path discards SQL-function result subtypes
10328 // (see sqlite-vec.c §vec0Update_UpdateVectorColumn — "subtypes don't
10329 // appear to survive xColumn -> xUpdate, it's always 0"), so a direct
10330 // `UPDATE ... SET embedding_bin = vec_quantize_binary(?)` reads the
10331 // bound value as a float32-tagged vector and trips the column-type
10332 // check. We work around by DELETE+INSERT inside the same transaction:
10333 // INSERT preserves the BIT subtype on `vec_quantize_binary`. The
10334 // surrounding pin-commit tx keeps the rewrite atomic.
10335 for (rowid, blob) in rows {
10336 if blob.len() != dim * 4 {
10337 return Err(EngineError::Storage);
10338 }
10339 let un_centered = decode_vector_blob(blob);
10340 let centered = subtract_mean(&un_centered, mean);
10341 let centered_blob = encode_vector_blob(¢ered);
10342
10343 let (source_type, kind, created_at): (String, String, i64) = tx
10344 .query_row(
10345 "SELECT source_type, kind, created_at FROM vector_default WHERE rowid = ?1",
10346 params![rowid],
10347 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
10348 )
10349 .map_err(|_| EngineError::Storage)?;
10350
10351 // 0.8.20 Slice 15e — this DELETE+INSERT re-quantize (its BIT-subtype
10352 // workaround, condition #4's SEPARATE same-shape op) must PRESERVE every
10353 // `filterable` `attr_<hex>` value, not just re-`''` them: a projection may
10354 // have been declared before the pin. Read the live attr columns + this
10355 // row's values BEFORE the DELETE, then re-bind them. Empty ⇒ the INSERT is
10356 // byte-identical to the shipped statement.
10357 let attr_cols = actual_vector_attr_columns(tx).map_err(|_| EngineError::Storage)?;
10358 let attr_vals: Vec<String> = if attr_cols.is_empty() {
10359 Vec::new()
10360 } else {
10361 let select = attr_cols.join(", ");
10362 tx.query_row(
10363 &format!("SELECT {select} FROM vector_default WHERE rowid = ?1"),
10364 params![rowid],
10365 |row| {
10366 let mut vals = Vec::with_capacity(attr_cols.len());
10367 for i in 0..attr_cols.len() {
10368 vals.push(row.get::<_, String>(i)?);
10369 }
10370 Ok(vals)
10371 },
10372 )
10373 .map_err(|_| EngineError::Storage)?
10374 };
10375
10376 delete_vector_partition_row(tx, *rowid).map_err(|_| EngineError::Storage)?;
10377
10378 // Slice 10 / G10 — `status` ships the empty-string sentinel (vec0 TEXT
10379 // metadata is NOT NULL-able).
10380 let mut cols_sql = String::new();
10381 let mut ph_sql = String::new();
10382 for (i, col) in attr_cols.iter().enumerate() {
10383 cols_sql.push_str(&format!(", {col}"));
10384 ph_sql.push_str(&format!(", ?{}", 7 + i));
10385 }
10386 let sql = format!(
10387 "INSERT INTO vector_default(
10388 rowid, embedding, embedding_bin, source_type, kind, created_at, status{cols_sql}
10389 ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, ''{ph_sql})"
10390 );
10391 let mut pv: Vec<rusqlite::types::Value> = vec![
10392 rusqlite::types::Value::Integer(*rowid),
10393 rusqlite::types::Value::Blob(blob.clone()),
10394 rusqlite::types::Value::Blob(centered_blob),
10395 rusqlite::types::Value::Text(source_type),
10396 rusqlite::types::Value::Text(kind),
10397 rusqlite::types::Value::Integer(created_at),
10398 ];
10399 for v in attr_vals {
10400 pv.push(rusqlite::types::Value::Text(v));
10401 }
10402 tx.execute(&sql, rusqlite::params_from_iter(pv.iter()))
10403 .map_err(|_| EngineError::Storage)?;
10404
10405 updated = updated.saturating_add(1);
10406 }
10407 let events = vec![EmbedderEvent::MeanVecPinned {
10408 dim: u32::try_from(dim).unwrap_or(u32::MAX),
10409 doc_count: updated,
10410 }];
10411 Ok((updated, events))
10412}
10413
10414/// EU-5a2 — back-compat test-only count+emit helper. Preserved so the
10415/// EU-5a2 machinery test stays green; the EU-5b production path uses
10416/// `run_pin_and_requantize_pass`.
10417fn run_requantize_pass(rows: &[(i64, Vec<u8>)], mean: &[f32]) -> (u64, Vec<EmbedderEvent>) {
10418 let mut updated: u64 = 0;
10419 let dim = mean.len();
10420 for (_rowid, blob) in rows {
10421 if blob.len() != dim * 4 {
10422 continue;
10423 }
10424 updated = updated.saturating_add(1);
10425 }
10426 let events = vec![EmbedderEvent::MeanVecPinned {
10427 dim: u32::try_from(dim).unwrap_or(u32::MAX),
10428 doc_count: updated,
10429 }];
10430 (updated, events)
10431}
10432
10433/// EU-5a2 — test-visible re-exports of the mean-centering internals.
10434/// Per the handoff RED tests; the production accumulator and re-quantize
10435/// pass are otherwise crate-private.
10436#[doc(hidden)]
10437pub mod mean_centering_internals_for_test {
10438 use super::{EmbedderEvent, MeanAccumulator};
10439
10440 pub struct AccumulatorHandle(MeanAccumulator);
10441
10442 #[must_use]
10443 pub fn new_mean_accumulator(dim: usize) -> AccumulatorHandle {
10444 AccumulatorHandle(MeanAccumulator::new(dim))
10445 }
10446
10447 pub fn accumulator_add(handle: &mut AccumulatorHandle, v: &[f32]) {
10448 handle.0.add(v);
10449 }
10450
10451 #[must_use]
10452 pub fn accumulator_materialize(handle: &AccumulatorHandle) -> Vec<f32> {
10453 handle.0.materialize()
10454 }
10455
10456 #[must_use]
10457 pub fn accumulator_count(handle: &AccumulatorHandle) -> u64 {
10458 handle.0.count()
10459 }
10460
10461 #[must_use]
10462 pub fn run_requantize_pass(rows: &[(i64, Vec<u8>)], mean: &[f32]) -> (u64, Vec<EmbedderEvent>) {
10463 super::run_requantize_pass(rows, mean)
10464 }
10465}
10466
10467/// G9 — Reciprocal Rank Fusion constant. IR-C (2026-06-10b,
10468/// `performance-output-and-compare.md`) found the standard `k≈60` slightly too
10469/// high: the recall gain is concentrated at the top of the list, where a lower
10470/// `k` sharpens rank-1/2 contributions. `k=30` is the validated operating point
10471/// (`k10 > k30 > k60 > k100` on the sweep, `30` the conservative middle).
10472/// Fusion is on **rank**, never raw score.
10473pub const RRF_K: f64 = 30.0;
10474
10475/// G9 / IR-C — per-branch RRF weights. The sweep's optimum is strongly
10476/// **text-dominant** (`text:vector ≈ 3:1`): the lexical (BM25) arm carries
10477/// exact-fact recall and the dense arm, over-weighted, is a net drag on
10478/// exploratory recall (`performance-output-and-compare.md`, 2026-06-10b/e). A
10479/// branch contributes `weight / (RRF_K + rank)`.
10480pub const RRF_WEIGHT_VECTOR: f64 = 1.0;
10481pub const RRF_WEIGHT_TEXT: f64 = 3.0;
10482/// R3 (Slice 30) — graph arm RRF weight. Conservative starting value (equal to
10483/// `RRF_WEIGHT_VECTOR`). Without R2 per-class delta data the graph arm weight
10484/// cannot be calibrated; 1.0 is the minimum non-zero contribution. The graph
10485/// arm surfaces newly-reachable nodes from BFS traversal; it is not meant to
10486/// override the primary text/vector signals. Revisable after R2 data arrives.
10487/// See `dev/design/slice-30-design.md` §Q2.
10488pub const RRF_WEIGHT_GRAPH: f64 = 1.0;
10489
10490/// G12-recency — additive recency weight. Must satisfy two constraints:
10491/// 1. Small enough to never override a clear RRF signal: a gap of > RECENCY_WEIGHT
10492/// between two hits' RRF scores means the stronger RRF hit always wins.
10493/// 2. Large enough to break exact ties: any hit with a higher `write_cursor` (more
10494/// recent) gets RECENCY_WEIGHT × 1.0 > 0 nudge and wins a tied comparison.
10495///
10496/// Value 0.002 satisfies the near-tie-nudge contract with respect to the
10497/// committed test (`recency_does_not_override_a_clear_rrf_signal`):
10498/// the test's RRF gap is 0.01, which is larger than 0.002, so recency
10499/// never overrides it. Note: this value is larger than the minimum
10500/// vector-only rank-step at deep ranks (~0.00101 for adjacent ranks near
10501/// the bottom), so recency can flip a single-rank vector difference at
10502/// deep ranks — by design, recency is a near-tie nudge, and "near-tie"
10503/// is scoped to the test gap (0.01), not to every possible rank step.
10504///
10505/// 0.8.1 Slice 10 fix: the previous value `0.5/RRF_K ≈ 0.01667` violated
10506/// the test gap constraint (it exceeded 0.01). Lowered to 0.002.
10507pub const RECENCY_WEIGHT: f64 = 0.002;
10508
10509/// 0.8.8 Slice 15 — the lowercase wire string for a retrieval arm (telemetry +
10510/// the same spelling `SearchHit.branch` crosses every binding).
10511fn branch_str(branch: SoftFallbackBranch) -> &'static str {
10512 match branch {
10513 SoftFallbackBranch::Vector => "vector",
10514 SoftFallbackBranch::Text => "text",
10515 SoftFallbackBranch::TextEdge => "text_edge",
10516 SoftFallbackBranch::GraphArm => "graph_arm",
10517 }
10518}
10519
10520/// 0.8.8 Slice 15 — append one JSON value as a line to the telemetry sink
10521/// (append-only, local file; no network). Best-effort caller handles the error.
10522fn append_jsonl(path: &Path, value: &serde_json::Value) -> std::io::Result<()> {
10523 let mut file = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
10524 writeln!(file, "{value}")?;
10525 Ok(())
10526}
10527
10528/// 0.8.20 Slice 5b (R-20-E6) — rewrite a telemetry JSONL sink with every
10529/// `result_stable_ids` element in `erased` replaced by [`REDACTED_STABLE_ID`].
10530///
10531/// **Selective, never truncating.** Every line is carried across: records that
10532/// reference no erased id are byte-identical, records that do keep their shape
10533/// and only lose the matching id VALUES, and a line that is not an
10534/// engine-authored JSON event (an operator note, a hand-appended record) is
10535/// copied through verbatim. The sink is a caller-supplied path that may hold
10536/// unrelated eval history; destroying it is not part of any erasure obligation.
10537///
10538/// **Crash safety.** Write-temp-then-`rename`: the redacted content goes to a
10539/// sibling `<sink>.redact.tmp`, is `sync_all`ed, and is then atomically renamed
10540/// over the sink. A crash at any point leaves either the intact old file or the
10541/// complete new one — never a half-rewritten sink. `rename` is atomic because
10542/// the temp file is a sibling (same directory ⇒ same filesystem).
10543///
10544/// **Concurrent appends.** The caller holds the telemetry mutex, so no
10545/// in-process `capture_telemetry` can append into the window. An out-of-process
10546/// appender is still possible (the sink is just a file), so before renaming we
10547/// re-check the source length: if it grew, the tail delta is read, redacted and
10548/// appended, and the check repeats — bounded, so a pathologically hot external
10549/// writer surfaces as an error rather than an unbounded loop.
10550fn redact_jsonl_stable_ids(
10551 path: &Path,
10552 erased: &std::collections::HashSet<&str>,
10553) -> std::io::Result<()> {
10554 /// Bound on the re-check loop for an out-of-process appender.
10555 const MAX_TAIL_FOLDS: usize = 8;
10556
10557 let mut source = std::fs::read(path)?;
10558 let mut redacted = redact_jsonl_bytes(&source, erased);
10559
10560 for _ in 0..MAX_TAIL_FOLDS {
10561 let current = std::fs::read(path)?;
10562 if current.len() == source.len() {
10563 let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
10564 tmp_name.push(".redact.tmp");
10565 let tmp = path.with_file_name(tmp_name);
10566 {
10567 let mut file = std::fs::File::create(&tmp)?;
10568 file.write_all(&redacted)?;
10569 file.sync_all()?;
10570 }
10571 std::fs::rename(&tmp, path)?;
10572 return Ok(());
10573 }
10574 // Someone appended while we were building the replacement: fold the
10575 // delta in (redacted) rather than dropping it, then re-check.
10576 if current.len() > source.len() && current.starts_with(&source) {
10577 redacted.extend_from_slice(&redact_jsonl_bytes(¤t[source.len()..], erased));
10578 } else {
10579 // The file was rewritten under us, not appended to. Start over.
10580 redacted = redact_jsonl_bytes(¤t, erased);
10581 }
10582 source = current;
10583 }
10584 Err(std::io::Error::other(format!(
10585 "telemetry sink {} is being appended to faster than it can be redacted",
10586 path.display()
10587 )))
10588}
10589
10590/// Line-wise redaction of a JSONL byte buffer. Non-JSON and non-event lines are
10591/// passed through unchanged, as is a trailing partial line (no terminating
10592/// newline) — the sink is append-only, so a partial tail is a torn write, not
10593/// ours to normalize.
10594fn redact_jsonl_bytes(bytes: &[u8], erased: &std::collections::HashSet<&str>) -> Vec<u8> {
10595 let mut out = Vec::with_capacity(bytes.len());
10596 let mut rest = bytes;
10597 while !rest.is_empty() {
10598 let (line, tail) = match rest.iter().position(|b| *b == b'\n') {
10599 Some(idx) => (&rest[..idx], &rest[idx + 1..]),
10600 // No trailing newline: a torn/partial final line. Pass it through.
10601 None => {
10602 out.extend_from_slice(rest);
10603 break;
10604 }
10605 };
10606 match redact_jsonl_line(line, erased) {
10607 Some(replacement) => out.extend_from_slice(replacement.as_bytes()),
10608 None => out.extend_from_slice(line),
10609 }
10610 out.push(b'\n');
10611 rest = tail;
10612 }
10613 out
10614}
10615
10616/// `Some(replacement)` when the line is an engine-authored telemetry event whose
10617/// `result_stable_ids` referenced an erased id; `None` to pass it through.
10618fn redact_jsonl_line(line: &[u8], erased: &std::collections::HashSet<&str>) -> Option<String> {
10619 let text = std::str::from_utf8(line).ok()?;
10620 let mut value: serde_json::Value = serde_json::from_str(text).ok()?;
10621 let ids = value.get_mut("result_stable_ids")?.as_array_mut()?;
10622 let mut touched = false;
10623 for id in ids.iter_mut() {
10624 if id.as_str().is_some_and(|s| erased.contains(s)) {
10625 *id = serde_json::Value::from(REDACTED_STABLE_ID);
10626 touched = true;
10627 }
10628 }
10629 touched.then(|| value.to_string())
10630}
10631
10632/// G9 — fuse the vector and text branches with Reciprocal Rank Fusion.
10633///
10634/// Delegates to [`fuse_three_arms`] with an empty graph arm. The two-arm
10635/// contract is preserved: `fuse_rrf(v, t)` == `fuse_three_arms(v, t, vec![])`.
10636/// All existing callers are unaffected.
10637///
10638/// See [`fuse_three_arms`] for the full RRF formula documentation.
10639#[doc(hidden)]
10640#[must_use]
10641pub fn fuse_rrf(vector_hits: Vec<SearchHit>, text_hits: Vec<SearchHit>) -> Vec<SearchHit> {
10642 fuse_three_arms(vector_hits, text_hits, vec![])
10643}
10644
10645/// R3 (Slice 30) — fuse vector, text, and graph arms with Reciprocal Rank Fusion.
10646///
10647/// Each branch contributes `weight / (RRF_K + rank)` (1-based rank within that
10648/// branch; `weight` = [`RRF_WEIGHT_VECTOR`] / [`RRF_WEIGHT_TEXT`] /
10649/// [`RRF_WEIGHT_GRAPH`], text-dominant per IR-C), accumulated **keyed on
10650/// `SearchHit.body`**, so a body surfaced by multiple branches accumulates all
10651/// terms (agreement boosts it). The fused value is written into `SearchHit.score`.
10652/// A body in multiple branches surfaces **once** with the **vector** branch's
10653/// identity (vector-first), then graph arm identity for non-vector hits, then
10654/// text. Output is sorted by score descending, then vector-first, then insertion
10655/// order — a pure, deterministic function of the three input lists.
10656///
10657/// With an empty `graph_hits` (`vec![]`), the output is byte-identical to the
10658/// pre-Slice-30 two-arm `fuse_rrf`. This is the backward-compatibility contract.
10659///
10660/// This is the **unconditional** new ranking (HITL Q3 — no `fusion_mode` knob,
10661/// no legacy path). Graph arm is opt-in via `use_graph_arm=true`.
10662#[doc(hidden)]
10663#[must_use]
10664pub fn fuse_three_arms(
10665 vector_hits: Vec<SearchHit>,
10666 text_hits: Vec<SearchHit>,
10667 graph_hits: Vec<SearchHit>,
10668) -> Vec<SearchHit> {
10669 struct Entry {
10670 hit: SearchHit,
10671 score: f64,
10672 in_vector: bool,
10673 order: usize,
10674 }
10675 let mut entries: Vec<Entry> = Vec::new();
10676 let mut accumulate = |hit: SearchHit, rank0: usize, in_vector: bool, weight: f64| {
10677 let contrib = weight / (RRF_K + (rank0 as f64 + 1.0));
10678 if let Some(existing) = entries.iter_mut().find(|e| e.hit.body == hit.body) {
10679 // Dedup on body; the representative hit (vector-first) is retained.
10680 existing.score += contrib;
10681 } else {
10682 let order = entries.len();
10683 entries.push(Entry { hit, score: contrib, in_vector, order });
10684 }
10685 };
10686 for (rank0, hit) in vector_hits.into_iter().enumerate() {
10687 accumulate(hit, rank0, true, RRF_WEIGHT_VECTOR);
10688 }
10689 for (rank0, hit) in text_hits.into_iter().enumerate() {
10690 accumulate(hit, rank0, false, RRF_WEIGHT_TEXT);
10691 }
10692 for (rank0, hit) in graph_hits.into_iter().enumerate() {
10693 // Graph arm: vector-first=false (never overrides an existing vector hit's
10694 // representative identity; only new bodies from the graph arm get GraphArm
10695 // as their branch identity). The in_vector=false ensures graph arm hits
10696 // never sort ahead of vector hits on exact score ties.
10697 accumulate(hit, rank0, false, RRF_WEIGHT_GRAPH);
10698 }
10699 entries.sort_by(|a, b| {
10700 b.score
10701 .partial_cmp(&a.score)
10702 .unwrap_or(std::cmp::Ordering::Equal)
10703 // vector-first on equal score (true sorts before false).
10704 .then_with(|| b.in_vector.cmp(&a.in_vector))
10705 .then_with(|| a.order.cmp(&b.order))
10706 });
10707 entries
10708 .into_iter()
10709 .map(|mut e| {
10710 e.hit.score = e.score;
10711 e.hit
10712 })
10713 .collect()
10714}
10715
10716/// G12-recency — reweight fused hits toward the more recent (higher
10717/// `write_cursor`/`id`) AFTER bit-KNN (never a vec0 predicate). Gated by the
10718/// caller's dedicated recency flag; `enabled=false` is a no-op (pure RRF).
10719#[doc(hidden)]
10720#[must_use]
10721pub fn apply_recency_reweight(hits: Vec<SearchHit>, enabled: bool) -> Vec<SearchHit> {
10722 if !enabled || hits.len() < 2 {
10723 return hits;
10724 }
10725 let min_id = hits.iter().map(|h| h.write_cursor).min().unwrap_or(0);
10726 let max_id = hits.iter().map(|h| h.write_cursor).max().unwrap_or(0);
10727 if max_id == min_id {
10728 return hits;
10729 }
10730 let span = (max_id - min_id) as f64;
10731 let mut reweighted: Vec<SearchHit> = hits
10732 .into_iter()
10733 .map(|mut h| {
10734 let norm = (h.write_cursor - min_id) as f64 / span;
10735 h.score += RECENCY_WEIGHT * norm;
10736 h
10737 })
10738 .collect();
10739 // Stable sort preserves the fused order on exact ties.
10740 reweighted.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
10741 reweighted
10742}
10743
10744/// 0.8.16 Slice 5 / F9 — OFF-by-default importance/confidence reweight, applied
10745/// to the fused hits AFTER bit-KNN + RRF (mirrors [`apply_recency_reweight`]).
10746///
10747/// Multiplicative-on-fused (ADR-0.8.16 §2.2, HITL-SIGNED 2026-07-08): a hit's
10748/// score is scaled by node `importance` (`importance_by_id`) × edge `confidence`
10749/// (`confidence_by_id`), each keyed by the hit's interim id (`write_cursor`). A
10750/// missing key = `NULL` = never assigned = graceful-absent ⇒ neutral (1.0), the
10751/// OPP-12 Q6a graceful-absent state. Node hits carry `importance`; graph/edge hits
10752/// carry `confidence`; the two id-spaces never collide (cursors are globally
10753/// unique), so each hit gets exactly one non-neutral factor.
10754///
10755/// **R-F9-4 graceful-neutral identity:** when `enabled` but *no* hit has a
10756/// non-neutral factor (every importance/confidence absent), the input is returned
10757/// **unchanged** — byte-identical to the `enabled == false` result (no re-sort),
10758/// so declaring the mechanism never perturbs an all-absent corpus.
10759#[must_use]
10760pub fn apply_importance_reweight(
10761 hits: Vec<SearchHit>,
10762 importance_by_id: &HashMap<u64, f64>,
10763 confidence_by_id: &HashMap<u64, f64>,
10764 enabled: bool,
10765) -> Vec<SearchHit> {
10766 if !enabled {
10767 return hits;
10768 }
10769 // Graceful-neutral fast path (R-F9-4): if nothing is weighted, do not touch
10770 // order or scores — identical to the reweight-OFF result.
10771 let any_weighted = hits.iter().any(|h| {
10772 importance_by_id.contains_key(&h.write_cursor)
10773 || confidence_by_id.contains_key(&h.write_cursor)
10774 });
10775 if !any_weighted {
10776 return hits;
10777 }
10778 let mut reweighted: Vec<SearchHit> = hits
10779 .into_iter()
10780 .map(|mut h| {
10781 let importance = importance_by_id.get(&h.write_cursor).copied().unwrap_or(1.0);
10782 let confidence = confidence_by_id.get(&h.write_cursor).copied().unwrap_or(1.0);
10783 h.score *= importance * confidence;
10784 h
10785 })
10786 .collect();
10787 // Stable sort preserves the fused order on exact ties.
10788 reweighted.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
10789 reweighted
10790}
10791
10792/// 0.8.16 Slice 5 / F9 — build the per-hit importance/confidence weight maps for
10793/// the candidate `hits` from the durable columns (`canonical_nodes.importance`,
10794/// `canonical_edges.confidence`). Only NON-NULL values are inserted; an absent
10795/// value stays out of the map (graceful-absent ⇒ neutral in
10796/// [`apply_importance_reweight`]). Prepared statements are guarded so a pre-step-18
10797/// / pre-step-14 schema (no column) yields empty maps rather than an error.
10798fn build_importance_confidence_maps(
10799 tx: &rusqlite::Connection,
10800 hits: &[SearchHit],
10801) -> rusqlite::Result<(HashMap<u64, f64>, HashMap<u64, f64>)> {
10802 let mut importance_by_id: HashMap<u64, f64> = HashMap::new();
10803 let mut confidence_by_id: HashMap<u64, f64> = HashMap::new();
10804 if let Ok(mut stmt) =
10805 tx.prepare("SELECT importance FROM canonical_nodes WHERE write_cursor = ?1 LIMIT 1")
10806 {
10807 for h in hits {
10808 if let Ok(Some(v)) = stmt.query_row([h.write_cursor], |r| r.get::<_, Option<f64>>(0)) {
10809 importance_by_id.insert(h.write_cursor, v);
10810 }
10811 }
10812 }
10813 if let Ok(mut stmt) = tx.prepare(
10814 "SELECT confidence FROM canonical_edges \
10815 WHERE write_cursor = ?1 AND superseded_at IS NULL LIMIT 1",
10816 ) {
10817 for h in hits {
10818 if let Ok(Some(v)) = stmt.query_row([h.write_cursor], |r| r.get::<_, Option<f64>>(0)) {
10819 confidence_by_id.insert(h.write_cursor, v);
10820 }
10821 }
10822 }
10823 Ok((importance_by_id, confidence_by_id))
10824}
10825
10826/// 0.8.1 Slice 10 (R1) — CE rerank seam.
10827///
10828/// `rerank_depth = 0` (or model absent / `default-reranker` feature off): returns
10829/// `hits` **unchanged** — byte-identical to the old identity stub. This is the
10830/// soft-fallback contract.
10831///
10832/// `rerank_depth > 0` with the `default-reranker` feature on and the model
10833/// loaded: scores the top-`rerank_depth` (query, passage) pairs with the
10834/// TinyBERT-L-2 cross-encoder, blends CE score with the RRF score using the
10835/// formula from the design memo (Decision 5), re-sorts the top-N, and appends
10836/// the remainder in their original RRF order.
10837///
10838/// Score-blend (Decision 5): `α × sigmoid(ce_logit) + (1−α) × rrf_score_normalized`
10839/// where both CE and RRF scores are normalized to [0,1] over the reranked pool.
10840///
10841/// 0.8.5 (EXP-0): `alpha` (clamped to `[0,1]`) and `pool_n` (the reranked-pool
10842/// size, clamped to `hits.len()`) are caller-supplied. The defaults
10843/// `alpha = 0.3, pool_n = rerank_depth` reproduce the pre-slice blend exactly.
10844/// `rerank_depth == 0` remains the identity gate regardless of `pool_n`.
10845///
10846/// This is the rerank hook, **not** the dropped `fusion_mode` knob.
10847#[doc(hidden)]
10848#[must_use]
10849pub fn rerank_fused(
10850 _query: &str,
10851 hits: Vec<SearchHit>,
10852 rerank_depth: usize,
10853 alpha: f64,
10854 pool_n: usize,
10855) -> Vec<SearchHit> {
10856 // Soft-fallback: depth=0 → identity (byte-identical to old stub). NOTE this
10857 // early gate is independent of `pool_n`: `rerank_depth == 0, pool_n = 10`
10858 // does NOT rerank (0.8.5 D4).
10859 if rerank_depth == 0 {
10860 return hits;
10861 }
10862
10863 // Feature-gated CE inference. In the default build (no feature) this block
10864 // compiles away and `hits` is returned unchanged regardless of `rerank_depth`.
10865 // FIX-1: pass `&hits` (borrow) so `hits` remains owned for the soft-fallback path.
10866 #[cfg(feature = "default-reranker")]
10867 {
10868 if let Some(reranked) = ce_rerank(_query, &hits, rerank_depth, alpha, pool_n) {
10869 return reranked;
10870 }
10871 }
10872
10873 // 0.8.5: the bindings/default callers pass `alpha = 0.3, pool_n = rerank_depth`;
10874 // referenced here so the no-feature build does not warn on unused params.
10875 #[cfg(not(feature = "default-reranker"))]
10876 let _ = (alpha, pool_n);
10877
10878 // Model absent (feature off, weights not loaded, or CE returned None) →
10879 // soft-fallback: return input unchanged.
10880 hits
10881}
10882
10883/// 0.8.2 Slice E2 — standalone CE rerank of a caller-supplied passage list.
10884///
10885/// The pure, testable core that the `fathomdb.rerank` pyo3 binding is a thin
10886/// wrapper over. Slice 5's `fused_rerank` comparator must CE-rerank its OWN
10887/// in-harness fused(bm25+dense) pool — a pool the engine's `search()` never
10888/// constructs — so the CE has to be reachable over an arbitrary passage list,
10889/// not just the engine's capped text-only pool. This adapts `(id, body, score)`
10890/// passages into `SearchHit`s (`kind = "passage"`, `branch = Vector`,
10891/// `source_id = None`; only `body` and `score` feed the blend), runs them
10892/// through [`rerank_fused`], and projects back to `(id, score, ce_score)` in the reranked
10893/// order.
10894///
10895/// Contract (inherited verbatim from `rerank_fused`): `rerank_depth == 0` OR an
10896/// empty list returns the input order WITH the input scores, byte-identical — no
10897/// model load, no network. With `--features default-reranker` and
10898/// `rerank_depth > 0` the CE blends the top-`depth` and may reorder; with the
10899/// feature off the CE path compiles away and this is always identity.
10900///
10901/// 0.8.2 Slice E2 fix-1 [P2]: returns `Err` when any passage carries a non-finite
10902/// score (NaN / ±inf), mirroring the malformed-passage loud-fail contract.
10903/// Callers (pyo3 `rerank` binding, tests) must handle `Result`.
10904/// (`#[must_use]` removed: `Result` is already `#[must_use]`.)
10905pub fn rerank_passages(
10906 query: &str,
10907 passages: Vec<(u64, String, f64)>,
10908 rerank_depth: usize,
10909 alpha: f64,
10910 pool_n: usize,
10911) -> Result<Vec<(u64, f64, Option<f64>)>, String> {
10912 // [P2] guard: reject non-finite scores before they reach normalization/sort.
10913 // A NaN or ±inf score would produce NaN blended scores and an unstable sort
10914 // order — surface the error early as the typed WriteValidationError at the
10915 // pyo3 boundary (mirroring the malformed-passage loud-fail contract).
10916 for (id, _, score) in &passages {
10917 if !score.is_finite() {
10918 return Err(format!(
10919 "rerank: non-finite score for passage id={id}: {score} \
10920 (NaN/\u{00b1}inf must not reach the normalization/sort step)"
10921 ));
10922 }
10923 }
10924 let hits: Vec<SearchHit> = passages
10925 .into_iter()
10926 .map(|(id, body, score)| SearchHit {
10927 // C-2: synthetic passages carry no canonical identity — mint the
10928 // `Passage` (`p:`) id from the caller-supplied ordinal. The ordinal
10929 // is ALSO kept as the engine-internal positional cursor so the
10930 // projection below returns it byte-unchanged.
10931 id: IdSpace::passage(id.to_string()),
10932 write_cursor: id,
10933 kind: "passage".to_string(),
10934 body,
10935 score,
10936 branch: SoftFallbackBranch::Vector,
10937 source_id: None,
10938 ce_score: None,
10939 })
10940 .collect();
10941 // 0.8.5 — project `(id, score, ce_score)` so the binding can surface the CE
10942 // score per candidate; `ce_score` is `None` for the identity / out-of-pool path.
10943 // The projected id is the caller's ordinal (the engine-internal `write_cursor`).
10944 Ok(rerank_fused(query, hits, rerank_depth, alpha, pool_n)
10945 .into_iter()
10946 .map(|h| (h.write_cursor, h.score, h.ce_score))
10947 .collect())
10948}
10949
10950/// 0.8.1 Slice 10 — score-blend reranking when CE model is loaded.
10951///
10952/// Returns `Some(reranked)` if the model is available, `None` otherwise
10953/// (caller then applies the soft-fallback).
10954///
10955/// Design memo Decision 5:
10956/// - CE normalized = sigmoid(raw_logit) ∈ [0,1]
10957/// - RRF normalized = min-max of `hit.score` over the top-K pool
10958/// - `final_score = 0.3 × ce_norm + 0.7 × rrf_norm`
10959/// - Hits beyond `rerank_depth` keep their original RRF scores and order.
10960#[cfg(feature = "default-reranker")]
10961fn ce_rerank(
10962 _query: &str,
10963 hits: &[SearchHit], // FIX-1: borrow, not move — caller retains ownership for soft-fallback
10964 _rerank_depth: usize, // 0.8.5: pool sizing moved to `pool_n`; depth gate stays in `rerank_fused`.
10965 alpha: f64,
10966 pool_n: usize,
10967) -> Option<Vec<SearchHit>> {
10968 // 0.8.5 (D3) — clamp α to [0,1] silently here so EVERY path (engine search,
10969 // `rerank_passages`, the bindings) is covered by one clamp, matching the
10970 // existing `pool_n.min(len)` clamp idiom.
10971 // codex §9 P2-1: `f64::clamp(NaN)` returns NaN (clamp does NOT map NaN into
10972 // range) — a non-finite α would then make every blended score NaN and destroy
10973 // the ranking. The high-level SDKs reject non-finite α, but the low-level
10974 // `rerank()` / direct-Rust callers don't, so fall back to the documented
10975 // default α=0.3 here for any non-finite input.
10976 let alpha = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 0.3 };
10977 // fix-1 [P2]: short-circuit before touching the singleton when there is
10978 // nothing to rerank — avoids loading/downloading the ~17 MB model for an
10979 // empty result set and prevents memoizing a transient load failure.
10980 if hits.is_empty() {
10981 return Some(vec![]);
10982 }
10983
10984 // Try to get the loaded model. Returns None when weights are absent.
10985 let model = CandleCrossEncoder::try_get_loaded()?;
10986
10987 // 0.8.5 (D4) — the reranked pool is the top `pool_n` (caller resolves the
10988 // `unwrap_or(rerank_depth)` default at the binding), clamped to the hit count.
10989 let n = pool_n.min(hits.len());
10990 let top = &hits[..n]; // no split_at_mut needed; borrow slices directly
10991 let rest = &hits[n..];
10992
10993 // --- RRF min-max normalization over the top-N pool ---
10994 let rrf_min = top.iter().map(|h| h.score).fold(f64::INFINITY, f64::min);
10995 let rrf_max = top.iter().map(|h| h.score).fold(f64::NEG_INFINITY, f64::max);
10996 let rrf_span = rrf_max - rrf_min;
10997
10998 // Batched CE scoring: ONE forward over the whole top-N pool instead of N
10999 // per-pair forwards. The ranking math below (RRF min-max norm, sigmoid,
11000 // ALPHA blend, sort) is byte-unchanged — only the scoring is batched.
11001 let bodies: Vec<&str> = top.iter().map(|h| h.body.as_str()).collect();
11002 let raw_logits = model.score_batch(_query, &bodies);
11003
11004 let mut scored: Vec<(f64, SearchHit)> = top
11005 .iter()
11006 .zip(raw_logits)
11007 .map(|(h, raw_logit)| {
11008 let rrf_norm = if rrf_span > 0.0 { (h.score - rrf_min) / rrf_span } else { 1.0 };
11009 // Sigmoid for CE normalization: 1/(1+exp(-x)).
11010 let ce_norm = 1.0 / (1.0 + (-raw_logit).exp());
11011 // 0.8.5 — α is the caller-supplied (clamped) blend weight; default 0.3
11012 // reproduces the pre-slice `const ALPHA = 0.3` blend exactly.
11013 let blended = alpha * ce_norm + (1.0 - alpha) * rrf_norm;
11014 // 0.8.5 (D1) — expose the per-candidate CE score on in-pool hits.
11015 let mut hit = h.clone();
11016 hit.ce_score = Some(ce_norm);
11017 (blended, hit)
11018 })
11019 .collect();
11020
11021 // Sort top-N by blended score descending (stable within ties by original order).
11022 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
11023
11024 let mut result: Vec<SearchHit> = scored
11025 .into_iter()
11026 .map(|(score, mut h)| {
11027 h.score = score;
11028 h
11029 })
11030 .collect();
11031
11032 // Append hits beyond rerank_depth in their original RRF order.
11033 result.extend_from_slice(rest);
11034 Some(result)
11035}
11036
11037/// 0.8.1 Slice 10 (R1) / 0.8.2 Slice E1 — CPU TinyBERT-L-2 cross-encoder.
11038///
11039/// Thin engine-side handle over the embedder crate's `CandleTinyBertReranker`
11040/// (Candle BERT stack + `tokenizers`, pinned `cross-encoder/ms-marco-TinyBERT-
11041/// L2-v2`). The model is loaded once, process-wide, the first time
11042/// `rerank_depth > 0` reaches the CE path (lazy init via the `OnceLock` below);
11043/// on cache miss that first load fetches the ~17 MB weights over the network
11044/// (sha256-verified). When the weights are absent and the network is
11045/// unavailable, the load fails and `try_get_loaded()` returns `None` so the
11046/// caller soft-falls-back to RRF order — it never panics.
11047///
11048/// Footprint: this whole type compiles ONLY under `default-reranker`. With the
11049/// feature off the CE path compiles away and `rerank_fused` is always identity.
11050/// With the feature on, `rerank_depth == 0` short-circuits in `rerank_fused`
11051/// BEFORE this is ever touched, so depth-0 stays byte-identical and no-network.
11052/// Similarly, an empty hit set short-circuits in `ce_rerank` before the singleton
11053/// is consulted (fix-1 [P2]).
11054#[cfg(feature = "default-reranker")]
11055struct CandleCrossEncoder {
11056 inner: &'static fathomdb_embedder::CandleTinyBertReranker,
11057}
11058
11059/// Process-wide lazily-initialized reranker. `None` once initialization has
11060/// been attempted and failed (no weights + no network) — memoized so a failed
11061/// load is not retried on every query.
11062#[cfg(feature = "default-reranker")]
11063fn reranker_singleton() -> Option<&'static fathomdb_embedder::CandleTinyBertReranker> {
11064 static CELL: std::sync::OnceLock<Option<fathomdb_embedder::CandleTinyBertReranker>> =
11065 std::sync::OnceLock::new();
11066 CELL.get_or_init(|| fathomdb_embedder::CandleTinyBertReranker::try_load().ok()).as_ref()
11067}
11068
11069#[cfg(feature = "default-reranker")]
11070impl CandleCrossEncoder {
11071 /// Returns a model handle if the reranker is (or can be) loaded, `None`
11072 /// otherwise. The first call drives the lazy load (cache probe → gated
11073 /// download); subsequent calls reuse the memoized result.
11074 fn try_get_loaded() -> Option<Self> {
11075 Some(Self { inner: reranker_singleton()? })
11076 }
11077
11078 /// Score a (query, passage) pair. Returns the raw cross-encoder logit, or
11079 /// `0.0` (a neutral logit → sigmoid 0.5) if the forward pass errors, so a
11080 /// single bad pair degrades to a neutral CE contribution rather than
11081 /// panicking in the reader thread.
11082 fn score(&self, query: &str, passage: &str) -> f64 {
11083 self.inner.score(query, passage).map(f64::from).unwrap_or(0.0)
11084 }
11085
11086 /// Batched [`score`](Self::score): score every `(query, passage_i)` pair in a
11087 /// single forward pass. Returns one logit per passage in input order, each
11088 /// honoring the same neutral-`0.0`-on-error contract as [`score`](Self::score).
11089 ///
11090 /// Fallback: if the batched forward errors as a whole (e.g. an OOM or a
11091 /// tokenize failure on one pair surfaces as a batch `Err`), we DO NOT
11092 /// neutralize the entire pool — we fall back to per-pair [`score`](Self::score),
11093 /// so a single bad pair degrades only its own element to a neutral logit while
11094 /// the rest keep their real scores. Empty input → empty output (no forward).
11095 fn score_batch(&self, query: &str, passages: &[&str]) -> Vec<f64> {
11096 match self.inner.score_batch(query, passages) {
11097 Ok(logits) => logits.into_iter().map(f64::from).collect(),
11098 Err(_) => passages.iter().map(|p| self.score(query, p)).collect(),
11099 }
11100 }
11101}
11102
11103/// 0.8.20 Slice 15e fix-2 finding 1 [P2] + keystone closeout fix-3 (codex §9 [P2],
11104/// TOCTOU) — reject a search filter that names an attribute with NO declared
11105/// `filterable` projection, ON THE READER'S OWN SNAPSHOT, before the vec0 SQL is
11106/// built.
11107///
11108/// The vector arm lowers each `filter.attributes` term to `AND attr_<hex>=?`
11109/// against a vec0 metadata column that exists ONLY for a declared `filterable`
11110/// projection (the reshape in [`reconcile_vector_attr_columns`] tracks exactly the
11111/// registry's `filterable` set; see [`desired_vector_attr_columns`]). A name that
11112/// is not a declared `filterable` projection therefore has no column: the vec0 KNN
11113/// would fail with `no such column` (surfacing as an opaque `Storage` error),
11114/// while the FTS arm ([`hit_attributes_pass_filter`]) would silently no-match. That
11115/// divergence violates ADR-0.8.11 D3 (every filter term has a DEFINED, arm-uniform
11116/// outcome).
11117///
11118/// fix-3 snapshot contract: this is called from [`read_search_in_tx`] INSIDE the
11119/// reader's `DEFERRED` transaction, so the `_fathomdb_projection_registry` it reads
11120/// and the `vector_default` columns [`vector_filter_clause`] compiles against are
11121/// the SAME WAL snapshot. A concurrent `configure_projections` DROP either commits
11122/// before this snapshot is pinned (then BOTH the registry and the vec0 columns show
11123/// the attribute gone ⇒ a consistent `InvalidFilter`) or after (then it is invisible
11124/// to this transaction and BOTH still show it declared ⇒ the query runs against the
11125/// column it validated). The registry's `filterable` set is the arm-INDEPENDENT
11126/// authority (correct even with no embedder / no `vector_default`, where a
11127/// declared-`filterable` term still filters legitimately via the row-owned
11128/// `canonical_attributes` EAV store). The caller re-raises
11129/// [`SearchReaderError::InvalidFilter`] as the EXISTING typed
11130/// [`EngineError::InvalidFilter`], so both arms see the SAME rejection because it is
11131/// raised before either runs.
11132fn validate_filter_attributes_on_snapshot(
11133 conn: &Connection,
11134 filter: &SearchFilter,
11135) -> Result<(), SearchReaderError> {
11136 if filter.attributes.is_empty() {
11137 return Ok(());
11138 }
11139 // `?` maps a registry-read failure to `SearchReaderError::Sqlite` (unchanged
11140 // `Storage` semantics for a genuine backend fault) via the `From` impl.
11141 let registry = load_projection_registry(conn)?;
11142 for (name, _value) in &filter.attributes {
11143 let declared_filterable =
11144 registry.get(name).is_some_and(|s| s.roles.contains(&ProjectionRole::Filterable));
11145 if !declared_filterable {
11146 return Err(SearchReaderError::InvalidFilter(format!(
11147 "filter attribute {name:?} is not a declared `filterable` projection; \
11148 declare it via configure_projections before filtering on it"
11149 )));
11150 }
11151 }
11152 Ok(())
11153}
11154
11155/// G10 — the `AND col=?n` predicate fragment appended to the phase-1 candidates
11156/// `WHERE` for the present filter fields. Placeholders are numbered from `?3`
11157/// (`?1` = sign-quant query, `?2` = f32 rerank query). Field order is canonical
11158/// (`source_type`, `kind`, `created_after`, `status`), THEN the Slice-15e
11159/// `filterable`-attribute predicates (`attr_<hex>=?n`) in `attributes` order, and
11160/// is mirrored exactly by [`vector_filter_values`]. Empty for `None`/all-`None`
11161/// (byte-identity path).
11162fn vector_filter_clause(filter: Option<&SearchFilter>) -> String {
11163 let Some(filter) = filter else {
11164 return String::new();
11165 };
11166 if filter.is_unfiltered() {
11167 return String::new();
11168 }
11169 // 0.8.20 Slice 15e — the attribute predicates encode the (arbitrary,
11170 // possibly space/unicode-bearing) registry name into the byte-safe
11171 // `attr_<hex>` column vec0 accepts (vec0 rejects quoted identifiers). Owned
11172 // strings so they live past the closure; the shipped metadata columns are
11173 // static `&str`.
11174 let mut cols: Vec<(String, &str)> = Vec::new();
11175 if filter.source_type.is_some() {
11176 cols.push(("source_type".to_string(), "="));
11177 }
11178 if filter.kind.is_some() {
11179 cols.push(("kind".to_string(), "="));
11180 }
11181 if filter.created_after.is_some() {
11182 cols.push(("created_at".to_string(), ">="));
11183 }
11184 if filter.status.is_some() {
11185 cols.push(("status".to_string(), "="));
11186 }
11187 for (name, _value) in &filter.attributes {
11188 cols.push((attr_vec0_column(name), "="));
11189 }
11190 let mut clause = String::new();
11191 for (i, (col, op)) in cols.iter().enumerate() {
11192 clause.push_str(&format!(" AND {col}{op}?{}", i + 3));
11193 }
11194 clause
11195}
11196
11197/// G10 — the bound values for the present filter fields, in the SAME canonical
11198/// order as [`vector_filter_clause`] so placeholder `?{n}` lines up with value
11199/// `n-3`.
11200fn vector_filter_values(filter: Option<&SearchFilter>) -> Vec<rusqlite::types::Value> {
11201 use rusqlite::types::Value;
11202 let mut out = Vec::new();
11203 let Some(filter) = filter else {
11204 return out;
11205 };
11206 if filter.is_unfiltered() {
11207 return out;
11208 }
11209 if let Some(s) = &filter.source_type {
11210 out.push(Value::Text(s.clone()));
11211 }
11212 if let Some(s) = &filter.kind {
11213 out.push(Value::Text(s.clone()));
11214 }
11215 if let Some(c) = filter.created_after {
11216 out.push(Value::Integer(c));
11217 }
11218 if let Some(s) = &filter.status {
11219 out.push(Value::Text(s.clone()));
11220 }
11221 // 0.8.20 Slice 15e — attribute values, in the SAME order the clause appended
11222 // the `attr_<hex>` columns (after the four metadata fields). fix-3 [P2] — the
11223 // filter value is encoded `\x01 || V` to match the encoded PRESENT column
11224 // value, so `attr_<hex> = enc("")` matches present-empty but NEVER the
11225 // `''`-absent rows.
11226 for (_name, value) in &filter.attributes {
11227 out.push(Value::Text(encode_attr_vec0_present(value)));
11228 }
11229 out
11230}
11231
11232/// G10 — build the single phase-1 candidates statement. With `filter=None` (or
11233/// all-`None`) the `{filter_clause}` is empty and the SQL is **byte-identical to
11234/// 0.7.2** (the documented behavior-compat invariant; pinned by
11235/// `pr_g10_filtered_knn.rs`). The KNN form (`ORDER BY distance LIMIT top_k`, no
11236/// `k=`) is preserved.
11237fn build_vector_phase1_sql(filter: Option<&SearchFilter>, final_limit: usize) -> String {
11238 let filter_clause = vector_filter_clause(filter);
11239 format!(
11240 "WITH candidates AS (
11241 SELECT rowid
11242 FROM vector_default
11243 WHERE embedding_bin MATCH vec_quantize_binary(vec_f32(?1)){filter_clause}
11244 ORDER BY distance
11245 LIMIT {top_k}
11246 )
11247 SELECT c.rowid, vec_distance_l2(v.embedding, vec_f32(?2)) AS l2
11248 FROM candidates c
11249 JOIN vector_default v ON v.rowid = c.rowid
11250 ORDER BY l2
11251 LIMIT {final_limit}",
11252 top_k = TOP_K_BIT_CANDIDATES,
11253 )
11254}
11255
11256/// Test seam — exposes [`build_vector_phase1_sql`] at the production
11257/// `SEARCH_RERANK_LIMIT` so `pr_g10_filtered_knn.rs` can pin the `filter=None`
11258/// byte-identity and the appended predicates.
11259#[doc(hidden)]
11260#[must_use]
11261pub fn vector_phase1_sql_for_test(filter: Option<&SearchFilter>) -> String {
11262 build_vector_phase1_sql(filter, SEARCH_RERANK_LIMIT)
11263}
11264
11265/// G10 — does a text-branch hit satisfy the filter? The vector branch is
11266/// pruned in-SQL; the text branch is constrained here against the same metadata:
11267/// `kind` directly, `source_type` via [`resolve_source_type`], and
11268/// `created_after`/`status` from `vector_default` by `rowid == write_cursor`. A
11269/// text-only row absent from the vector partition cannot satisfy a
11270/// `created_after`/`status` predicate, so it is excluded — filtered semantic
11271/// search is a vector-metadata capability.
11272fn text_hit_passes_filter(
11273 tx: &rusqlite::Transaction<'_>,
11274 id: u64,
11275 kind: &str,
11276 filter: Option<&SearchFilter>,
11277) -> rusqlite::Result<bool> {
11278 let Some(filter) = filter else {
11279 return Ok(true);
11280 };
11281 if filter.is_unfiltered() {
11282 return Ok(true);
11283 }
11284 if let Some(k) = &filter.kind {
11285 if kind != k {
11286 return Ok(false);
11287 }
11288 }
11289 if let Some(st) = &filter.source_type {
11290 match resolve_source_type(kind) {
11291 Ok(resolved) if resolved == st.as_str() => {}
11292 _ => return Ok(false),
11293 }
11294 }
11295 if filter.created_after.is_some() || filter.status.is_some() {
11296 let meta: Option<(i64, Option<String>)> = tx
11297 .query_row(
11298 "SELECT created_at, status FROM vector_default WHERE rowid = ?1 LIMIT 1",
11299 [id as i64],
11300 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?)),
11301 )
11302 .optional()?;
11303 let Some((created_at, status)) = meta else {
11304 // No vector-partition row: cannot satisfy a vec-metadata predicate.
11305 return Ok(false);
11306 };
11307 if let Some(bound) = filter.created_after {
11308 if created_at < bound {
11309 return Ok(false);
11310 }
11311 }
11312 if let Some(want) = &filter.status {
11313 if status.as_deref() != Some(want.as_str()) {
11314 return Ok(false);
11315 }
11316 }
11317 }
11318 // 0.8.20 Slice 15e fix-1 finding 1 [P2] — enforce the declared-`filterable`
11319 // attribute-equality predicates on the TEXT/FTS arm too (D3 total dispatch).
11320 if !hit_attributes_pass_filter(tx, id, filter)? {
11321 return Ok(false);
11322 }
11323 Ok(true)
11324}
11325
11326/// 0.8.20 Slice 15e fix-1 finding 1 [P2] — do a TEXT/FTS hit's `filterable`
11327/// attributes satisfy `filter.attributes`?
11328///
11329/// The vector arm enforces each `(attr_name, value)` pre-KNN as `attr_<hex>=?`
11330/// against the vec0 metadata column. The FTS/text arm must enforce the SAME
11331/// equality so a hybrid RRF fusion is coherent (ADR-0.8.11 D3 — every filter term
11332/// has a defined outcome on EVERY surface; no arm silently ignores it). Without
11333/// this, a doc returned by the FTS arm that FAILS the attribute filter still
11334/// surfaces in a hybrid search — a false positive.
11335///
11336/// The value is read from the row-owned `canonical_attributes` EAV table (keyed
11337/// by the hit's `write_cursor` + `attr_name`), which Slice 15d keeps active-only
11338/// and populates via the SAME [`extract_scalar_attribute`] that fills the vec0
11339/// `attr_<hex>` column — so the two arms see IDENTICAL values by construction.
11340///
11341/// # fix-3 [P2]: ABSENT vs PRESENT-EMPTY
11342///
11343/// A real empty-string value (`{"status":""}`) is DISTINCT from an absent
11344/// attribute. On this (FTS/text) arm the distinction is ROW EXISTENCE: a present
11345/// attribute (even value `''`) has a `canonical_attributes` row; an absent one has
11346/// NONE. So a filter `("status","")` matches present-empty (row exists, RAW value
11347/// `''`) but NOT absent (no row), and `("status","open")` matches only the
11348/// `open` row. The vec0 arm reaches the SAME verdict via its `\x01`-marker
11349/// encoding (see [`ATTR_VEC0_PRESENT_MARKER`]): present-empty is the bare marker,
11350/// absent is `''`, so `attr_<hex> = enc("")` matches present-empty but never
11351/// absent. `canonical_attributes.attr_value` and `property_search_index` stay RAW.
11352///
11353/// # 0.8.20 semantics (Finding 1 → HITL ruling (A)): attribute filters are NODE-scoped
11354///
11355/// Attribute projection is `PreparedWrite::Node`-gated (see `collect_projection_jobs`
11356/// / [`project_one_attribute`]): an EDGE is never projected into
11357/// `canonical_attributes`, and its `vector_default` row (kind `edge_fact`) carries
11358/// the `''` sentinel in every `attr_<hex>` column (the async worker reads the body
11359/// from `canonical_nodes`, which has no row for an edge cursor). Therefore an
11360/// attribute filter **excludes every edge hit** — on BOTH the edge-FTS arm (this
11361/// helper, keyed by the edge's write_cursor, reads `''`) and the edge-vector arm
11362/// (the pre-KNN `attr_<hex>='…'` predicate prunes the `''`-sentinel edge row) —
11363/// even when the edge body itself names the attribute. This is the intended
11364/// 0.8.20 behaviour, pinned by `attribute_filter_excludes_edge_hits_on_both_arms`.
11365///
11366/// The reserved widening is **(D) endpoint-node filtering** (an edge passes iff its
11367/// endpoint node(s) satisfy the attribute predicate): **(A) is (D) with an empty
11368/// endpoint rule.** (B) edges-pass-through and (C) project-edge-attributes are the
11369/// other reserved options. None are implemented in 0.8.20 — do not add a per-query
11370/// flag; a widening is a deliberate, separately-governed later slice.
11371fn hit_attributes_pass_filter(
11372 tx: &rusqlite::Transaction<'_>,
11373 id: u64,
11374 filter: &SearchFilter,
11375) -> rusqlite::Result<bool> {
11376 for (name, want) in &filter.attributes {
11377 // fix-3 [P2] — distinguish ABSENT from PRESENT-EMPTY by ROW EXISTENCE: a
11378 // present attribute (including one whose value is a real empty string `''`)
11379 // has a `canonical_attributes` row; an absent one has NONE. The outer
11380 // `Option` is row existence; the RAW `attr_value` is compared verbatim.
11381 // This mirrors the vec0 arm exactly (present-empty matches `("k","")`;
11382 // absent matches nothing, including `""`), so a fused hybrid search is
11383 // coherent. `canonical_attributes.attr_value` stays RAW (unencoded).
11384 let stored: Option<Option<String>> = tx
11385 .query_row(
11386 "SELECT attr_value FROM canonical_attributes \
11387 WHERE write_cursor = ?1 AND attr_name = ?2 LIMIT 1",
11388 params![id as i64, name],
11389 |row| row.get::<_, Option<String>>(0),
11390 )
11391 .optional()?;
11392 match stored {
11393 // Present (row exists) and the RAW value equals the filter value.
11394 Some(Some(v)) if v.as_str() == want.as_str() => {}
11395 // Absent (no row), present-but-NULL, or present-but-different ⇒ fail.
11396 _ => return Ok(false),
11397 }
11398 }
11399 Ok(true)
11400}
11401
11402/// G11 (Slice 15) — does an edge FTS hit satisfy the filter?
11403///
11404/// Edge FTS hits always have `source_type = "edge_fact"` (the partition
11405/// discriminant). Their `row.kind` is the **relation** kind (e.g. `"owns"`,
11406/// `"works_for"`), not a node kind, so [`text_hit_passes_filter`] MUST NOT be
11407/// used for edge hits: `resolve_source_type(relation_kind)` returns `Err` for
11408/// unknown kinds, causing every edge hit to be silently rejected when a
11409/// `source_type` filter is set — the exact inverse of correct behaviour.
11410///
11411/// Edge bodies ARE projected into `vector_default` (rowid = `write_cursor`),
11412/// so `created_after` / `status` are satisfied by querying `vector_default`
11413/// exactly as [`text_hit_passes_filter`] does for node hits.
11414///
11415/// Rules:
11416/// - `source_type`: pass iff `None` **or** `== "edge_fact"`.
11417/// - `kind`: filter on the relation kind (`row.kind`) if specified.
11418/// - `created_after` / `status`: query `vector_default WHERE rowid = write_cursor`;
11419/// if absent from the vector partition the hit cannot satisfy a vec-metadata
11420/// predicate and is excluded.
11421fn edge_fts_hit_passes_filter(
11422 tx: &rusqlite::Transaction<'_>,
11423 write_cursor: u64,
11424 row_kind: &str,
11425 filter: Option<&SearchFilter>,
11426) -> rusqlite::Result<bool> {
11427 let Some(filter) = filter else {
11428 return Ok(true);
11429 };
11430 if filter.is_unfiltered() {
11431 return Ok(true);
11432 }
11433 if let Some(ref st) = filter.source_type {
11434 if st != "edge_fact" {
11435 return Ok(false); // filter targets a specific non-edge source_type
11436 }
11437 }
11438 if let Some(ref k) = filter.kind {
11439 if k != row_kind {
11440 return Ok(false); // kind filter applies to the relation kind
11441 }
11442 }
11443 // Edge bodies are projected into vector_default; check created_after/status
11444 // there, the same way text_hit_passes_filter does for node hits.
11445 if filter.created_after.is_some() || filter.status.is_some() {
11446 let meta: Option<(i64, Option<String>)> = tx
11447 .query_row(
11448 "SELECT created_at, status FROM vector_default WHERE rowid = ?1 LIMIT 1",
11449 [write_cursor as i64],
11450 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?)),
11451 )
11452 .optional()?;
11453 let Some((created_at, status)) = meta else {
11454 // No vector-partition row: cannot satisfy a vec-metadata predicate.
11455 return Ok(false);
11456 };
11457 if let Some(bound) = filter.created_after {
11458 if created_at < bound {
11459 return Ok(false);
11460 }
11461 }
11462 if let Some(want) = &filter.status {
11463 if status.as_deref() != Some(want.as_str()) {
11464 return Ok(false);
11465 }
11466 }
11467 }
11468 // 0.8.20 Slice 15e fix-1 finding 1 [P2] — an edge body is projected into
11469 // vector_default (rowid = write_cursor) with the same `attr_<hex>` pre-KNN
11470 // columns as a node, and Slice 15d projects an edge's `filterable` attributes
11471 // into `canonical_attributes` keyed by its write_cursor. Enforce the same
11472 // attribute equality here so the edge-FTS arm matches the vector arm (D3 total
11473 // dispatch). An edge that carries no such attribute reads the `''` sentinel and
11474 // fails a non-empty equality, exactly as on the vector arm.
11475 if !hit_attributes_pass_filter(tx, write_cursor, filter)? {
11476 return Ok(false);
11477 }
11478 Ok(true)
11479}
11480
11481/// Apply every edge filter except declared projection attributes. This isolates
11482/// the explanatory count from edge candidates rejected for an independent
11483/// source-type, relation-kind, or vec-metadata predicate.
11484fn edge_fts_hit_passes_non_attribute_filter(
11485 tx: &rusqlite::Transaction<'_>,
11486 write_cursor: u64,
11487 row_kind: &str,
11488 filter: Option<&SearchFilter>,
11489) -> rusqlite::Result<bool> {
11490 let Some(filter) = filter else {
11491 return Ok(true);
11492 };
11493 let mut non_attribute_filter = filter.clone();
11494 non_attribute_filter.attributes.clear();
11495 edge_fts_hit_passes_filter(tx, write_cursor, row_kind, Some(&non_attribute_filter))
11496}
11497
11498/// Read projection cursor and matching body rows inside one read tx.
11499fn read_projected_text_in_tx(
11500 reader: &mut Connection,
11501 query: &str,
11502 name: &str,
11503 filter: Option<&SearchFilter>,
11504 limit: usize,
11505 view: ReadView,
11506) -> ProjectedTextReaderResponse {
11507 let compiled = compile_text_query(query);
11508 let frozen = view.freeze();
11509 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
11510 let registry = load_projection_registry(&tx)?;
11511 let declared = registry.get(name).ok_or_else(|| {
11512 SearchReaderError::InvalidFilter(format!("projected text field {name:?} is not declared"))
11513 })?;
11514 if !declared.wants_property_fts() {
11515 return Err(SearchReaderError::InvalidFilter(format!(
11516 "projected text field {name:?} is not a declared `searchable` projection with property FTS"
11517 )));
11518 }
11519 if let Some(filter) = filter {
11520 validate_filter_attributes_on_snapshot(&tx, filter)?;
11521 }
11522 let validity = frozen.validity_sql("n", 3);
11523 let sql = format!(
11524 "SELECT p.write_cursor, bm25(property_search_index), n.kind, n.body, n.logical_id, n.source_id
11525 FROM property_search_index p JOIN canonical_nodes n ON n.write_cursor = p.write_cursor
11526 WHERE p.attr_name = ?1 AND property_search_index MATCH ?2
11527 AND n.superseded_at IS NULL AND n.state = 'active'{validity}
11528 ORDER BY bm25(property_search_index) ASC, p.write_cursor ASC"
11529 );
11530 let mut params = vec![
11531 rusqlite::types::Value::Text(name.to_string()),
11532 rusqlite::types::Value::Text(compiled.match_expression),
11533 ];
11534 if let Some(now) = frozen.now_param() {
11535 params.push(rusqlite::types::Value::Integer(now));
11536 }
11537 let mut stmt = tx.prepare(&sql)?;
11538 let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| {
11539 Ok((
11540 row.get::<_, i64>(0)?,
11541 row.get::<_, f64>(1)?,
11542 row.get::<_, String>(2)?,
11543 row.get::<_, String>(3)?,
11544 row.get::<_, Option<String>>(4)?,
11545 row.get::<_, Option<String>>(5)?,
11546 ))
11547 })?;
11548 let mut results = Vec::new();
11549 for row in rows {
11550 let (cursor, bm25, kind, body, logical_id, source_id) = row?;
11551 if !text_hit_passes_filter(&tx, cursor as u64, &kind, filter)? {
11552 continue;
11553 }
11554 results.push(SearchHit {
11555 id: derive_stable_id(logical_id.as_deref(), &body),
11556 write_cursor: cursor as u64,
11557 kind,
11558 body,
11559 score: -bm25,
11560 branch: SoftFallbackBranch::Text,
11561 source_id,
11562 ce_score: None,
11563 });
11564 if results.len() >= limit {
11565 break;
11566 }
11567 }
11568 let projection_cursor = load_projection_cursor(&tx)?;
11569 Ok(SearchResult { projection_cursor, soft_fallback: None, results, explanation: None })
11570}
11571
11572// The 8th parameter (`vector_stage_only`) is the additive GA-2 / ◆ B-1
11573// measurement seam; the reader-worker call site threads each field through
11574// explicitly (mirroring the existing `recency_enabled` plumbing), so a wrapper
11575// struct would only obscure that 1:1 mapping for a test-only flag.
11576#[allow(clippy::too_many_arguments)]
11577fn read_search_in_tx(
11578 reader: &mut Connection,
11579 compiled: &fathomdb_query::CompiledQuery,
11580 query_vector: Option<&str>,
11581 query_vector_bin: Option<&str>,
11582 final_limit: usize,
11583 candidate_limit: usize,
11584 direct_text_candidate_limit: Option<usize>,
11585 filter: Option<&SearchFilter>,
11586 recency_enabled: bool,
11587 importance_enabled: bool,
11588 vector_stage_only: bool,
11589 raw_query: &str,
11590 rerank_depth: usize,
11591 use_graph_arm: bool,
11592 alpha: f64,
11593 pool_n: usize,
11594 explain: bool,
11595 view: ReadView,
11596) -> ReaderResponse {
11597 // 0.8.20 Slice 15b fix-2 (R-20-NV) — the `:now` instant is read HERE, in
11598 // Rust, ONCE per query, and bound positionally into every node-hydration
11599 // SELECT. Never `datetime('now')` / `strftime('%s','now')`: an inline clock
11600 // would make the query non-deterministic, untestable, and re-evaluated per
11601 // row. `None` ⇒ the view relaxes validity ⇒ no conjunct is emitted and
11602 // nothing is bound (`validity_sql` returns the empty string).
11603 //
11604 // fix-3 (F2): FREEZE the view here, at the single point every arm flows
11605 // through. `freeze()` is the only place on this path that reads the clock;
11606 // downstream arms hold a `FrozenView` and have no way to resolve a second,
11607 // different instant. Previously the graph arm re-derived it from the raw
11608 // `ReadView`, so a boundary-straddling query could have its arms disagree.
11609 let view = view.freeze();
11610 let now_param = view.now_param();
11611 // fix-3 (codex §9 [P2], TOCTOU) — test-only rendezvous: parks the worker here,
11612 // BEFORE the deferred snapshot is pinned, so a test can commit a concurrent
11613 // `configure_projections` DROP in the exact race window. Disarmed (no-op) in
11614 // production and on every non-race test.
11615 reader_search_hook::fire();
11616 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
11617 let cursor = load_projection_cursor(&tx)?;
11618 // fix-3 (codex §9 [P2], TOCTOU) — validate every filter attribute name on THIS
11619 // reader transaction's snapshot, before `build_vector_phase1_sql` emits
11620 // `AND attr_<hex>=?` and before the FTS arm probes `canonical_attributes`. The
11621 // registry read and the vec0 query now share ONE snapshot (see
11622 // `validate_filter_attributes_on_snapshot`), so a `configure_projections` DROP
11623 // racing this search yields a consistent typed `InvalidFilter` — never the
11624 // opaque `no such column` `Storage` error fix-2's writer-connection check could
11625 // still leak. Skipped when the filter carries no attribute terms (common path).
11626 if let Some(filter) = filter {
11627 validate_filter_attributes_on_snapshot(&tx, filter)?;
11628 }
11629 let vector_results = if let Some(query_vector) = query_vector {
11630 let mut rowids = Vec::new();
11631 let bin_vector = query_vector_bin.unwrap_or(query_vector);
11632 {
11633 // Phase 1: bit-KNN over `embedding_bin` to a top-K candidate
11634 // set; Phase 2: f32 rerank on the candidate set via
11635 // vec_distance_l2 against the retained `embedding` column.
11636 // EU-5a2: ?1 is the (possibly centered) sign-quant input,
11637 // ?2 is the un-centered f32 for vec_distance_l2 — both sides
11638 // of the f32 cosine use un-centered vectors.
11639 // Slice 18: `final_limit` is the caller's public result limit
11640 // (default 10, validated through the public API). `candidate_limit`
11641 // controls only vector phase-2 fanout: the test-only
11642 // `set_search_limit_for_test` seam may raise it for recall tests.
11643 // The seam never changes `final_limit`; visible results are still
11644 // truncated to that caller-requested limit after ranking.
11645 // G10: the metadata filter is appended to this single phase-1
11646 // statement (`AND col=?n` from ?3); `filter=None` keeps the SQL
11647 // byte-identical to 0.7.2. `?1`/`?2` are the sign-quant + f32 query
11648 // vectors; filter values bind at ?3.. in `vector_filter_clause`
11649 // order.
11650 // fix-3 (F1, codex §9 [P2]) — OVERFETCH the phase-2 rerank so the
11651 // validity/existence filter applied at hydration cannot starve the
11652 // result set.
11653 //
11654 // The defect: hydration drops rows that are expired, superseded or
11655 // inactive, but it ran on candidates ALREADY truncated to
11656 // `final_limit`. If the nearest `final_limit` neighbours were all
11657 // out-of-window they consumed every slot and were then dropped, so
11658 // valid rows just below the cutoff were never considered — a
11659 // default search silently returned too few hits, or none.
11660 //
11661 // Why overfetch rather than filtering in SQL: the natural fix is to
11662 // join `canonical_nodes` into the candidate query, but (i) phase 1
11663 // is a `vec0` KNN and ADR-0.8.11 D3 forbids demoting it with
11664 // non-metadata predicates, and (ii) there is NO index on
11665 // `canonical_nodes(write_cursor)`, so an `EXISTS` per candidate
11666 // would be a full scan × the whole pool on EVERY query — a
11667 // guaranteed cost to fix a degenerate case.
11668 //
11669 // Overfetching is free by comparison: phase 2 already computes
11670 // `vec_distance_l2` for all `TOP_K_BIT_CANDIDATES` in order to sort
11671 // them, so raising the LIMIT only returns more of a result set that
11672 // was already materialized. No extra vec0 work, no schema change,
11673 // no new index, no second query. The hydration loop below then
11674 // stops at `final_limit` SURVIVING hits, so the common case does
11675 // exactly as many hydration probes as before.
11676 //
11677 // `max` (not a bare constant) preserves both the test seam's
11678 // deeper candidate fanout and the caller's requested result limit.
11679 let candidate_limit = candidate_limit.max(final_limit).max(TOP_K_BIT_CANDIDATES);
11680 let sql = build_vector_phase1_sql(filter, candidate_limit);
11681 let mut params: Vec<rusqlite::types::Value> = vec![
11682 rusqlite::types::Value::Text(bin_vector.to_string()),
11683 rusqlite::types::Value::Text(query_vector.to_string()),
11684 ];
11685 params.extend(vector_filter_values(filter));
11686 let mut statement = tx.prepare(&sql)?;
11687 let rows = statement.query_map(rusqlite::params_from_iter(params.iter()), |row| {
11688 Ok((row.get::<_, i64>(0)?, row.get::<_, f64>(1)?))
11689 })?;
11690 for row in rows.flatten() {
11691 rowids.push(row);
11692 }
11693 }
11694 // G1: carry the canonical row's `write_cursor` (interim id), `kind`,
11695 // `body`, and the `vec_distance_l2` rerank score per hit. The
11696 // `_fathomdb_vector_rows.rowid` equals the canonical `write_cursor`,
11697 // so the candidate rowid IS the hit id.
11698 //
11699 // G11 (Slice 15) fix: edge bodies are projected into vector_default under
11700 // kind = "edge_fact"; their write_cursor is in canonical_edges, not
11701 // canonical_nodes. Try canonical_nodes first; fall back to canonical_edges
11702 // for edge-fact hits so they are not silently dropped.
11703 let mut results = Vec::new();
11704 // Cause-A: the two node/edge SELECTs additively fetch `logical_id` so the
11705 // hit can carry a stable cross-session id (derive_stable_id). Read-only
11706 // additive column — ordering/scores are untouched.
11707 // fix-1 (codex §9): co-locate BOTH existence guards. Node supersession
11708 // is tombstone-then-insert (`commit_batch`) — the prior `canonical_nodes`
11709 // row is kept (same `write_cursor`, `state = 'active'`, `superseded_at`
11710 // set) and, unlike the edge path (fix-30), its stale `vector_default` row
11711 // is NOT pruned, so the phase-1 bit-KNN can still surface the OLD cursor.
11712 // Without `superseded_at IS NULL` here that superseded version would
11713 // hydrate and leak stale content through vector search. This matches the
11714 // edge branch below and every other retrieval site (design §2: enforce
11715 // the exclusion at EVERY retrieval site). It only drops already-superseded
11716 // rows → a no-op on the all-active / non-superseded corpus.
11717 // TC-31 (0.8.20 Slice 10a): both hydration SELECTs additively fetch the
11718 // canonical row's OWN `source_id` so a vector hit carries the provenance
11719 // `erase_source` consumes. These statements already read the canonical
11720 // row by `write_cursor`, so this is one extra COLUMN on an existing
11721 // lookup — NOT an extra query. (A per-hit `WHERE write_cursor = ?`
11722 // probe would be a full scan: there is no index on
11723 // `canonical_nodes(write_cursor)`. This site already pays that cost by
11724 // construction; TC-31 must not add a second one.) Read-only additive
11725 // column — row-set, ordering and scores are untouched.
11726 // fix-2 (codex §9 [P2]): the validity conjunct comes from
11727 // `ReadView::validity_sql` — the SAME generator the five read verbs use.
11728 // It is NOT hand-rolled here: Slice 10's whole design is that the
11729 // predicate exists in exactly ONE place, so no retrieval site can drift
11730 // from another. `?1` is the candidate rowid, so `:now` binds at `?2`.
11731 // On a corpus that never authored a window every row is NULL/NULL and
11732 // the conjunct matches everything ⇒ default behaviour is unchanged.
11733 let node_validity = view.validity_sql("canonical_nodes", 2);
11734 let mut node_stmt = tx.prepare(&format!(
11735 "SELECT kind, body, logical_id, source_id FROM canonical_nodes \
11736 WHERE write_cursor = ?1 AND superseded_at IS NULL AND state = 'active'\
11737 {node_validity} LIMIT 1"
11738 ))?;
11739 // fix-2 (codex §9 [P2]): an edge body projected into `vector_default`
11740 // (kind = "edge_fact") is hydrated HERE by write_cursor. Gating on
11741 // `superseded_at` alone let an EXPIRED edge (`t_invalid <= :now`) surface
11742 // its body through the VECTOR arm — the same "validity enforced on
11743 // traversal, not on search" gap Slice 15b closed for nodes, now on the
11744 // edge-vector read path. Apply the shared `edge_validity_sql` predicate
11745 // (the ONE generator every edge read site uses) so no arm can drift.
11746 // `?1` is the rowid, so the edge `:now` binds at `?2`; the instant is the
11747 // frozen `view.edge_now()` — a bound value, never `datetime('now')`
11748 // (the :9161 no-inline-clock rule). edge_now is ALWAYS present, so unlike
11749 // node validity this conjunct is unconditional (an edge invalidated in the
11750 // past stays excluded even when node existence is relaxed).
11751 let edge_validity = edge_validity_sql("canonical_edges", 2);
11752 let mut edge_stmt = tx.prepare(&format!(
11753 "SELECT body, logical_id, source_id FROM canonical_edges \
11754 WHERE write_cursor = ?1 AND superseded_at IS NULL AND body IS NOT NULL\
11755 {edge_validity} LIMIT 1"
11756 ))?;
11757 // The bound parameter list for the node lookup: the candidate rowid,
11758 // plus `:now` when (and only when) the view emitted a validity conjunct.
11759 // One instant for the whole query — resolved once, above, not per row.
11760 let node_params = |rowid: i64| -> Vec<rusqlite::types::Value> {
11761 let mut p = vec![rusqlite::types::Value::Integer(rowid)];
11762 if let Some(now) = now_param {
11763 p.push(rusqlite::types::Value::Integer(now));
11764 }
11765 p
11766 };
11767 for (rowid, score) in rowids {
11768 // fix-3 (F1): the candidate list is now the OVERFETCHED pool in
11769 // exact-L2 order, so the caller's cutoff is applied HERE — after
11770 // the validity/existence filter, not before it. Bounded worst case:
11771 // at most `TOP_K_BIT_CANDIDATES` hydration probes when nearly every
11772 // candidate is filtered out; exactly `final_limit` (i.e. unchanged)
11773 // when nothing is. Ordering is unchanged — the surviving rows are
11774 // still emitted nearest-first — so on a corpus with no windows this
11775 // loop yields byte-identical results to the pre-fix code.
11776 if results.len() >= final_limit {
11777 break;
11778 }
11779 if let Ok((kind, body, logical_id, source_id)) =
11780 node_stmt.query_row(rusqlite::params_from_iter(node_params(rowid)), |row| {
11781 Ok((
11782 row.get::<_, String>(0)?,
11783 row.get::<_, String>(1)?,
11784 row.get::<_, Option<String>>(2)?,
11785 row.get::<_, Option<String>>(3)?,
11786 ))
11787 })
11788 {
11789 let id = derive_stable_id(logical_id.as_deref(), &body);
11790 results.push(SearchHit {
11791 id,
11792 write_cursor: rowid as u64,
11793 kind,
11794 body,
11795 score,
11796 branch: SoftFallbackBranch::Vector,
11797 // TC-31: the NODE's own provenance (a node hit is erased by
11798 // the document it was written from).
11799 source_id,
11800 ce_score: None,
11801 });
11802 } else if let Ok((body, logical_id, source_id)) =
11803 edge_stmt.query_row(rusqlite::params![rowid, view.edge_now()], |row| {
11804 Ok((
11805 row.get::<_, String>(0)?,
11806 row.get::<_, Option<String>>(1)?,
11807 row.get::<_, Option<String>>(2)?,
11808 ))
11809 })
11810 {
11811 let id = derive_stable_id(logical_id.as_deref(), &body);
11812 results.push(SearchHit {
11813 id,
11814 write_cursor: rowid as u64,
11815 kind: "edge_fact".to_string(),
11816 body,
11817 score,
11818 branch: SoftFallbackBranch::TextEdge,
11819 // TC-31: the EDGE's own provenance — consistent with the
11820 // graph arm's existing edge-source semantics.
11821 source_id,
11822 ce_score: None,
11823 });
11824 }
11825 }
11826 results
11827 } else {
11828 Vec::new()
11829 };
11830 let vector_rows_visible = !vector_results.is_empty();
11831 let soft_fallback = if query_vector.is_some() && !vector_rows_visible {
11832 tx.query_row(
11833 "SELECT 1
11834 FROM search_index
11835 JOIN _fathomdb_vector_kinds ON _fathomdb_vector_kinds.kind = search_index.kind
11836 LEFT JOIN _fathomdb_projection_terminal
11837 ON _fathomdb_projection_terminal.write_cursor = search_index.write_cursor
11838 WHERE search_index MATCH ?1
11839 AND _fathomdb_projection_terminal.write_cursor IS NULL
11840 LIMIT 1",
11841 [compiled.match_expression.as_str()],
11842 |_row| Ok(SoftFallback { branch: SoftFallbackBranch::Vector }),
11843 )
11844 .ok()
11845 } else {
11846 None
11847 };
11848 // Collect the text branch (ranked by `write_cursor`, as 0.7.2), then
11849 // post-filter it against the same metadata the vector branch was pruned by
11850 // in SQL (the vector branch is filtered in phase 1; the text branch has no
11851 // metadata columns of its own).
11852 let text_candidates: Vec<SearchHit> = {
11853 // 0.7.0 perf-experiments: optional FTS5 LIMIT cap. Gated on
11854 // FATHOMDB_PERF_EXPERIMENTS=1; opt-in via
11855 // FATHOMDB_PERF_SEARCH_LIMIT=<k>. No-op by default — preserves
11856 // 0.6.x unbounded result-set semantics. Removed (or made the
11857 // hardcoded default) at Wave 5 landing per
11858 // dev/plans/0.7.0-perf-experiments.md.
11859 let perf_limit: Option<usize> = if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_some() {
11860 std::env::var("FATHOMDB_PERF_SEARCH_LIMIT").ok().and_then(|s| s.parse().ok())
11861 } else {
11862 None
11863 };
11864 // Only the explicit direct text-only API fixes its node candidate window.
11865 // A missing vector is not sufficient evidence of that API: hybrid search
11866 // can also take its no-vector fallback and must retain its existing input
11867 // behavior. The fixed direct bound makes node inputs invariant across
11868 // accepted public limits before edge-body fusion and final truncation.
11869 let fts_only_limit =
11870 direct_text_candidate_limit.or_else(|| query_vector.is_none().then_some(final_limit));
11871 // G1: SELECT body + kind + write_cursor (interim id) and the
11872 // `bm25()` text-relevance score. IR-C (2026-06-10,
11873 // `performance-output-and-compare.md`): the per-branch rank RRF fuses on
11874 // must be **`bm25()` relevance**, not `write_cursor` (insertion order) —
11875 // the prior `ORDER BY write_cursor` meant the lexical arm never ranked by
11876 // relevance, the single biggest fusion bug. `bm25()` is more-negative ⇒
11877 // better, so ascending puts best matches first; `write_cursor` is the
11878 // deterministic tiebreak. The filter is applied as a Rust post-filter so
11879 // the unfiltered path is untouched.
11880 let limit_clause =
11881 fts_only_limit.or(perf_limit).map(|k| format!(" LIMIT {k}")).unwrap_or_default();
11882 // Cause-A: PREFER a logical_id-bearing query — LEFT JOIN canonical_nodes so
11883 // node hits carry the `l:`-tagged stable id. The join is 1:1 on
11884 // `write_cursor` (search_index holds node bodies only; edge bodies live in
11885 // search_index_edges), so the row-set and the `bm25(search_index),
11886 // write_cursor` ordering are byte-unchanged — only `cn.logical_id` is added.
11887 // FALL BACK to the original (logical_id-free) query on pre-step-12 schemas
11888 // (v10) whose `canonical_nodes` lacks `logical_id`: those hits key by the
11889 // `h:` content-hash. This keeps old-schema search byte-identical to the
11890 // pre-Cause-A behaviour (the prepare of the plain SQL is the exact prior
11891 // statement). Columns are qualified because both tables expose `write_cursor`.
11892 // CORRECTNESS (0.8.11.2 pico): `AND cn.superseded_at IS NULL` drops
11893 // superseded node versions. Node supersession is tombstone-then-insert
11894 // (`commit_batch`): the prior `canonical_nodes` row is UPDATEd to set
11895 // `superseded_at` (row kept, same write_cursor) and a NEW `search_index`
11896 // row is inserted for the new cursor — the OLD `search_index` row is
11897 // never deleted, so without this filter both versions stay live in FTS
11898 // and the stale one is returned. The other arms already filter this way
11899 // (edge branch, graph-arm node seed, point-recall `read_get_by_id`);
11900 // only this default node-text branch was missing it. The `LEFT JOIN` is
11901 // KEPT (not switched to inner): an active row joins to its `cn` with
11902 // `superseded_at = NULL` (kept); a superseded row joins to its tombstoned
11903 // `cn` with `superseded_at` NOT NULL (dropped); a legacy/orphan
11904 // `search_index` row with no `cn` gets `superseded_at = NULL` via the
11905 // LEFT JOIN (KEPT — preserves prior behaviour for ownerless rows).
11906 // TC-31 (0.8.20 Slice 10a): `cn.source_id` is selected off the SAME
11907 // already-present 1:1 LEFT JOIN that supplies `cn.logical_id` — one extra
11908 // column, no extra query, no row-set or ordering change.
11909 // fix-2 (codex §9 [P2]): the node-body FTS branch takes the SAME
11910 // validity conjunct, generated by `ReadView::validity_sql` rather than
11911 // hand-rolled — the predicate lives in exactly one place (Slice 10).
11912 // `?1` is the MATCH expression, so `:now` binds at `?2`.
11913 //
11914 // The generated conjunct is NULL-PERMISSIVE by construction
11915 // (`valid_from IS NULL OR ...`), which is exactly what this LEFT JOIN
11916 // needs: an ownerless `search_index` row with no `cn` reads NULL on both
11917 // columns and is KEPT, preserving the deliberate keep-ownerless
11918 // behaviour the `superseded_at` / `state` conjuncts above encode with
11919 // their explicit `OR ... IS NULL`. No extra `OR IS NULL` is needed here,
11920 // and none may be added: that would be a second, drifting copy of the
11921 // predicate.
11922 //
11923 // NO-REGRESSION: on a corpus that never authored a window every
11924 // `cn.valid_from` / `cn.valid_until` is NULL (step 22 back-filled NULL
11925 // with no DEFAULT), so both disjuncts are TRUE for every row and the
11926 // row-set, the `bm25(search_index), write_cursor` ordering and the
11927 // scores are all byte-unchanged.
11928 let text_validity = view.validity_sql("cn", 2);
11929 let join_sql = format!(
11930 "SELECT search_index.body, search_index.kind, search_index.write_cursor, \
11931 bm25(search_index), cn.logical_id, cn.source_id FROM search_index \
11932 LEFT JOIN canonical_nodes cn ON cn.write_cursor = search_index.write_cursor \
11933 WHERE search_index MATCH ?1 \
11934 AND cn.superseded_at IS NULL \
11935 AND (cn.state = 'active' OR cn.state IS NULL)\
11936 {text_validity} \
11937 ORDER BY bm25(search_index), search_index.write_cursor{limit_clause}"
11938 );
11939 // `:now` rides at ?2 only when the view emitted a conjunct; the relaxed
11940 // view produces the byte-identical single-parameter statement.
11941 let mut text_params: Vec<rusqlite::types::Value> =
11942 vec![rusqlite::types::Value::Text(compiled.match_expression.clone())];
11943 if let Some(now) = now_param {
11944 text_params.push(rusqlite::types::Value::Integer(now));
11945 }
11946 if let Ok(mut statement) = tx.prepare(&join_sql) {
11947 let rows =
11948 statement.query_map(rusqlite::params_from_iter(text_params.iter()), |row| {
11949 let body = row.get::<_, String>(0)?;
11950 let logical_id = row.get::<_, Option<String>>(4)?;
11951 Ok(SearchHit {
11952 id: derive_stable_id(logical_id.as_deref(), &body),
11953 body,
11954 kind: row.get::<_, String>(1)?,
11955 write_cursor: row.get::<_, i64>(2)? as u64,
11956 score: row.get::<_, f64>(3)?,
11957 branch: SoftFallbackBranch::Text,
11958 // TC-31: the NODE's own provenance. NULL for a legacy /
11959 // TC-11-spared governed row, and NULL for an ownerless
11960 // `search_index` row the LEFT JOIN keeps with no `cn`.
11961 source_id: row.get::<_, Option<String>>(5)?,
11962 ce_score: None,
11963 })
11964 })?;
11965 rows.flatten().collect()
11966 } else {
11967 // No `superseded_at IS NULL` filter here (and none is possible): this
11968 // fallback fires only on pre-step-12 schemas whose `canonical_nodes`
11969 // lacks `logical_id` — and step-12 adds `logical_id` and
11970 // `superseded_at` in the SAME migration, so this schema has neither
11971 // column. Supersession (`commit_batch`) is a no-op without
11972 // `logical_id`, so no superseded node rows can exist on this path.
11973 //
11974 // TC-31 (0.8.20 Slice 10a): `source_id` arrived in step 8, `logical_id`
11975 // in step 12, so a schema that lands HERE (no `logical_id`) may still
11976 // HAVE `source_id` — steps 8..11. Try a provenance-bearing variant
11977 // first, adding only `cn.source_id` over the SAME 1:1 LEFT JOIN shape
11978 // used above (row-set and ordering unchanged; a missing `cn` row keeps
11979 // NULL as before). Fall back to the historical, byte-identical
11980 // provenance-free statement on a pre-step-8 schema, where the column
11981 // genuinely does not exist and `None` is the only truthful answer.
11982 let source_sql = format!(
11983 "SELECT search_index.body, search_index.kind, search_index.write_cursor, \
11984 bm25(search_index), cn.source_id FROM search_index \
11985 LEFT JOIN canonical_nodes cn ON cn.write_cursor = search_index.write_cursor \
11986 WHERE search_index MATCH ?1 \
11987 ORDER BY bm25(search_index), search_index.write_cursor{limit_clause}"
11988 );
11989 if let Ok(mut statement) = tx.prepare(&source_sql) {
11990 let rows = statement.query_map([compiled.match_expression.as_str()], |row| {
11991 let body = row.get::<_, String>(0)?;
11992 Ok(SearchHit {
11993 // No logical_id column on this schema → content-hash id.
11994 id: derive_stable_id(None, &body),
11995 body,
11996 kind: row.get::<_, String>(1)?,
11997 write_cursor: row.get::<_, i64>(2)? as u64,
11998 score: row.get::<_, f64>(3)?,
11999 branch: SoftFallbackBranch::Text,
12000 source_id: row.get::<_, Option<String>>(4)?,
12001 ce_score: None,
12002 })
12003 })?;
12004 rows.flatten().collect()
12005 } else {
12006 // Pre-step-8: no `source_id` column anywhere. Byte-identical to
12007 // the historical statement.
12008 let plain_sql = format!(
12009 "SELECT body, kind, write_cursor, bm25(search_index) FROM search_index \
12010 WHERE search_index MATCH ?1 \
12011 ORDER BY bm25(search_index), write_cursor{limit_clause}"
12012 );
12013 let mut statement = tx.prepare(&plain_sql)?;
12014 let rows = statement.query_map([compiled.match_expression.as_str()], |row| {
12015 let body = row.get::<_, String>(0)?;
12016 Ok(SearchHit {
12017 // No logical_id column on this schema → content-hash id.
12018 id: derive_stable_id(None, &body),
12019 body,
12020 kind: row.get::<_, String>(1)?,
12021 write_cursor: row.get::<_, i64>(2)? as u64,
12022 score: row.get::<_, f64>(3)?,
12023 branch: SoftFallbackBranch::Text,
12024 source_id: None,
12025 ce_score: None,
12026 })
12027 })?;
12028 rows.flatten().collect()
12029 }
12030 }
12031 };
12032 let mut text_results: Vec<SearchHit> = Vec::with_capacity(text_candidates.len());
12033 for hit in text_candidates {
12034 if text_hit_passes_filter(&tx, hit.write_cursor, &hit.kind, filter)? {
12035 text_results.push(hit);
12036 }
12037 }
12038
12039 // G11 (Slice 15) — edge-body FTS branch from `search_index_edges`.
12040 // Appended to text_results; tagged with SoftFallbackBranch::TextEdge so
12041 // callers can distinguish edge hits from node hits.
12042 //
12043 // fix-1 [P2]: JOIN canonical_edges to exclude superseded edge rows
12044 // (invalidate-not-accumulate can leave a superseded body in the FTS index).
12045 // fix-2 [P2]: use edge_fts_hit_passes_filter (NOT text_hit_passes_filter).
12046 // Edge hits always have source_type="edge_fact"; text_hit_passes_filter
12047 // calls resolve_source_type(relation_kind) which returns Err for unknown
12048 // relation kinds, silently rejecting every edge hit when a source_type
12049 // filter is set — the exact inverse of correct behaviour.
12050 // fix-3 [P2]: edge_fts_hit_passes_filter now queries vector_default for
12051 // created_after/status (mirroring text_hit_passes_filter). Collect edge
12052 // candidates into a Vec first (drops stmt borrow on tx) so we can pass
12053 // &tx to edge_fts_hit_passes_filter without a borrow conflict.
12054 let edge_candidates: Vec<SearchHit> = {
12055 // Cause-A: the JOIN to canonical_edges already exists; additively select
12056 // `ce.logical_id` (edges always carry one) for the stable hit-id. No
12057 // ordering/row-set change.
12058 // TC-31 (0.8.20 Slice 10a): `ce.source_id` rides the SAME existing inner
12059 // JOIN as `ce.logical_id` — one extra column, no extra query, no
12060 // row-set/ordering change. An edge hit carries the EDGE's own provenance,
12061 // matching the graph arm's edge-source semantics.
12062 // fix-2 (codex §9 [P2]): the JOIN already dropped superseded edge rows,
12063 // but a body-bearing edge written with `t_invalid <= :now` (expired /
12064 // invalidated) still MATCHed and surfaced its body through ordinary
12065 // search — edge temporal validity was enforced on the graph-traversal and
12066 // projection paths but NOT on this FTS read path. Apply the shared
12067 // `edge_validity_sql` conjunct (the ONE generator every edge read site
12068 // uses, so no path can drift). `?1` is the MATCH expression, so the edge
12069 // `:now` binds at `?2`; the instant is the frozen `view.edge_now()` — a
12070 // bound value, never `datetime('now')` (the :9161 no-inline-clock rule),
12071 // and always present (edge invalidation is not relaxed by node existence
12072 // relaxation).
12073 let edge_validity = edge_validity_sql("ce", 2);
12074 let edge_sql = format!(
12075 "SELECT sei.body, sei.kind, sei.write_cursor, bm25(search_index_edges), \
12076 ce.logical_id, ce.source_id \
12077 FROM search_index_edges sei \
12078 JOIN canonical_edges ce ON ce.write_cursor = sei.write_cursor \
12079 WHERE search_index_edges MATCH ?1 \
12080 AND ce.superseded_at IS NULL{edge_validity} \
12081 ORDER BY bm25(search_index_edges), sei.write_cursor"
12082 );
12083 // search_index_edges may not exist on very old DBs not yet at step-14;
12084 // ignore the error gracefully (returns empty slice).
12085 if let Ok(mut stmt) = tx.prepare(&edge_sql) {
12086 if let Ok(rows) = stmt.query_map(
12087 rusqlite::params![compiled.match_expression.as_str(), view.edge_now()],
12088 |row| {
12089 let body = row.get::<_, String>(0)?;
12090 let logical_id = row.get::<_, Option<String>>(4)?;
12091 Ok(SearchHit {
12092 id: derive_stable_id(logical_id.as_deref(), &body),
12093 body,
12094 kind: row.get::<_, String>(1)?,
12095 write_cursor: row.get::<_, i64>(2)? as u64,
12096 score: row.get::<_, f64>(3)?,
12097 branch: SoftFallbackBranch::TextEdge,
12098 // TC-31: the EDGE's own provenance.
12099 source_id: row.get::<_, Option<String>>(5)?,
12100 ce_score: None,
12101 })
12102 },
12103 ) {
12104 rows.flatten().collect()
12105 } else {
12106 Vec::new()
12107 }
12108 } else {
12109 Vec::new()
12110 }
12111 };
12112 // Attribute predicates intentionally apply only to node projections. Count
12113 // edge-FTS candidates that would otherwise pass when the caller requested
12114 // the opt-in explanation, without adding work to the default search path.
12115 let mut dropped_edge_hits = 0_u32;
12116 for row in edge_candidates {
12117 if edge_fts_hit_passes_filter(&tx, row.write_cursor, &row.kind, filter)? {
12118 text_results.push(row);
12119 } else if explain
12120 && filter.is_some_and(|active_filter| !active_filter.attributes.is_empty())
12121 && edge_fts_hit_passes_non_attribute_filter(&tx, row.write_cursor, &row.kind, filter)?
12122 {
12123 dropped_edge_hits = dropped_edge_hits.saturating_add(1);
12124 }
12125 }
12126 tx.commit()?;
12127
12128 // GA-2 / Slice-40 (◆ B-1) measurement seam: when `vector_stage_only` is set
12129 // (only ever by the eu7 recall harness via `set_vector_stage_only_for_test`,
12130 // off for every production caller), return the pre-fusion VECTOR-branch
12131 // ranking (bit-KNN K=192 + f32 rerank) verbatim, skipping `fuse_rrf` /
12132 // recency / `rerank_fused`. This exposes the ANN-quantization FIDELITY
12133 // signal — vector top-N vs the exact-f32 VECTOR top-10 ground truth — that
12134 // the AC-075 0.90 floor is defined to measure. It is NOT a `fusion_mode`
12135 // knob: the production branch below is byte-unchanged and RRF stays
12136 // unconditional.
12137 // G0 Phase-2 (BLOCK-1) side-channel meter — default (all-zero, rate 0.0) on
12138 // the non-graph-arm paths; populated by the BFS seed phase when graph-arm runs.
12139 let mut graph_stats = GraphFrontierStats::default();
12140
12141 // 0.8.8 EXP-OBS (Slice 5) — capture per-arm rank maps + counts BEFORE the arms
12142 // are consumed by fusion. All reads; only when `explain` (else zero work).
12143 // `body_rank_map` keeps the FIRST occurrence (== the rank `fuse_three_arms`
12144 // uses, which dedups keeping the first). `*_fused_scores` is captured from the
12145 // post-recency / pre-CE intermediate so `fused_score` is faithful to what
12146 // `ce_rerank` normalizes.
12147 let body_rank_map = |hits: &[SearchHit]| -> HashMap<String, u32> {
12148 let mut m: HashMap<String, u32> = HashMap::new();
12149 for (i, h) in hits.iter().enumerate() {
12150 m.entry(h.body.clone()).or_insert(i as u32);
12151 }
12152 m
12153 };
12154 let body_score_map = |hits: &[SearchHit]| -> HashMap<String, f64> {
12155 hits.iter().map(|h| (h.body.clone(), h.score)).collect()
12156 };
12157
12158 let (exp_vector_ranks, exp_text_ranks, exp_vector_n, exp_text_n) = if explain {
12159 (
12160 Some(body_rank_map(&vector_results)),
12161 Some(body_rank_map(&text_results)),
12162 vector_results.len() as u32,
12163 text_results.len() as u32,
12164 )
12165 } else {
12166 (None, None, 0, 0)
12167 };
12168 let mut exp_graph_ranks: Option<HashMap<String, u32>> = None;
12169 let mut exp_fused_scores: Option<HashMap<String, f64>> = None;
12170 let mut exp_graph_n: u32 = 0;
12171 // F9 (0.8.16 Slice 5) — per-hit importance/confidence contribution maps
12172 // (keyed by hit id == write_cursor), captured for the explain sidecar.
12173 let mut exp_importance: Option<HashMap<u64, f64>> = None;
12174 let mut exp_confidence: Option<HashMap<u64, f64>> = None;
12175
12176 let mut results = if vector_stage_only {
12177 vector_results
12178 } else if use_graph_arm {
12179 // R3 (Slice 30) — graph arm: BFS over temporal fact-edges seeded from
12180 // the top-10 two-arm fused candidates, depth ≤ 3, cap 50.
12181 // Temporal filter: superseded_at IS NULL AND (t_invalid IS NULL OR t_invalid > now).
12182 // Synthesized-node penalty: kind = 'unknown' → score *= 0.3.
12183 //
12184 // Approach: compute the two-arm fused result first (for BFS seeding),
12185 // then fuse three arms: the two-arm result (as "vector" arm), an empty
12186 // text arm, and the graph candidates. The two-arm result preserves all
12187 // existing ranking semantics; the graph arm contributes new candidates.
12188 let two_arm_fused = fuse_rrf(vector_results, text_results);
12189 // C1: seed the graph arm from the query's FTS match expression (entities /
12190 // edge-facts), not the doc-node fused hits. `fused_hits` is still passed for
12191 // the seed-body exclusion set.
12192 let (graph_candidates, stats, graph_edge_confidence) = bfs_graph_arm_candidates(
12193 reader,
12194 &two_arm_fused,
12195 compiled.match_expression.as_str(),
12196 3,
12197 50,
12198 view,
12199 )?;
12200 graph_stats = stats;
12201 if explain {
12202 exp_graph_ranks = Some(body_rank_map(&graph_candidates));
12203 exp_graph_n = graph_candidates.len() as u32;
12204 }
12205 // Named intermediate (byte-identical to the prior nested call) so explain
12206 // can read the pre-CE fused scores without perturbing the ranking.
12207 let fused = apply_recency_reweight(
12208 fuse_three_arms(two_arm_fused, vec![], graph_candidates),
12209 recency_enabled,
12210 );
12211 // F9 — importance (node) / confidence (edge) reweight, OFF by default.
12212 // Order: AFTER recency (consistent placement), BEFORE the CE rerank seam.
12213 let (imp_map, mut conf_map) = if importance_enabled || explain {
12214 build_importance_confidence_maps(reader, &fused).unwrap_or_default()
12215 } else {
12216 (HashMap::new(), HashMap::new())
12217 };
12218 // F9 FIX-1: `build_importance_confidence_maps` keys edge confidence on the
12219 // EDGE `write_cursor`, which never matches a graph-arm NODE hit's cursor —
12220 // so it alone leaves graph-arm hits with no edge confidence. Merge the
12221 // BFS-collected per-node traversing-edge confidence (node cursor ⇒ conf).
12222 // Node/edge cursors are globally unique, so there is never a key collision
12223 // with the edge-fact confidence above; `or_insert` documents that intent.
12224 if importance_enabled || explain {
12225 for (cursor, conf) in &graph_edge_confidence {
12226 conf_map.entry(*cursor).or_insert(*conf);
12227 }
12228 }
12229 let fused = apply_importance_reweight(fused, &imp_map, &conf_map, importance_enabled);
12230 if explain {
12231 exp_importance = Some(imp_map);
12232 exp_confidence = Some(conf_map);
12233 exp_fused_scores = Some(body_score_map(&fused));
12234 }
12235 rerank_fused(raw_query, fused, rerank_depth, alpha, pool_n)
12236 } else {
12237 // G9 + G12: RRF-fuse the two ranked branches (keyed on body, vector-first
12238 // tiebreak) into the unconditional new ranking, recency-reweight (gated,
12239 // off by default), then pass through the identity rerank seam. The
12240 // vector-empty `soft_fallback` signal was computed above, BEFORE this
12241 // branch-collapse.
12242 let fused = apply_recency_reweight(fuse_rrf(vector_results, text_results), recency_enabled);
12243 // F9 — importance (node) / confidence (edge) reweight, OFF by default.
12244 // Same placement as the graph-arm branch: after recency, before CE rerank.
12245 let (imp_map, conf_map) = if importance_enabled || explain {
12246 build_importance_confidence_maps(reader, &fused).unwrap_or_default()
12247 } else {
12248 (HashMap::new(), HashMap::new())
12249 };
12250 let fused = apply_importance_reweight(fused, &imp_map, &conf_map, importance_enabled);
12251 if explain {
12252 exp_importance = Some(imp_map);
12253 exp_confidence = Some(conf_map);
12254 exp_fused_scores = Some(body_score_map(&fused));
12255 }
12256 rerank_fused(raw_query, fused, rerank_depth, alpha, pool_n)
12257 };
12258
12259 results.truncate(final_limit);
12260
12261 // 0.8.8 EXP-OBS — assemble the sidecar `Explanation` from the captured maps +
12262 // the final `results`. `embedder_id` is left empty here (the worker has no
12263 // identity) and filled by `search_inner_with_stats`.
12264 let explanation = if explain {
12265 let fused_scores = exp_fused_scores.unwrap_or_default();
12266 let per_hit: Vec<PerHitExplain> = results
12267 .iter()
12268 .map(|h| PerHitExplain {
12269 // `PerHitExplain.id` carries the engine-internal positional
12270 // `write_cursor` (the pre-C-2 `SearchHit.id`), matching the
12271 // telemetry `result_ids` / importance-map key space; the typed
12272 // `SearchHit.id` is the separate caller-facing identity.
12273 id: h.write_cursor,
12274 arm: h.branch,
12275 vector_rank: exp_vector_ranks.as_ref().and_then(|m| m.get(&h.body).copied()),
12276 text_rank: exp_text_ranks.as_ref().and_then(|m| m.get(&h.body).copied()),
12277 graph_rank: exp_graph_ranks.as_ref().and_then(|m| m.get(&h.body).copied()),
12278 fused_score: fused_scores.get(&h.body).copied().unwrap_or(h.score),
12279 ce_score: h.ce_score,
12280 blended: h.score,
12281 importance: exp_importance.as_ref().and_then(|m| m.get(&h.write_cursor).copied()),
12282 confidence: exp_confidence.as_ref().and_then(|m| m.get(&h.write_cursor).copied()),
12283 })
12284 .collect();
12285 let ce_active = rerank_depth > 0 && per_hit.iter().any(|p| p.ce_score.is_some());
12286 Some(Explanation {
12287 trace: QueryTrace {
12288 query_chars: raw_query.chars().count() as u32,
12289 k: final_limit as u32,
12290 rerank_depth: rerank_depth as u32,
12291 pool_n: pool_n as u32,
12292 alpha,
12293 use_graph_arm,
12294 recency: recency_enabled,
12295 embedder_id: String::new(),
12296 ce_active,
12297 vector_hits: exp_vector_n,
12298 text_hits: exp_text_n,
12299 graph_hits: exp_graph_n,
12300 dropped_edge_hits,
12301 },
12302 per_hit,
12303 })
12304 } else {
12305 None
12306 };
12307
12308 Ok((cursor, soft_fallback, results, graph_stats, explanation))
12309}
12310
12311/// R3 (Slice 30) + C1 (0.8.1 graph-arm seeding) — graph-arm BFS candidate generation.
12312///
12313/// **C1 seeding (the BLOCK-1 fix):** the frontier is seeded from the graph's OWN
12314/// query-matched text surfaces — NOT from doc-node hits (doc nodes carry
12315/// `logical_id = NULL`, so the old doc-seeding produced an empty frontier). Two
12316/// seed sources are unioned on `match_expression` (the compiled FTS query):
12317/// A. **edge-fact FTS** (`search_index_edges`) — both endpoints (`from_id`,
12318/// `to_id`) of matched, temporally-live, non-fallback edges;
12319/// B. **entity-node FTS** (`search_index` ⋈ `canonical_nodes`) — matched nodes
12320/// with `logical_id IS NOT NULL` (excludes doc nodes — the bug surface).
12321/// Each distinct candidate `logical_id` is counted in `seeds_considered`; those
12322/// confirmed active in `canonical_nodes` are `seeds_resolved` and pushed onto the
12323/// frontier (dangling edge endpoints count considered-but-unresolved).
12324///
12325/// Phase 2 is unchanged: BFS over `canonical_edges` with the temporal filter,
12326/// carrying each traversed edge's `source_id` (G0 BLOCK-2) onto the emitted hit.
12327/// Collects reachable node bodies (up to `cap`) as [`SearchHit`]s tagged
12328/// `SoftFallbackBranch::GraphArm`. Score = `1.0 / (1.0 + hop_count)` with a
12329/// synthesized-node penalty (`kind = 'unknown'` → score *= 0.3). Bodies already
12330/// present in `fused_hits` are excluded (already covered by the two-arm result).
12331///
12332/// **F9 (0.8.16 Slice 5) confidence carry:** the third tuple element maps each
12333/// emitted graph-arm hit's `write_cursor` (its `SearchHit.id`, a NODE cursor) to
12334/// the `confidence` of the EDGE traversed to reach that node — the input the F9
12335/// reweight (`graph_rrf_score(edge) = confidence × 1/(K+bfs_rank)`) consumes.
12336/// `build_importance_confidence_maps` keys edge confidence on the EDGE
12337/// `write_cursor`, which never equals a reached node's cursor, so without this
12338/// carry edge confidence never reaches a graph-arm hit. **Determinism rule (matches
12339/// the BLOCK-2 provenance carry):** when several edges reach the same node, the
12340/// FIRST edge to claim the node in the `visited` dedup wins — i.e. the edge that
12341/// produced the node's winning `bfs_rank` (seeds are considered before Phase-2
12342/// neighbors; within a phase, `ORDER BY write_cursor` makes the earliest-written
12343/// edge win). A NULL edge confidence is simply not inserted ⇒ neutral (1.0).
12344fn bfs_graph_arm_candidates(
12345 reader: &mut Connection,
12346 fused_hits: &[SearchHit],
12347 match_expression: &str,
12348 max_depth: u32,
12349 cap: usize,
12350 view: FrozenView,
12351) -> rusqlite::Result<(Vec<SearchHit>, GraphFrontierStats, HashMap<u64, f64>)> {
12352 // fix-2 (codex §9 [P2]): the opt-in graph arm hydrates NODES too, so it takes
12353 // the same validity conjunct as the vector and FTS branches — otherwise
12354 // `search_reranked(.., use_graph_arm = true)` would keep the exact leak the
12355 // other two branches just closed. Same generator, same bound `:now`.
12356 //
12357 // fix-3 (F2): the instant arrives ALREADY RESOLVED in the `FrozenView` — it
12358 // is the identical value the vector and FTS arms bound. This arm cannot
12359 // re-read the clock: a `FrozenView` carries no route to one.
12360 let now_param = view.now_param();
12361 // C1 — seed-FTS fan-out cap per source (A: edge endpoints, B: entity nodes).
12362 const SEED_FTS_N: usize = 10;
12363 const SYNTHESIZED_PENALTY: f64 = 0.3;
12364
12365 // Bodies already in the fused result — exclude these from graph arm output.
12366 let seed_bodies: std::collections::HashSet<&str> =
12367 fused_hits.iter().map(|h| h.body.as_str()).collect();
12368
12369 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12370
12371 let mut frontier: VecDeque<(String, u32)> = VecDeque::new(); // (logical_id, depth)
12372 let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
12373 let mut candidates: Vec<SearchHit> = Vec::new();
12374 // F9 (0.8.16 Slice 5) — per-hit traversing-edge confidence, keyed by the
12375 // emitted hit's NODE `write_cursor`. First edge to reach a node wins (visited
12376 // dedup); NULL confidence is never inserted (⇒ neutral in the reweight).
12377 let mut edge_confidence_by_cursor: HashMap<u64, f64> = HashMap::new();
12378 // G0 Phase-2 (BLOCK-1) frontier meter — distinct seed candidates considered vs
12379 // resolved-active; `resolved_seed_rate` flips 0→>0 once entities/edge-facts seed.
12380 let mut stats = GraphFrontierStats::default();
12381 {
12382 // C1 seeding — gather distinct candidate (logical_id, provenance source_id)
12383 // pairs from the graph's OWN query-matched FTS surfaces (NOT doc-node hits).
12384 // Order-preserving dedup (first provenance wins) so `seeds_considered` counts
12385 // each candidate once. `source_id` is the session the seed traces back to: the
12386 // matched edge's `source_id` (source A) or the entity node's own (source B).
12387 // F9: each seed carries the confidence of the edge that surfaced it
12388 // (`None` for entity-FTS seeds, which have no traversing edge).
12389 let mut candidate_seeds: Vec<(String, Option<String>, Option<f64>)> = Vec::new();
12390 let mut seen_candidates: std::collections::HashSet<String> =
12391 std::collections::HashSet::new();
12392 let push_candidate =
12393 |lid: String,
12394 source_id: Option<String>,
12395 confidence: Option<f64>,
12396 seen: &mut std::collections::HashSet<String>,
12397 out: &mut Vec<(String, Option<String>, Option<f64>)>| {
12398 if seen.insert(lid.clone()) {
12399 out.push((lid, source_id, confidence));
12400 }
12401 };
12402
12403 // Seed source A — edge-fact endpoints (primary). Both endpoints of each
12404 // matched, temporally-live, non-fallback edge are candidate seeds, tagged with
12405 // the edge's `source_id` provenance and (F9) `confidence`. `search_index_edges`
12406 // may be absent on very old DBs (< step-14) — degrade to no edge seeds rather
12407 // than error.
12408 // TC-33: `?1` MATCH, `?2` LIMIT ⇒ the edge `:now` binds at `?3`.
12409 if let Ok(mut edge_seed_stmt) = tx.prepare(&format!(
12410 "SELECT ce.from_id, ce.to_id, ce.source_id, ce.confidence \
12411 FROM search_index_edges sei \
12412 JOIN canonical_edges ce ON ce.write_cursor = sei.write_cursor \
12413 WHERE search_index_edges MATCH ?1 \
12414 AND ce.superseded_at IS NULL{} \
12415 AND (ce.temporal_fallback IS NULL OR ce.temporal_fallback = 0) \
12416 ORDER BY bm25(search_index_edges), sei.write_cursor \
12417 LIMIT ?2",
12418 edge_validity_sql("ce", 3)
12419 )) {
12420 let rows = edge_seed_stmt.query_map(
12421 rusqlite::params![match_expression, SEED_FTS_N as i64, view.edge_now()],
12422 |row| {
12423 Ok((
12424 row.get::<_, String>(0)?,
12425 row.get::<_, String>(1)?,
12426 row.get::<_, Option<String>>(2)?,
12427 row.get::<_, Option<f64>>(3)?,
12428 ))
12429 },
12430 )?;
12431 for quad in rows {
12432 let (from_id, to_id, source_id, confidence) = quad?;
12433 push_candidate(
12434 from_id,
12435 source_id.clone(),
12436 confidence,
12437 &mut seen_candidates,
12438 &mut candidate_seeds,
12439 );
12440 push_candidate(
12441 to_id,
12442 source_id,
12443 confidence,
12444 &mut seen_candidates,
12445 &mut candidate_seeds,
12446 );
12447 }
12448 }
12449
12450 // Seed source B — entity-node FTS (isolated / strongly-named entities).
12451 // `logical_id IS NOT NULL` structurally excludes doc nodes (the bug surface).
12452 // Provenance = the node's own `source_id` (the session it was extracted from).
12453 {
12454 // `?1` MATCH, `?2` LIMIT ⇒ `:now` binds at `?3`.
12455 let seed_validity = view.validity_sql("cn", 3);
12456 let mut node_seed_stmt = tx.prepare(&format!(
12457 "SELECT cn.logical_id, cn.source_id \
12458 FROM search_index si \
12459 JOIN canonical_nodes cn ON cn.write_cursor = si.write_cursor \
12460 WHERE search_index MATCH ?1 \
12461 AND cn.superseded_at IS NULL \
12462 AND cn.state = 'active' \
12463 AND cn.logical_id IS NOT NULL\
12464 {seed_validity} \
12465 ORDER BY bm25(search_index), si.write_cursor \
12466 LIMIT ?2"
12467 ))?;
12468 let mut seed_params: Vec<rusqlite::types::Value> = vec![
12469 rusqlite::types::Value::Text(match_expression.to_string()),
12470 rusqlite::types::Value::Integer(SEED_FTS_N as i64),
12471 ];
12472 if let Some(now) = now_param {
12473 seed_params.push(rusqlite::types::Value::Integer(now));
12474 }
12475 let rows = node_seed_stmt
12476 .query_map(rusqlite::params_from_iter(seed_params.iter()), |row| {
12477 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
12478 })?;
12479 for pair in rows {
12480 let (lid, source_id) = pair?;
12481 // Entity-FTS seed: no traversing edge ⇒ no edge confidence (neutral).
12482 push_candidate(lid, source_id, None, &mut seen_candidates, &mut candidate_seeds);
12483 }
12484 }
12485
12486 // Resolve + emit: a seed is `resolved` only if an ACTIVE canonical_node carries
12487 // that logical_id (dangling edge endpoints count considered-not-resolved). A
12488 // resolved seed is BOTH a BFS root AND emitted as a graph-arm candidate (depth
12489 // 0, hop_score 1.0) — so an edge-only query match surfaces the connected ENTITY
12490 // nodes, not just the fact body (codex §9 [P2]). Seeds whose body is already in
12491 // the two-arm result are skipped; the cap is respected.
12492 let active_validity = view.validity_sql("canonical_nodes", 2);
12493 let mut active_stmt = tx.prepare(&format!(
12494 "SELECT kind, body, write_cursor FROM canonical_nodes \
12495 WHERE logical_id = ?1 AND superseded_at IS NULL AND state = 'active'\
12496 {active_validity} LIMIT 1"
12497 ))?;
12498 for (lid, source_id, seed_confidence) in candidate_seeds {
12499 stats.seeds_considered += 1;
12500 let mut active_params: Vec<rusqlite::types::Value> =
12501 vec![rusqlite::types::Value::Text(lid.clone())];
12502 if let Some(now) = now_param {
12503 active_params.push(rusqlite::types::Value::Integer(now));
12504 }
12505 let row: Option<(String, String, i64)> = active_stmt
12506 .query_row(rusqlite::params_from_iter(active_params.iter()), |r| {
12507 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, i64>(2)?))
12508 })
12509 .optional()?;
12510 if let Some((kind, body, write_cursor)) = row {
12511 stats.seeds_resolved += 1;
12512 if visited.insert(lid.clone()) {
12513 // Cause-A: the seed's `logical_id` is in hand (`lid`) — derive the
12514 // stable id before `lid` is moved onto the frontier (zero extra query).
12515 let id = derive_stable_id(Some(&lid), &body);
12516 frontier.push_back((lid, 0));
12517 if !seed_bodies.contains(body.as_str()) && candidates.len() < cap {
12518 // depth-0 hop_score = 1.0/(1.0+0) = 1.0; synthesized penalty for
12519 // 'unknown' kind (mirrors the Phase-2 neighbor scoring).
12520 let score = if kind == "unknown" { SYNTHESIZED_PENALTY } else { 1.0 };
12521 // F9: an edge-seeded endpoint carries its seeding edge's
12522 // confidence (source A); entity-FTS seeds carry None.
12523 if let Some(c) = seed_confidence {
12524 edge_confidence_by_cursor.insert(write_cursor as u64, c);
12525 }
12526 candidates.push(SearchHit {
12527 id,
12528 write_cursor: write_cursor as u64,
12529 kind,
12530 body,
12531 score,
12532 branch: SoftFallbackBranch::GraphArm,
12533 source_id,
12534 ce_score: None,
12535 });
12536 }
12537 }
12538 }
12539 }
12540 }
12541 stats.frontier_nonempty = !frontier.is_empty();
12542
12543 // Phase 2: BFS over canonical_edges (temporal filter). `candidates` already
12544 // holds the depth-0 emitted seeds; BFS appends the reachable neighbors.
12545 // Both statements are prepared ONCE outside the loops — re-preparing inside
12546 // would issue O(frontier_size × neighbors) sqlite3_prepare_v2 calls.
12547 let mut edge_stmt = tx.prepare(
12548 // G0 Phase-2 (BLOCK-2): carry the traversed edge's `source_id` so a
12549 // graph-reached neighbor can resolve back to the session it was extracted
12550 // from. `ORDER BY e.write_cursor` makes the traversal deterministic: when
12551 // several active edges connect this node to the SAME neighbor with
12552 // different `source_id`s, the earliest-written edge wins the `visited`
12553 // dedup, so the carried provenance is stable (not SQLite-order-dependent).
12554 // (codex §9 [P2]; the design §B already rejected the memo's arbitrary
12555 // `LIMIT 1` lookup for the same reason.)
12556 // F9: also carry the traversed edge's `confidence` — the reweight input for
12557 // the reached node (keyed downstream by the node's `write_cursor`). Same
12558 // determinism as `source_id`: the earliest-written edge wins the `visited`
12559 // dedup, so the reached node's confidence is the winning-`bfs_rank` edge's.
12560 // TC-33: `?1` is the anchor logical_id ⇒ the edge `:now` binds at `?2`.
12561 &format!(
12562 "SELECT e.from_id, e.to_id, e.source_id, e.confidence \
12563 FROM canonical_edges e \
12564 WHERE (e.from_id = ?1 OR e.to_id = ?1) \
12565 AND e.superseded_at IS NULL{} \
12566 AND (e.temporal_fallback IS NULL OR e.temporal_fallback = 0) \
12567 ORDER BY e.write_cursor \
12568 LIMIT 64",
12569 edge_validity_sql("e", 2)
12570 ),
12571 )?;
12572 // Fetch write_cursor alongside kind+body so graph-arm hits carry a real id
12573 // for apply_recency_reweight (id=0 would force min_id=0 and distort span).
12574 let body_validity = view.validity_sql("canonical_nodes", 2);
12575 let mut body_stmt = tx.prepare(&format!(
12576 "SELECT kind, body, write_cursor FROM canonical_nodes \
12577 WHERE logical_id = ?1 AND superseded_at IS NULL AND state = 'active'\
12578 {body_validity} \
12579 LIMIT 1"
12580 ))?;
12581
12582 while let Some((lid, depth)) = frontier.pop_front() {
12583 if candidates.len() >= cap {
12584 break;
12585 }
12586 if depth >= max_depth {
12587 continue;
12588 }
12589
12590 // Fetch temporal-live neighbors via edges, each paired with the
12591 // traversing edge's `source_id` (BLOCK-2 provenance carry) and (F9)
12592 // `confidence` (the reweight input for the reached node).
12593 let neighbors: Vec<(String, Option<String>, Option<f64>)> = {
12594 let rows = edge_stmt.query_map(params![&lid, view.edge_now()], |row| {
12595 Ok((
12596 row.get::<_, String>(0)?,
12597 row.get::<_, String>(1)?,
12598 row.get::<_, Option<String>>(2)?,
12599 row.get::<_, Option<f64>>(3)?,
12600 ))
12601 })?;
12602 rows.flatten()
12603 .map(|(from_id, to_id, source_id, confidence)| {
12604 let neighbor = if from_id == lid { to_id } else { from_id };
12605 (neighbor, source_id, confidence)
12606 })
12607 .collect()
12608 };
12609
12610 for (neighbor, edge_source_id, edge_confidence) in neighbors {
12611 if visited.contains(&neighbor) {
12612 continue;
12613 }
12614 visited.insert(neighbor.clone());
12615
12616 // Fetch neighbor body + write_cursor from canonical_nodes.
12617 let mut body_params: Vec<rusqlite::types::Value> =
12618 vec![rusqlite::types::Value::Text(neighbor.clone())];
12619 if let Some(now) = now_param {
12620 body_params.push(rusqlite::types::Value::Integer(now));
12621 }
12622 let row: Option<(String, String, i64)> = body_stmt
12623 .query_row(rusqlite::params_from_iter(body_params.iter()), |row| {
12624 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?))
12625 })
12626 .optional()?;
12627
12628 if let Some((kind, body, write_cursor)) = row {
12629 // Skip bodies already covered by the two-arm result.
12630 if !seed_bodies.contains(body.as_str()) {
12631 let hop_score = 1.0 / (1.0 + (depth + 1) as f64);
12632 let score =
12633 if kind == "unknown" { hop_score * SYNTHESIZED_PENALTY } else { hop_score };
12634 // Cause-A: the neighbor's `logical_id` is `neighbor` (still in
12635 // scope here; only moved onto the frontier below) — derive the
12636 // stable id with no extra query.
12637 let id = derive_stable_id(Some(&neighbor), &body);
12638 // F9: record the traversing edge's confidence for this node
12639 // (first edge wins — this is the winning-`bfs_rank` edge).
12640 if let Some(c) = edge_confidence {
12641 edge_confidence_by_cursor.insert(write_cursor as u64, c);
12642 }
12643 candidates.push(SearchHit {
12644 id,
12645 write_cursor: write_cursor as u64,
12646 kind,
12647 body,
12648 score,
12649 branch: SoftFallbackBranch::GraphArm,
12650 // BLOCK-2: the session this fact-edge was extracted from.
12651 source_id: edge_source_id.clone(),
12652 ce_score: None,
12653 });
12654 if candidates.len() >= cap {
12655 break;
12656 }
12657 }
12658 // Always push neighbor to frontier for further BFS expansion.
12659 frontier.push_back((neighbor, depth + 1));
12660 }
12661 }
12662 }
12663
12664 drop(edge_stmt);
12665 drop(body_stmt);
12666 tx.commit()?;
12667 stats.graph_candidates_emitted = candidates.len() as u32;
12668 Ok((candidates, stats, edge_confidence_by_cursor))
12669}
12670
12671/// Slice 30 (G3) — the ~1M cap on a single op-store read-back page. The public
12672/// `read.collection` / `read.mutations` LIMIT is `min(caller_limit, this)`, so
12673/// no API path can issue an unbounded SELECT. Cursor/limit hardening under a
12674/// genuine ~1M-row append-only log is reserved-gap Slice 32.
12675const READ_COLLECTION_MAX_LIMIT: usize = 1_000_000;
12676
12677/// Slice 30 (G2) — active-only point lookup by `logical_id` on the DEFERRED
12678/// reader tx (mirrors `read_search_in_tx`'s snapshot-stable BEGIN DEFERRED). One
12679/// returned slot per requested id, in REQUEST ORDER; `None` where no ACTIVE row
12680/// (`superseded_at IS NULL`) carries that id. Mirrors the `:4170` canonical
12681/// projection columns + `logical_id`; superseded versions are never returned.
12682fn read_get_by_id_in_tx(
12683 reader: &mut Connection,
12684 logical_ids: &[String],
12685 view: &ReadView,
12686) -> rusqlite::Result<Vec<Option<NodeRecord>>> {
12687 if logical_ids.is_empty() {
12688 return Ok(Vec::new());
12689 }
12690 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12691 // De-duplicate the requested ids for the IN(...) probe, then re-expand into
12692 // request order (a repeated id echoes the same active row).
12693 let mut found: HashMap<String, NodeRecord> = HashMap::new();
12694 {
12695 let unique: Vec<&String> = {
12696 let mut seen = std::collections::HashSet::new();
12697 logical_ids.iter().filter(|id| seen.insert((*id).clone())).collect()
12698 };
12699 let placeholders = std::iter::repeat_n("?", unique.len()).collect::<Vec<_>>().join(", ");
12700 // The `?` placeholders above auto-number 1..=unique.len(), so the
12701 // validity instant takes the next positional slot.
12702 let now_idx = unique.len() + 1;
12703 let node_sql = view.node_sql("canonical_nodes", now_idx);
12704 // R-20-RV: with `include_superseded` a logical_id can match several
12705 // rows. `ORDER BY write_cursor` + last-write-wins into `found` resolves
12706 // the slot DETERMINISTICALLY to the most recent version, rather than
12707 // leaving it at the mercy of scan order.
12708 let sql = format!(
12709 "SELECT logical_id, kind, body, write_cursor
12710 FROM canonical_nodes
12711 WHERE logical_id IN ({placeholders}){node_sql}
12712 ORDER BY write_cursor"
12713 );
12714 let mut statement = tx.prepare(&sql)?;
12715 let mut binds: Vec<rusqlite::types::Value> =
12716 unique.iter().map(|s| rusqlite::types::Value::Text((*s).clone())).collect();
12717 if let Some(now) = view.now_param() {
12718 binds.push(rusqlite::types::Value::Integer(now));
12719 }
12720 let rows = statement.query_map(rusqlite::params_from_iter(binds.iter()), |row| {
12721 let logical_id: String = row.get(0)?;
12722 Ok(NodeRecord {
12723 logical_id,
12724 kind: row.get(1)?,
12725 body: row.get(2)?,
12726 write_cursor: row.get::<_, i64>(3)? as u64,
12727 })
12728 })?;
12729 for row in rows {
12730 let record = row?;
12731 found.insert(record.logical_id.clone(), record);
12732 }
12733 }
12734 // tx is read-only; dropping it rolls back the (empty) transaction.
12735 let out = logical_ids.iter().map(|id| found.get(id).cloned()).collect();
12736 Ok(out)
12737}
12738
12739/// Slice 30 (G3) — paginated op-store read-back over `operational_mutations` for
12740/// one `collection`, `ORDER BY id`, on the DEFERRED reader tx. The effective SQL
12741/// LIMIT is `min(limit, READ_COLLECTION_MAX_LIMIT)`; a caller `limit == 0`
12742/// returns an empty `Vec` without a SELECT. The after-id cursor (`id > ?`,
12743/// default 0) excludes the boundary row. The `_for_test` SELECTs
12744/// (`lib.rs` op-store probes) are a shape oracle only — this is a new statement.
12745///
12746/// Slice 33 (G3 / F4-READ) — hardened under a genuine large multi-collection log:
12747/// the SELECT rides the step-13 `operational_mutations(collection_name, id)`
12748/// index (`SEARCH … USING INDEX …(collection_name=? AND id>?)`), so the per-page
12749/// cost is O(page) — the leading `collection_name` equality fixes the prefix and
12750/// the trailing `id` serves both the cursor range and `ORDER BY id` with no temp
12751/// B-tree. The cursor is normalized with `.max(0)` so a negative `after_id` is
12752/// explicitly clamped to the start of the log (ids are ≥ 1) and is never confused
12753/// with a row id; `after_id` past the end and unknown collections yield empty
12754/// pages.
12755fn read_collection_in_tx(
12756 reader: &mut Connection,
12757 collection: &str,
12758 after_id: Option<i64>,
12759 limit: usize,
12760) -> rusqlite::Result<Vec<OpStoreRow>> {
12761 if limit == 0 {
12762 return Ok(Vec::new());
12763 }
12764 let clamped = limit.min(READ_COLLECTION_MAX_LIMIT) as i64;
12765 // Normalize the cursor: a negative after_id is clamped to the start of the
12766 // log. `operational_mutations.id` is autoincrement (≥ 1), so `id > 0` is the
12767 // full log; clamping removes the "is a negative cursor a sentinel or a row
12768 // id?" ambiguity without changing happy-path semantics.
12769 let after = after_id.unwrap_or(0).max(0);
12770 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12771 let mut statement = tx.prepare(
12772 "SELECT id, collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
12773 FROM operational_mutations
12774 WHERE collection_name = ?1 AND id > ?2
12775 ORDER BY id
12776 LIMIT ?3",
12777 )?;
12778 let rows = statement.query_map(params![collection, after, clamped], |row| {
12779 Ok(OpStoreRow {
12780 id: row.get(0)?,
12781 collection: row.get(1)?,
12782 record_key: row.get(2)?,
12783 op_kind: row.get(3)?,
12784 payload: row.get(4)?,
12785 schema_id: row.get(5)?,
12786 write_cursor: row.get::<_, i64>(6)? as u64,
12787 })
12788 })?;
12789 let mut out = Vec::new();
12790 for row in rows {
12791 out.push(row?);
12792 }
12793 Ok(out)
12794}
12795
12796/// Slice 35 (G4) — execute `read.list` inside a DEFERRED reader transaction.
12797///
12798/// Builds parameterized SQL: `kind = ?1 AND superseded_at IS NULL [AND
12799/// json_extract(body, '$.field') <op> ?N ...]` — injection-safe because:
12800/// (a) `kind` is `?1` (bound parameter);
12801/// (b) each predicate value is a bound `?N` parameter;
12802/// (c) the json_extract path is the ALLOWLIST ENTRY (a server-side constant
12803/// validated at `Predicate` construction time), never the raw caller string;
12804/// (d) `ComparisonOp` compiles to a server-side literal operator string from a
12805/// closed enum, not a caller-supplied string.
12806fn read_list_in_tx(
12807 reader: &mut Connection,
12808 kind: &str,
12809 predicates: &[Predicate],
12810 limit: usize,
12811 view: &ReadView,
12812) -> rusqlite::Result<Vec<NodeRecord>> {
12813 if limit == 0 {
12814 return Ok(Vec::new());
12815 }
12816 // Build the SQL WHERE clauses for each predicate.
12817 // Parameters: ?1 = kind; ?2..?N = predicate values; limit is inlined.
12818 // `logical_id IS NOT NULL` is a SQL-level predicate so that LIMIT counts
12819 // only rows that can be represented as NodeRecord (which requires a non-null
12820 // String logical_id). Anonymous nodes (PreparedWrite::Node { logical_id: None })
12821 // cannot be included in NodeRecord results and are excluded before LIMIT.
12822 // When predicates are present we add `json_valid(body)` so rows with
12823 // non-JSON bodies are skipped rather than causing a `malformed JSON` error.
12824 let json_valid_guard = if predicates.is_empty() { "" } else { " AND json_valid(body)" };
12825 // R-20-RV/R-20-NV: the view's predicates replace the previously hard-coded
12826 // existence pair. The validity instant takes the positional slot AFTER the
12827 // predicate binds (?1 = kind, ?2..=?(1+n) = predicate values), so it is
12828 // `?{predicates.len() + 2}`. Positional `?N` is order-independent in SQLite,
12829 // so emitting it here — textually before the predicate clauses appended
12830 // below — is safe and unambiguous.
12831 let now_idx = predicates.len() + 2;
12832 let node_sql = view.node_sql("canonical_nodes", now_idx);
12833 let mut sql = format!(
12834 "SELECT logical_id, kind, body, write_cursor \
12835 FROM canonical_nodes \
12836 WHERE kind = ?1{node_sql} \
12837 AND logical_id IS NOT NULL{json_valid_guard}"
12838 );
12839
12840 // Predicate params start at ?2.
12841 for (i, pred) in predicates.iter().enumerate() {
12842 let param_idx = i + 2; // ?1 is kind
12843 sql.push_str(" AND ");
12844 sql.push_str(&pred.to_sql_clause(param_idx));
12845 }
12846 sql.push_str(&format!(" LIMIT {limit}"));
12847
12848 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12849 let mut statement = tx.prepare(&sql)?;
12850
12851 // Bind all parameters: [kind, predicate_values...]
12852 let mut params: Vec<rusqlite::types::Value> = Vec::with_capacity(2 + predicates.len());
12853 params.push(rusqlite::types::Value::Text(kind.to_string()));
12854 for pred in predicates {
12855 params.push(pred.bind_value());
12856 }
12857 // Lands at index `now_idx` (= predicates.len() + 2), matching `?{now_idx}`
12858 // emitted by `ReadView::validity_sql`. Omitted entirely when the view
12859 // relaxes validity, in which case no `?{now_idx}` was emitted either.
12860 if let Some(now) = view.now_param() {
12861 params.push(rusqlite::types::Value::Integer(now));
12862 }
12863
12864 let rows = statement.query_map(rusqlite::params_from_iter(params.iter()), |row| {
12865 Ok(NodeRecord {
12866 logical_id: row.get(0)?,
12867 kind: row.get(1)?,
12868 body: row.get(2)?,
12869 write_cursor: row.get::<_, i64>(3)? as u64,
12870 })
12871 })?;
12872
12873 let mut out = Vec::new();
12874 for row in rows {
12875 out.push(row?);
12876 }
12877 Ok(out)
12878}
12879
12880// ---------------------------------------------------------------------------
12881// Slice 20 (G5/G6) — BFS graph-traversal helpers
12882// ---------------------------------------------------------------------------
12883
12884/// Hard cap on the number of nodes returned by a single `graph_neighbors` call.
12885/// Ported from v0.5.6 `MAX_TRAVERSAL_DEPTH` (applied as a LIMIT on the CTE and
12886/// the final SELECT). Defense-in-depth against unbounded traversal.
12887const GRAPH_NEIGHBORS_HARD_CAP: usize = 50;
12888
12889/// Build the BFS CTE SQL for the given `direction`, under `view`.
12890///
12891/// Parameters (positional):
12892/// `?1` — root `logical_id`
12893/// `?2` — max_depth (`u32`, SDK-facing depth ceiling ≤ 3)
12894/// `?3` — R-20-NV node-validity instant (`:now` seam), emitted at EVERY node
12895/// position; omitted entirely when the view relaxes validity.
12896///
12897/// `LIMIT {GRAPH_NEIGHBORS_HARD_CAP}` appears on both the CTE and the final SELECT.
12898///
12899/// # Why one template instead of three
12900///
12901/// The three directions previously carried three hand-maintained copies of the
12902/// CTE, each repeating the node predicate at THREE positions (anchor, recursive
12903/// join, final projection) — nine hand-written copies in total. R-20-RV requires
12904/// a relax flag to apply at every one of them, and nine copies is exactly the
12905/// shape in which "it works on `Outgoing` but silently not on `Both`" hides. The
12906/// directions are folded into ONE template parameterised by the two things that
12907/// actually differ (the edge join condition and the traversed-to expression), so
12908/// `view.node_sql(...)` is written once per position and applying to all three
12909/// directions is structural rather than a thing to remember.
12910///
12911/// **TC-33: the `canonical_edges` temporal filter is now parameterised too.** It
12912/// was `datetime(e.t_invalid) > datetime('now')` inline, deliberately left alone
12913/// while edge validity was ISO-8601 TEXT. Edge timestamps are INTEGER epoch
12914/// seconds now, so the predicate is generated by [`edge_validity_sql`] and binds
12915/// the frozen instant at `?4` — no inline clock remains in this template.
12916fn build_bfs_sql(direction: TraversalDirection, view: &ReadView) -> String {
12917 let cap = GRAPH_NEIGHBORS_HARD_CAP;
12918 // cte_cap: the SQLite CTE LIMIT counts path-rows, not distinct nodes. In a
12919 // multigraph (multiple parallel edges between the same pair of nodes), the CTE
12920 // can contain duplicate-target rows before the final SELECT DISTINCT. A cap of
12921 // cap+1 would be exhausted by ~50 parallel edges to the same node, preventing
12922 // other neighbors from being discovered. Use cap*cap as a generous safety
12923 // ceiling that still bounds CTE growth for any realistic graph while allowing
12924 // the final SELECT LIMIT cap to be the authoritative distinct-node cap.
12925 let cte_cap = cap * cap;
12926 // Cycle guard uses char(30) (ASCII Record Separator, 0x1E) as delimiter instead
12927 // of comma, so logical_ids containing commas are handled correctly. char(30) is
12928 // a non-printable control character that callers cannot place in logical_id values
12929 // via normal text input.
12930 //
12931 // `?3` is the node-validity instant. Positional (not named), so the repeated
12932 // occurrences across the three node positions all bind the SAME value once.
12933 const NOW_IDX: usize = 3;
12934 // TC-33: `?4` is the EDGE-validity instant, bound separately because the node
12935 // instant is `Option` (relaxed by `include_out_of_window`) while edge recency
12936 // is always applied.
12937 const EDGE_NOW_IDX: usize = 4;
12938
12939 // The ONLY two things that differ between directions.
12940 let (edge_join, target_expr) = match direction {
12941 TraversalDirection::Outgoing => ("e.from_id = t.logical_id", "e.to_id"),
12942 TraversalDirection::Incoming => ("e.to_id = t.logical_id", "e.from_id"),
12943 TraversalDirection::Both => (
12944 "(e.from_id = t.logical_id OR e.to_id = t.logical_id)",
12945 "CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END",
12946 ),
12947 };
12948
12949 // Position 1 (anchor), position 2 (recursive join), position 3 (final
12950 // projection) — the view is applied at all three, for every direction.
12951 let anchor_node = view.node_sql("n", NOW_IDX);
12952 let next_node = view.node_sql("next_n", NOW_IDX);
12953 let projection_node = view.node_sql("n", NOW_IDX);
12954 let edge_valid = edge_validity_sql("e", EDGE_NOW_IDX);
12955
12956 format!(
12957 "WITH RECURSIVE
12958 traversal(logical_id, depth, visited) AS (
12959 SELECT n.logical_id, 0, char(30) || n.logical_id || char(30)
12960 FROM canonical_nodes n
12961 WHERE n.logical_id = ?1{anchor_node}
12962 UNION ALL
12963 SELECT {target_expr}, t.depth + 1, t.visited || {target_expr} || char(30)
12964 FROM traversal t
12965 JOIN canonical_edges e ON {edge_join}
12966 JOIN canonical_nodes next_n ON next_n.logical_id = {target_expr}{next_node}
12967 WHERE t.depth < ?2
12968 AND e.superseded_at IS NULL{edge_valid}
12969 AND instr(t.visited, char(30) || {target_expr} || char(30)) = 0
12970 LIMIT {cte_cap}
12971 )
12972SELECT DISTINCT n.logical_id, n.kind, n.body, n.write_cursor
12973FROM traversal tr
12974JOIN canonical_nodes n ON n.logical_id = tr.logical_id
12975WHERE tr.logical_id != ?1{projection_node}
12976LIMIT {cap}"
12977 )
12978}
12979
12980/// Build the BFS CTE SQL for `search_expand` — identical to `build_bfs_sql`
12981/// but the final SELECT uses `GROUP BY` + `MIN(tr.depth)` so that each
12982/// expanded node carries its actual BFS distance from the root.
12983///
12984/// Returns 5 columns: logical_id, kind, body, write_cursor, min_depth.
12985fn build_bfs_with_depth_sql() -> String {
12986 let cap = GRAPH_NEIGHBORS_HARD_CAP;
12987 let cte_cap = cap * cap; // same multigraph-safe headroom as build_bfs_sql
12988 // TC-33: `?1` anchor, `?2` depth ⇒ the edge `:now` binds at `?3`. This is a
12989 // SECOND, separate BFS template — the edge-validity predicate has to be
12990 // re-grounded here too or `search_expand` silently keeps the old semantics.
12991 let edge_valid = edge_validity_sql("e", 3);
12992 format!(
12993 "WITH RECURSIVE
12994 traversal(logical_id, depth, visited) AS (
12995 SELECT n.logical_id, 0, char(30) || n.logical_id || char(30)
12996 FROM canonical_nodes n
12997 WHERE n.logical_id = ?1 AND n.superseded_at IS NULL AND n.state = 'active'
12998 UNION ALL
12999 SELECT
13000 CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END,
13001 t.depth + 1,
13002 t.visited || CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END || char(30)
13003 FROM traversal t
13004 JOIN canonical_edges e ON (e.from_id = t.logical_id OR e.to_id = t.logical_id)
13005 JOIN canonical_nodes next_n
13006 ON next_n.logical_id = CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END
13007 AND next_n.superseded_at IS NULL AND next_n.state = 'active'
13008 WHERE t.depth < ?2
13009 AND e.superseded_at IS NULL{edge_valid}
13010 AND instr(t.visited,
13011 char(30) || CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END || char(30)) = 0
13012 LIMIT {cte_cap}
13013 )
13014SELECT n.logical_id, n.kind, n.body, n.write_cursor, MIN(tr.depth) AS min_depth
13015FROM traversal tr
13016JOIN canonical_nodes n ON n.logical_id = tr.logical_id
13017WHERE n.superseded_at IS NULL AND n.state = 'active'
13018 AND tr.logical_id != ?1
13019GROUP BY n.logical_id
13020LIMIT {cap}"
13021 )
13022}
13023
13024/// 0.8.20 Slice 10b (R-20-NV) — the validity-boundary hook, on the DEFERRED
13025/// reader transaction.
13026///
13027/// Reports nodes whose `valid_from` and/or `valid_until` falls in the half-open
13028/// interval `(since, upper]`. Both bounds are BOUND parameters (`?1`, `?2`) —
13029/// the node-validity path never inlines `datetime('now')`.
13030///
13031/// The view's EXISTENCE conjunct applies (default: current + active rows only);
13032/// its VALIDITY conjunct deliberately does not, because the question is "did
13033/// this window cross a boundary", not "is this row valid now".
13034fn crossed_boundary_since_in_tx(
13035 reader: &mut Connection,
13036 since: i64,
13037 view: &ReadView,
13038) -> rusqlite::Result<Vec<BoundaryCrossing>> {
13039 // `now_param()` is None exactly when the view relaxes validity, which here
13040 // means "no upper bound on the interval".
13041 let upper = view.now_param().unwrap_or(i64::MAX);
13042 let existence = view.existence_sql("canonical_nodes");
13043 // `1 = 1` keeps the leading ` AND ` of `existence_sql` well-formed even when
13044 // every existence flag is relaxed and the conjunct is empty.
13045 let sql = format!(
13046 "SELECT logical_id, kind, body, write_cursor, valid_from, valid_until \
13047 FROM canonical_nodes \
13048 WHERE 1 = 1{existence} \
13049 AND logical_id IS NOT NULL \
13050 AND ( (valid_from IS NOT NULL AND valid_from > ?1 AND valid_from <= ?2) \
13051 OR (valid_until IS NOT NULL AND valid_until > ?1 AND valid_until <= ?2) ) \
13052 ORDER BY write_cursor"
13053 );
13054 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
13055 let mut statement = tx.prepare(&sql)?;
13056 let rows = statement.query_map(params![since, upper], |row| {
13057 let valid_from: Option<i64> = row.get(4)?;
13058 let valid_until: Option<i64> = row.get(5)?;
13059 Ok(BoundaryCrossing {
13060 node: NodeRecord {
13061 logical_id: row.get(0)?,
13062 kind: row.get(1)?,
13063 body: row.get(2)?,
13064 write_cursor: row.get::<_, i64>(3)? as u64,
13065 },
13066 became_valid_at: valid_from.filter(|t| *t > since && *t <= upper),
13067 became_invalid_at: valid_until.filter(|t| *t > since && *t <= upper),
13068 })
13069 })?;
13070 let mut out = Vec::new();
13071 for row in rows {
13072 out.push(row?);
13073 }
13074 Ok(out)
13075}
13076
13077/// Slice 20 (G5) — execute a bounded BFS on the DEFERRED reader transaction.
13078/// Called inside the reader worker loop.
13079fn graph_neighbors_in_tx(
13080 reader: &mut Connection,
13081 root_logical_id: &str,
13082 depth: u32,
13083 direction: TraversalDirection,
13084 view: &ReadView,
13085) -> rusqlite::Result<Vec<NodeRecord>> {
13086 let sql = build_bfs_sql(direction, view);
13087 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
13088 let depth_i64 = depth as i64;
13089 let mut statement = tx.prepare(&sql)?;
13090 // ?1 root, ?2 depth, ?3 = the NODE validity instant, ?4 = the EDGE validity
13091 // instant (TC-33).
13092 //
13093 // ?3 is bound UNCONDITIONALLY even when the view relaxes node validity and
13094 // `build_bfs_sql` emitted no `?3`: the template still references ?4, so
13095 // SQLite's parameter count is 4 and the positions must not shift. Binding an
13096 // index the SQL never reads is harmless; letting ?4's value slide into ?3
13097 // would silently compare edge times against a placeholder.
13098 let frozen = (*view).freeze();
13099 let binds: Vec<rusqlite::types::Value> = vec![
13100 rusqlite::types::Value::Text(root_logical_id.to_string()),
13101 rusqlite::types::Value::Integer(depth_i64),
13102 rusqlite::types::Value::Integer(frozen.now_param().unwrap_or_default()),
13103 rusqlite::types::Value::Integer(frozen.edge_now()),
13104 ];
13105 let rows = statement.query_map(rusqlite::params_from_iter(binds.iter()), |row| {
13106 Ok(NodeRecord {
13107 logical_id: row.get(0)?,
13108 kind: row.get(1)?,
13109 body: row.get(2)?,
13110 write_cursor: row.get::<_, i64>(3)? as u64,
13111 })
13112 })?;
13113 let mut out = Vec::new();
13114 for row in rows {
13115 out.push(row?);
13116 }
13117 Ok(out)
13118}
13119
13120/// Slice 20 (G6) — resolve search hit `write_cursor`s to `logical_id`s, run
13121/// BFS for each root, and merge into a [`SearchExpandResult`]. Called inside
13122/// the reader worker loop on the DEFERRED reader transaction.
13123fn search_expand_in_tx(
13124 reader: &mut Connection,
13125 search_hits: &[SearchHit],
13126 depth: u32,
13127) -> rusqlite::Result<SearchExpandResult> {
13128 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
13129
13130 // Step 1: resolve write_cursor → logical_id for each search hit.
13131 // Possible outcomes per hit:
13132 // - None: no matching write_cursor in canonical_nodes (superseded) → drop.
13133 // - Some(""): row exists but logical_id IS NULL (anonymous node) or TextEdge hit
13134 // → keep as valid search result, skip BFS expansion (empty sentinel).
13135 // - Some(lid): active named node → keep; use as BFS root.
13136 let mut hit_logical_ids: Vec<Option<String>> = Vec::with_capacity(search_hits.len());
13137 {
13138 let mut node_stmt = tx.prepare(
13139 "SELECT logical_id FROM canonical_nodes
13140 WHERE write_cursor = ?1 AND superseded_at IS NULL AND state = 'active'
13141 LIMIT 1",
13142 )?;
13143 let mut edge_stmt = tx.prepare(
13144 "SELECT 1 FROM canonical_edges
13145 WHERE write_cursor = ?1 AND superseded_at IS NULL
13146 LIMIT 1",
13147 )?;
13148 for hit in search_hits {
13149 if hit.branch == SoftFallbackBranch::TextEdge {
13150 // Edge-body hit: verify the edge row is still active in THIS snapshot.
13151 // Stale edge hits (superseded between search and expansion) are dropped.
13152 let cursor_i64 = hit.write_cursor as i64;
13153 let active: Option<i32> =
13154 edge_stmt.query_row([cursor_i64], |row| row.get(0)).optional()?;
13155 if active.is_some() {
13156 hit_logical_ids.push(Some(String::new())); // sentinel: keep hit, skip BFS
13157 } else {
13158 hit_logical_ids.push(None); // superseded edge: drop
13159 }
13160 } else {
13161 let cursor_i64 = hit.write_cursor as i64;
13162 // Returns Option<Option<String>>:
13163 // None → no row → superseded
13164 // Some(None) → row with NULL logical_id → anonymous node
13165 // Some(Some(s)) → active named node
13166 let resolved = node_stmt
13167 .query_row([cursor_i64], |row| row.get::<_, Option<String>>(0))
13168 .optional()?;
13169 match resolved {
13170 None => hit_logical_ids.push(None), // superseded: drop
13171 Some(None) => hit_logical_ids.push(Some(String::new())), // anon: keep, skip BFS
13172 Some(Some(lid)) => hit_logical_ids.push(Some(lid)), // named: keep + BFS root
13173 }
13174 }
13175 }
13176 }
13177
13178 // Build a set of logical_ids present in the search hits (for deduplication).
13179 // Empty-string sentinels (TextEdge hits) are excluded — they are not real node ids.
13180 let hit_id_set: std::collections::HashSet<String> =
13181 hit_logical_ids.iter().filter_map(|id| id.clone()).filter(|s| !s.is_empty()).collect();
13182
13183 // Step 2: for each root logical_id, run the BFS and collect expanded nodes.
13184 // A node already in `hit_id_set` is NOT added to `expanded`.
13185 // Use the depth-aware variant so each node reports its actual BFS distance.
13186 let bfs_sql = build_bfs_with_depth_sql();
13187 let depth_i64 = depth as i64;
13188 // nearest_hop: for each expanded logical_id track the minimum hop count
13189 // seen across ALL search-hit roots. A node reachable from multiple roots
13190 // at different depths must report the shortest distance (nearest root).
13191 let mut nearest_hop: std::collections::HashMap<String, (NodeRecord, u32)> =
13192 std::collections::HashMap::new();
13193
13194 if depth > 0 {
13195 let mut bfs_stmt = tx.prepare(&bfs_sql)?;
13196 // TC-33: `?3` is the edge-validity instant. `search_expand` has no
13197 // `ReadView` in scope, so it uses the default (strict) semantics —
13198 // resolved ONCE here, not per root, so every root in one call agrees.
13199 let edge_now = current_epoch_seconds();
13200 for root_id in hit_logical_ids.iter().flatten().filter(|s| !s.is_empty()) {
13201 let neighbor_rows =
13202 bfs_stmt.query_map(params![root_id, depth_i64, edge_now], |row| {
13203 let node = NodeRecord {
13204 logical_id: row.get(0)?,
13205 kind: row.get(1)?,
13206 body: row.get(2)?,
13207 write_cursor: row.get::<_, i64>(3)? as u64,
13208 };
13209 let min_depth: i64 = row.get(4)?;
13210 Ok((node, min_depth as u32))
13211 })?;
13212 for row_result in neighbor_rows {
13213 let (node, hop_count) = row_result?;
13214 if hit_id_set.contains(&node.logical_id) {
13215 // Already a search hit — skip (search score takes priority).
13216 continue;
13217 }
13218 nearest_hop
13219 .entry(node.logical_id.clone())
13220 .and_modify(|(_, prev_hop)| {
13221 if hop_count < *prev_hop {
13222 *prev_hop = hop_count;
13223 }
13224 })
13225 .or_insert((node, hop_count));
13226 }
13227 }
13228 }
13229
13230 // Materialize expanded in insertion order (deterministic for tests).
13231 let mut expanded: Vec<(NodeRecord, u32)> = nearest_hop.into_values().collect();
13232 expanded.sort_by(|(a, _), (b, _)| a.logical_id.cmp(&b.logical_id));
13233
13234 // Filter search_hits to only include those whose write_cursor resolved to an
13235 // active logical_id in THIS snapshot. Hits that were superseded between the
13236 // search phase and the expansion phase (the two-snapshot window) are dropped
13237 // rather than returned with stale data.
13238 let resolved_hits: Vec<SearchHit> = search_hits
13239 .iter()
13240 .zip(hit_logical_ids.iter())
13241 .filter_map(|(hit, lid)| lid.as_ref().map(|_| hit.clone()))
13242 .collect();
13243
13244 // Build `all_logical_ids` = resolved search-hit logical_ids + expanded node ids.
13245 // Empty-string sentinels (TextEdge hits) are excluded — they are not real node ids.
13246 let mut all_logical_ids: Vec<String> =
13247 hit_logical_ids.into_iter().flatten().filter(|s| !s.is_empty()).collect();
13248 for (node, _) in &expanded {
13249 if !all_logical_ids.contains(&node.logical_id) {
13250 all_logical_ids.push(node.logical_id.clone());
13251 }
13252 }
13253
13254 Ok(SearchExpandResult { search_hits: resolved_hits, expanded, all_logical_ids })
13255}
13256
13257/// Slice 20 test seam — run `EXPLAIN QUERY PLAN` on the BFS CTE SQL and return
13258/// the plan `detail` column (column index 3) for each row. Used by
13259/// `explain_plan_uses_indexes` to assert index usage.
13260fn explain_graph_neighbors_in_tx(
13261 reader: &mut Connection,
13262 root_logical_id: &str,
13263 depth: u32,
13264 direction: TraversalDirection,
13265) -> rusqlite::Result<Vec<String>> {
13266 // The EXPLAIN index-usage gate measures the DEFAULT (strict) read path.
13267 let view = ReadView::default();
13268 let bfs_sql = build_bfs_sql(direction, &view);
13269 let explain_sql = format!("EXPLAIN QUERY PLAN {bfs_sql}");
13270 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
13271 let depth_i64 = depth as i64;
13272 let mut statement = tx.prepare(&explain_sql)?;
13273 // EXPLAIN QUERY PLAN returns rows: (id, parent, notused, detail).
13274 // We collect the `detail` column (index 3).
13275 // The strict view emits `?3` (the node-validity instant) at every node
13276 // position; TC-33 adds `?4`, the edge-validity instant.
13277 let frozen = view.freeze();
13278 let now = frozen.now_param().expect("the strict view always binds a validity instant");
13279 let rows = statement
13280 .query_map(params![root_logical_id, depth_i64, now, frozen.edge_now()], |row| {
13281 row.get::<_, String>(3)
13282 })?;
13283 let mut out = Vec::new();
13284 for row in rows {
13285 out.push(row?);
13286 }
13287 Ok(out)
13288}
13289
13290fn projection_dispatcher_loop(shared: Arc<ProjectionRuntimeShared>) {
13291 let connection = match open_runtime_connection(&shared.path) {
13292 Ok(connection) => connection,
13293 Err(_) => return,
13294 };
13295 // 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — read ONCE:
13296 // `ProjectionRuntimeShared::embedder` is fixed for the session's lifetime.
13297 let dense_arm_live = shared.embedder.is_some();
13298 loop {
13299 let in_flight = {
13300 let mut state = match shared.state.lock() {
13301 Ok(state) => state,
13302 Err(_) => return,
13303 };
13304 while !state.stopping
13305 && (!state.pending_scan
13306 || state.frozen
13307 || state.active_jobs + state.queued_jobs >= PROJECTION_INFLIGHT_LIMIT)
13308 {
13309 state = match shared.state_cvar.wait(state) {
13310 Ok(state) => state,
13311 Err(_) => return,
13312 };
13313 }
13314 if state.stopping {
13315 return;
13316 }
13317 state.pending_scan = false;
13318 state.in_flight.clone()
13319 };
13320
13321 // Fetch up to the in-flight budget in one SQL roundtrip and
13322 // enqueue them as a batch — previously this loop fetched ONE job
13323 // per cycle, which capped projection throughput at one row per
13324 // scanner/worker handshake regardless of how much work was queued
13325 // in canonical_nodes.
13326 let budget = {
13327 let state = match shared.state.lock() {
13328 Ok(state) => state,
13329 Err(_) => return,
13330 };
13331 PROJECTION_INFLIGHT_LIMIT.saturating_sub(state.active_jobs + state.queued_jobs)
13332 };
13333 let fetch_cap = budget.clamp(1, PROJECTION_SCAN_FETCH);
13334 // With no usable dense runtime a NODE job can only come back DEFERRED
13335 // (`ProjectionOutcome::Deferred`),
13336 // which by design records no terminal, so dispatching one would re-fetch
13337 // the SAME cursor forever. fix-5 (codex §9 round 4 [P1]) moved that
13338 // exclusion INSIDE the scan, so the `LIMIT` applies to the already-filtered
13339 // set and a pending EDGE body behind a full window of node rows is still
13340 // reachable. See `next_pending_projection_jobs`.
13341 let fetched =
13342 next_pending_projection_jobs(&connection, &in_flight, fetch_cap, dense_arm_live);
13343 // Cheap assertion only — it can never DROP a job, which is precisely what
13344 // the fix-4 shape did.
13345 debug_assert!(
13346 fetched
13347 .as_ref()
13348 .map(|jobs| dense_arm_live || jobs.iter().all(|job| job.kind == EDGE_FACT_KIND))
13349 .unwrap_or(true),
13350 "no-embedder scan returned a NODE job: the exclusion must be in the scan's SQL"
13351 );
13352 match fetched {
13353 Ok(jobs) if !jobs.is_empty() => {
13354 if let Ok(mut state) = shared.state.lock() {
13355 state.queued_jobs = state.queued_jobs.saturating_add(jobs.len());
13356 for job in &jobs {
13357 state.in_flight.insert(job.cursor);
13358 }
13359 state.pending_scan = true;
13360 shared.state_cvar.notify_all();
13361 }
13362 if let Ok(mut queue) = shared.queue.lock() {
13363 for job in jobs {
13364 queue.push_back(job);
13365 }
13366 shared.queue_cvar.notify_all();
13367 }
13368 }
13369 Ok(_) => {}
13370 Err(_) => {
13371 if let Ok(mut state) = shared.state.lock() {
13372 state.pending_scan = false;
13373 shared.state_cvar.notify_all();
13374 }
13375 }
13376 }
13377 }
13378}
13379
13380fn projection_worker_loop(shared: Arc<ProjectionRuntimeShared>) {
13381 let mut connection = match open_runtime_connection(&shared.path) {
13382 Ok(connection) => connection,
13383 Err(_) => return,
13384 };
13385 if ensure_vector_partition(&mut connection, shared.embedder_identity.dimension).is_err() {
13386 return;
13387 }
13388 loop {
13389 let jobs = {
13390 let mut queue = match shared.queue.lock() {
13391 Ok(queue) => queue,
13392 Err(_) => return,
13393 };
13394 loop {
13395 let stopping = shared.state.lock().map(|state| state.stopping).unwrap_or(true);
13396 if stopping && queue.is_empty() {
13397 return;
13398 }
13399 if let Some(job) = queue.pop_front() {
13400 let mut jobs = vec![job];
13401 while jobs.len() < PROJECTION_COMMIT_BATCH {
13402 let Some(job) = queue.pop_front() else {
13403 break;
13404 };
13405 jobs.push(job);
13406 }
13407 if let Ok(mut state) = shared.state.lock() {
13408 state.queued_jobs = state.queued_jobs.saturating_sub(jobs.len());
13409 state.active_jobs = state.active_jobs.saturating_add(jobs.len());
13410 shared.state_cvar.notify_all();
13411 }
13412 break jobs;
13413 }
13414 queue = match shared.queue_cvar.wait(queue) {
13415 Ok(queue) => queue,
13416 Err(_) => return,
13417 };
13418 }
13419 };
13420
13421 // EU-5f — isolate worker faults. A panic inside `embed()` (or the
13422 // commit) must not skip the state cleanup below, or `active_jobs`
13423 // would stay elevated forever and `wait_for_idle` / `drain` would
13424 // wedge into `EngineError::Scheduler` (Finding A). Mirrors the
13425 // reader pool's `LiveGuard` panic-safety. The local commit tx rolls
13426 // back on unwind, leaving the connection clean for reuse.
13427 let commit_result = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
13428 run_projection_jobs(&shared, &mut connection, &jobs)
13429 })) {
13430 Ok(result) => result,
13431 Err(_) => commit_projection_panic_failures(&shared, &mut connection, &jobs),
13432 };
13433 if let Err(err) = commit_result {
13434 // Host subscribers are arbitrary application code. Their panic must
13435 // not bypass the mandatory state cleanup below, or the durable
13436 // pending row would stay stranded in `in_flight` forever.
13437 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
13438 report_projection_commit_failure(&shared, &err);
13439 }));
13440 #[cfg(debug_assertions)]
13441 if let Some((reported, release)) = shared
13442 .projection_commit_failure_pause
13443 .lock()
13444 .unwrap_or_else(|poisoned| poisoned.into_inner())
13445 .take()
13446 {
13447 reported.wait();
13448 release.wait();
13449 }
13450 }
13451
13452 if let Ok(mut state) = shared.state.lock() {
13453 state.active_jobs = state.active_jobs.saturating_sub(jobs.len());
13454 for job in &jobs {
13455 state.in_flight.remove(&job.cursor);
13456 }
13457 if !state.stopping {
13458 state.pending_scan = true;
13459 }
13460 shared.state_cvar.notify_all();
13461 }
13462 }
13463}
13464
13465enum ProjectionOutcome {
13466 /// `blob` is the un-centered f32 BLOB persisted to
13467 /// `vector_default.embedding`. `bin_blob` is the (possibly centered)
13468 /// f32 BLOB fed to `vec_quantize_binary` for the sign-bit column.
13469 /// EU-5a2: `bin_blob == blob` unless the identity is MC-required
13470 /// AND a mean_vec is pinned.
13471 Success {
13472 cursor: u64,
13473 kind: String,
13474 blob: Vec<u8>,
13475 bin_blob: Vec<u8>,
13476 },
13477 Failure {
13478 cursor: u64,
13479 failure_code: &'static str,
13480 },
13481 /// 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — the ENVIRONMENT could
13482 /// not serve this row, as distinct from the embed FAILING. Records nothing
13483 /// at all: no `projection_failures` audit row and, decisively, **no
13484 /// terminal**. The row stays `terminal IS NULL`, i.e. PENDING, so the next
13485 /// session that DOES have an embedder picks it up through the ordinary
13486 /// scheduler — no graft path and no recovery machinery.
13487 ///
13488 /// The only producer is the absent-embedder check at the top of
13489 /// [`run_projection_job`]. That condition cannot change within a session, so
13490 /// this can never become a retry loop for a genuinely-failing row.
13491 ///
13492 /// It carries NO cursor, deliberately: the other two variants carry one
13493 /// because they identify the row they are about to WRITE, and this variant
13494 /// writes nothing at all. A row's deferral is represented on disk by the
13495 /// continued ABSENCE of its `_fathomdb_projection_terminal` row, which is
13496 /// exactly the state it was already in.
13497 Deferred,
13498}
13499
13500fn run_projection_jobs(
13501 shared: &ProjectionRuntimeShared,
13502 connection: &mut Connection,
13503 jobs: &[ProjectionJob],
13504) -> rusqlite::Result<()> {
13505 let outcomes = embed_projection_batch(shared, jobs);
13506 commit_projection_outcomes(connection, &outcomes, shared)
13507}
13508
13509/// Embed a whole commit-batch in ONE `embed_batch` call (amortizes per-call
13510/// overhead; saturates the GPU — minutes -> seconds on a full-corpus embed). The
13511/// batched path is the fast HAPPY path only; on ANY anomaly — no embedder, breaker
13512/// open, single job, batch timeout/failure, row-count or per-row dimension mismatch
13513/// — it falls back to the proven per-job [`run_projection_job`], which carries the
13514/// full retry + circuit-breaker + failure-isolation semantics. So batching can only
13515/// make the common case faster, never change correctness. A panic inside the batch
13516/// embed resume-unwinds exactly like the per-embed watchdog, so the worker's
13517/// batch-level `catch_unwind` records `ProjectionPanic` as before.
13518///
13519/// Batching is **opt-in** via `FATHOMDB_PROJECTION_BATCH=1` (`true`/`on` accepted).
13520/// It reshapes the PR-9 per-embed watchdog/breaker accounting into per-batch, so the
13521/// conservative DEFAULT keeps the proven per-job path — leaving every PR-9 safety
13522/// test (watchdog, serialization, circuit breaker) behaving exactly as before. The
13523/// eval GPU-embed run sets the env to get the batched-forward speedup (minutes ->
13524/// seconds), where the per-job fallback below still backs every error case.
13525fn projection_batch_enabled() -> bool {
13526 matches!(
13527 std::env::var("FATHOMDB_PROJECTION_BATCH").ok().as_deref(),
13528 Some("1") | Some("true") | Some("on")
13529 )
13530}
13531
13532fn embed_projection_batch(
13533 shared: &ProjectionRuntimeShared,
13534 jobs: &[ProjectionJob],
13535) -> Vec<ProjectionOutcome> {
13536 let per_job = || jobs.iter().map(|job| run_projection_job(shared, job)).collect();
13537
13538 let Some(embedder) = shared.embedder.as_ref() else {
13539 return per_job();
13540 };
13541 if jobs.len() < 2
13542 || shared.embed_circuit_open.load(Ordering::Relaxed)
13543 || !projection_batch_enabled()
13544 {
13545 return per_job();
13546 }
13547
13548 let bodies: Vec<String> = jobs.iter().map(|job| job.body.clone()).collect();
13549 let embed_timeout = Duration::from_millis(shared.embed_timeout_ms.load(Ordering::Relaxed));
13550 // Each row keeps its single-embed budget worst-case (batch <= COMMIT_BATCH=16).
13551 let batch_timeout = embed_timeout.saturating_mul(jobs.len() as u32);
13552
13553 let vectors = {
13554 // PR-9 — serialize the embedder call (ONE batched call at a time) and make
13555 // the breaker decision with the guard held (race-free vs other workers),
13556 // mirroring `run_projection_job`. The batch thread counts as one live embed.
13557 let _embed_permit =
13558 shared.embed_serialize.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13559 let threshold = shared.embed_circuit_threshold.load(Ordering::Relaxed);
13560 if shared.embed_circuit_open.load(Ordering::Relaxed)
13561 || (threshold != 0 && shared.live_embed_threads.load(Ordering::Relaxed) >= threshold)
13562 {
13563 shared.embed_circuit_open.store(true, Ordering::Relaxed);
13564 return per_job();
13565 }
13566 match embed_batch_with_watchdog(
13567 embedder,
13568 &bodies,
13569 batch_timeout,
13570 &shared.live_embed_threads,
13571 ) {
13572 Ok(vectors) => vectors,
13573 // Timeout / failed / disconnected -> the per-job path retries each row
13574 // and engages the breaker exactly as before.
13575 Err(_) => return per_job(),
13576 }
13577 };
13578
13579 if vectors.len() != jobs.len() {
13580 return per_job();
13581 }
13582 let mut outcomes = Vec::with_capacity(jobs.len());
13583 for (job, vector) in jobs.iter().zip(vectors) {
13584 if u32::try_from(vector.len()).unwrap_or(u32::MAX) != shared.embedder_identity.dimension {
13585 // A row came back wrong-dim: fall back per-job for the whole batch
13586 // (rare; keeps the dimension-mismatch failure path identical).
13587 return per_job();
13588 }
13589 // Mirror run_projection_job's post-embed step exactly: persisted f32 BLOB is
13590 // un-centered; centering for the binary column is finalized in
13591 // commit_projection_outcomes (so bin_blob == blob here).
13592 let blob = encode_vector_blob(&vector);
13593 let bin_blob = blob.clone();
13594 outcomes.push(ProjectionOutcome::Success {
13595 cursor: job.cursor,
13596 kind: job.kind.clone(),
13597 blob,
13598 bin_blob,
13599 });
13600 }
13601 outcomes
13602}
13603
13604/// EU-5f — record every job in a panicked batch as a terminal projection
13605/// failure so the scheduler does not re-enqueue and re-panic on the same
13606/// cursors. Best-effort; runs after the worker caught a panic.
13607fn commit_projection_panic_failures(
13608 shared: &ProjectionRuntimeShared,
13609 connection: &mut Connection,
13610 jobs: &[ProjectionJob],
13611) -> rusqlite::Result<()> {
13612 let outcomes: Vec<ProjectionOutcome> = jobs
13613 .iter()
13614 .map(|job| ProjectionOutcome::Failure {
13615 cursor: job.cursor,
13616 failure_code: "ProjectionPanic",
13617 })
13618 .collect();
13619 commit_projection_outcomes(connection, &outcomes, shared)
13620}
13621
13622/// Route a background projection-commit failure through the engine's existing
13623/// host subscriber path. A SQLite error retains its stable SQLite code; a
13624/// rusqlite-layer error is an engine storage failure rather than a fabricated
13625/// SQLite diagnostic.
13626fn report_projection_commit_failure(shared: &ProjectionRuntimeShared, err: &rusqlite::Error) {
13627 let event = if let Some(code) = sqlite_extended_code_name(err) {
13628 lifecycle::Event {
13629 phase: lifecycle::Phase::Failed,
13630 source: lifecycle::EventSource::SqliteInternal,
13631 category: lifecycle::EventCategory::Error,
13632 code: Some(code),
13633 }
13634 } else {
13635 lifecycle::Event {
13636 phase: lifecycle::Phase::Failed,
13637 source: lifecycle::EventSource::Engine,
13638 category: lifecycle::EventCategory::Error,
13639 code: Some("StorageError"),
13640 }
13641 };
13642 shared.subscribers.dispatch(&event);
13643}
13644
13645/// PR-9 — ADR-0.6.0-embedder-protocol **Invariant 5**: run one `embed()`
13646/// under a per-call deadline. A hung (non-panicking) embed would otherwise
13647/// park a projection worker forever — the EU-5f `catch_unwind` only catches
13648/// *panics*. On timeout we return `RuntimeEmbedderError::Timeout`, which the
13649/// caller's existing retry/failure path already handles.
13650///
13651/// Cancellation follows Invariant 5 exactly: the embed runs on a detached
13652/// thread that is allowed to *finish + discard* its result — never aborted
13653/// mid-call (there is no safe thread-cancel API). The caller (the projection
13654/// worker) holds `embed_serialize` across this call, but DROPS it the moment
13655/// this returns — including on timeout — so the abandoned detached thread
13656/// runs lock-free and a hung embed can neither hold the serialization guard
13657/// forever nor deadlock the pool. (The commit happens later, outside this
13658/// call, under the separate `commit_gate`.)
13659///
13660/// Panic-transparent: if `embed()` panics, the panic payload is captured on
13661/// the watchdog thread and resumed on the worker thread, so the existing
13662/// batch-level `catch_unwind` records `ProjectionPanic` exactly as before.
13663///
13664/// `live` counts embed threads currently alive: incremented before the spawn
13665/// and decremented by the thread when it finishes (even if its result was
13666/// abandoned on timeout). The caller reads it to bound the abandoned-thread
13667/// leak via the circuit breaker.
13668fn embed_with_watchdog(
13669 embedder: &Arc<dyn Embedder>,
13670 body: &str,
13671 timeout: Duration,
13672 live: &Arc<AtomicU64>,
13673) -> Result<Vec<f32>, RuntimeEmbedderError> {
13674 let (tx, rx) = mpsc::channel();
13675 let embedder = Arc::clone(embedder);
13676 let body = body.to_string();
13677 // Count this embed thread as live before spawning; the thread decrements
13678 // when it finishes, whether or not its result is still wanted.
13679 live.fetch_add(1, Ordering::Relaxed);
13680 let live_thread = Arc::clone(live);
13681 thread::spawn(move || {
13682 let outcome =
13683 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| embedder.embed(&body)));
13684 // The receiver may already be gone (this call timed out): an async
13685 // channel send never blocks, and a send to a dropped receiver is a
13686 // no-op error we deliberately ignore — the result is discarded.
13687 let _ = tx.send(outcome);
13688 live_thread.fetch_sub(1, Ordering::Relaxed);
13689 });
13690 match rx.recv_timeout(timeout) {
13691 Ok(Ok(result)) => result,
13692 Ok(Err(panic_payload)) => std::panic::resume_unwind(panic_payload),
13693 Err(mpsc::RecvTimeoutError::Timeout) => Err(RuntimeEmbedderError::Timeout),
13694 // The watchdog thread dropped its sender without sending — should not
13695 // happen (panics are captured above), but treat as a failed embed so
13696 // the retry/failure path engages rather than silently succeeding.
13697 Err(mpsc::RecvTimeoutError::Disconnected) => Err(RuntimeEmbedderError::Failed {
13698 message: "embed watchdog thread dropped its result channel".to_string(),
13699 }),
13700 }
13701}
13702
13703/// Batch sibling of [`embed_with_watchdog`]: run ONE `embed_batch` on a detached,
13704/// timeout-bounded thread. Same Invariant-5 cancellation contract (the thread is
13705/// allowed to finish + discard on timeout, never aborted mid-call), same
13706/// panic-transparency (a panic is resumed on the caller so the worker's batch-level
13707/// `catch_unwind` records `ProjectionPanic`), same `live` accounting (one batch
13708/// thread = one live embed, bounding the abandoned-thread leak via the breaker).
13709fn embed_batch_with_watchdog(
13710 embedder: &Arc<dyn Embedder>,
13711 bodies: &[String],
13712 timeout: Duration,
13713 live: &Arc<AtomicU64>,
13714) -> Result<Vec<Vec<f32>>, RuntimeEmbedderError> {
13715 let (tx, rx) = mpsc::channel();
13716 let embedder = Arc::clone(embedder);
13717 let bodies = bodies.to_vec();
13718 live.fetch_add(1, Ordering::Relaxed);
13719 let live_thread = Arc::clone(live);
13720 thread::spawn(move || {
13721 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
13722 let refs: Vec<&str> = bodies.iter().map(String::as_str).collect();
13723 embedder.embed_batch(&refs)
13724 }));
13725 let _ = tx.send(outcome);
13726 live_thread.fetch_sub(1, Ordering::Relaxed);
13727 });
13728 match rx.recv_timeout(timeout) {
13729 Ok(Ok(result)) => result,
13730 Ok(Err(panic_payload)) => std::panic::resume_unwind(panic_payload),
13731 Err(mpsc::RecvTimeoutError::Timeout) => Err(RuntimeEmbedderError::Timeout),
13732 Err(mpsc::RecvTimeoutError::Disconnected) => Err(RuntimeEmbedderError::Failed {
13733 message: "embed batch watchdog thread dropped its result channel".to_string(),
13734 }),
13735 }
13736}
13737
13738fn run_projection_job(shared: &ProjectionRuntimeShared, job: &ProjectionJob) -> ProjectionOutcome {
13739 // 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — an ABSENT embedder is an
13740 // ENVIRONMENT fact, not an embed failure, and it CANNOT appear mid-job:
13741 // `ProjectionRuntimeShared::embedder` is fixed for the whole session. So for a
13742 // NODE row the whole retry ladder (0 + 1 + 4 + 16 s) can only reach a
13743 // conclusion that was already knowable at entry — answer it NOW, with the
13744 // NON-TERMINAL `Deferred`: no audit row, no terminal, and therefore a write
13745 // the next live-embedder session can still recover.
13746 //
13747 // That is codex's finding. With the kind already ENROLLED, the shipped
13748 // `'failed'` terminal was PERMANENT (nothing reopens one, and nothing should:
13749 // re-enqueueing one would loop a genuinely-failing row forever), so the write
13750 // was lost while `dense_readiness` read `ready`.
13751 //
13752 // `projection_dispatcher_loop` already declines to dispatch node jobs in a
13753 // no-embedder session — it must, or the still-pending row would be re-scanned
13754 // in a hot loop. This check is the LOCAL backstop for the same invariant:
13755 // whatever reaches a worker with no embedder must not be TERMINATED. Keeping
13756 // the invariant beside the code that would otherwise write the terminal is
13757 // what makes it hold if that dispatcher-side filter is ever loosened.
13758 //
13759 // EDGE rows deliberately fall THROUGH to the shipped path, ladder and all.
13760 // `'edge_fact'` is auto-registered by the edge write itself, UN-gated on the
13761 // embedder (`project_canonical_edge_row`, G11 — see the note in
13762 // `enrol_batch_vector_kinds`), so an edge body written with no embedder is
13763 // outstanding the moment it lands. Deferring it would leave `drain` and
13764 // `excise_source` returning `EngineError::Scheduler` on paths with nothing to
13765 // do with the dense arm (MEASURED: 4 shipped tests across
13766 // `tc31_source_id_on_every_hit`, `provenance_mandatory` and
13767 // `multidoc_extractor_provenance`). Making edges recoverable needs their
13768 // enrolment gated the way fix-2 gated node kinds — reported as OOS-13, and
13769 // outside codex's finding, which is the node path.
13770 //
13771 // Their LADDER is left alone for a second, separately MEASURED reason:
13772 // shortening it makes the worker's terminal-commit land while a caller's own
13773 // write is still open, which used to trip the governed write-race. Measured on
13774 // `consolidate_provider` under 6-way concurrency: 0/48 failures with the
13775 // ladder, 8/48 without. Left byte-for-byte as shipped; the ladder length is
13776 // reported as OOS-17 rather than newly exposed by a fix round.
13777 //
13778 // 0.8.20 Slice 21 (TC-57) — this note used to name that race
13779 // `SQLITE_BUSY_SNAPSHOT` and call it PRE-EXISTING. Both are corrected: the
13780 // characterized mechanism is plain `SQLITE_BUSY` (5) on a read→write lock
13781 // PROMOTION, with the busy handler invoked ZERO times (SQLite skips it for
13782 // deadlock avoidance), so no `busy_timeout` could absorb it;
13783 // `SQLITE_BUSY_SNAPSHOT` (517) is only a second, narrower exit of the same
13784 // shape. And the race is FIXED — `commit_batch` now takes `BEGIN IMMEDIATE`
13785 // (see the note there), so the governed path never promotes. The 0/48-vs-8/48
13786 // measurement above stands as the reason not to shorten the ladder, but it is
13787 // no longer load-bearing for correctness of the governed write path.
13788 if shared.embedder.is_none() && job.kind != EDGE_FACT_KIND {
13789 return ProjectionOutcome::Deferred;
13790 }
13791 // PR-9 — embed circuit breaker (see `embed_circuit_open`). Once abandoned
13792 // (timed-out) embed threads have piled up to the threshold the embedder is
13793 // treated as broken; fail subsequent jobs fast WITHOUT attempting an embed,
13794 // so a wedged embedder cannot keep leaking abandoned watchdog threads. This
13795 // entry check is the fast path; the latch decision itself is made under the
13796 // embed guard below (race-free against other workers).
13797 if shared.embed_circuit_open.load(Ordering::Relaxed) {
13798 return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: "EmbedderError" };
13799 }
13800 let delays = shared.retry_delays_ms.lock().map(|delays| delays.clone()).unwrap_or_default();
13801 let mut last_code = "EmbedderError";
13802 for (attempt, delay_ms) in std::iter::once(0_u64).chain(delays.iter().copied()).enumerate() {
13803 if attempt > 0 {
13804 if shared.state.lock().map(|state| state.stopping).unwrap_or(true) {
13805 return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code };
13806 }
13807 thread::sleep(Duration::from_millis(delay_ms));
13808 }
13809 // PR-9 — re-check the breaker on every attempt, not just at entry:
13810 // another worker (or an earlier attempt of this job) may have latched
13811 // it while we were sleeping between retries. Bail before spawning yet
13812 // another timeout-bound watchdog thread, so the abandoned-thread leak
13813 // stays bounded even on the multi-retry path.
13814 if shared.embed_circuit_open.load(Ordering::Relaxed) {
13815 return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code };
13816 }
13817 // PR-9 / ADR-0.6.0 Invariant 5 — every embed runs under the per-call
13818 // watchdog deadline so a hung embed surfaces Timeout instead of
13819 // parking this worker forever.
13820 let embed_timeout = Duration::from_millis(shared.embed_timeout_ms.load(Ordering::Relaxed));
13821 let vector = match shared.embedder.as_ref() {
13822 Some(embedder) => {
13823 // PR-9 — serialize the embed call engine-side (see
13824 // `embed_serialize`): the shared embedder is invoked one call
13825 // at a time, for SAFETY with arbitrary caller-supplied
13826 // embedders (throughput is ~neutral on the candle default).
13827 // The guard is held across the watchdog call and released
13828 // here, so commit/IO below stays parallel and a timed-out
13829 // embed frees it. The guard owns no data; a panic-resumed
13830 // embed poisons it, so we recover the inner guard rather than
13831 // wedge the whole pool.
13832 let _embed_permit =
13833 shared.embed_serialize.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13834 // PR-9 — breaker decision, made WITH the guard held so it is
13835 // race-free against other workers: if abandoned embed threads
13836 // from earlier timeouts have piled up to the threshold, latch
13837 // the breaker and fail fast WITHOUT spawning another one. The
13838 // live count is checked here (also covers a breaker latched by
13839 // another worker while we were queued on the lock), bounding
13840 // the abandoned-thread leak to ~threshold regardless of whether
13841 // the embedder hangs always or only intermittently.
13842 let threshold = shared.embed_circuit_threshold.load(Ordering::Relaxed);
13843 if shared.embed_circuit_open.load(Ordering::Relaxed)
13844 || (threshold != 0
13845 && shared.live_embed_threads.load(Ordering::Relaxed) >= threshold)
13846 {
13847 shared.embed_circuit_open.store(true, Ordering::Relaxed);
13848 return ProjectionOutcome::Failure {
13849 cursor: job.cursor,
13850 failure_code: last_code,
13851 };
13852 }
13853 match embed_with_watchdog(
13854 embedder,
13855 &job.body,
13856 embed_timeout,
13857 &shared.live_embed_threads,
13858 ) {
13859 Ok(vector) => vector,
13860 Err(RuntimeEmbedderError::Timeout) => {
13861 // The embed thread is now abandoned (still counted in
13862 // live_embed_threads until it returns); the breaker
13863 // check above caps how many can accumulate.
13864 last_code = "EmbedderError";
13865 continue;
13866 }
13867 Err(RuntimeEmbedderError::Failed { .. }) => {
13868 last_code = "EmbedderError";
13869 continue;
13870 }
13871 }
13872 }
13873 None => {
13874 last_code = "EmbedderNotConfiguredError";
13875 continue;
13876 }
13877 };
13878
13879 if u32::try_from(vector.len()).unwrap_or(u32::MAX) != shared.embedder_identity.dimension {
13880 last_code = "EmbedderDimensionMismatchError";
13881 continue;
13882 }
13883
13884 let blob = encode_vector_blob(&vector);
13885 // EU-5a2 mean-centering apply path (projection write side). The
13886 // f32 BLOB persisted is ALWAYS un-centered; `bin_blob` carries
13887 // the (possibly centered) f32 fed to `vec_quantize_binary`. The
13888 // centering decision is finalized in `commit_projection_outcomes`
13889 // where the writer connection is in-hand and the read of
13890 // `_fathomdb_embedder_profiles.mean_vec` is in the same tx as
13891 // the INSERT. NoopEmbedder (EU-5a2's only live identity) is not
13892 // MC-required, so `bin_blob == blob` throughout EU-5a2.
13893 let bin_blob = blob.clone();
13894 return ProjectionOutcome::Success {
13895 cursor: job.cursor,
13896 kind: job.kind.clone(),
13897 blob,
13898 bin_blob,
13899 };
13900 }
13901
13902 ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code }
13903}
13904
13905/// 0.8.20 Slice 20 fix-1 (codex §9 [P2]) — the ONE definition of "a canonical
13906/// EDGE row the vector pipeline still owes an embed for".
13907///
13908/// Two call sites must agree on this predicate and had drifted:
13909///
13910/// - [`next_pending_projection_jobs`] — the SCHEDULER, and therefore the
13911/// authority on what will actually be embedded. It joins
13912/// `_fathomdb_vector_kinds` on `'edge_fact'`, so an edge body is only ever
13913/// scheduled when that kind is registered.
13914/// - [`connection_has_pending_projection_work`] — the PROBE behind
13915/// `drain`/`wait_for_idle` and, since this slice, `dense_readiness`. It
13916/// omitted that join.
13917///
13918/// The consequence of the drift: a live edge body written while `edge_fact` was
13919/// not a registered vector kind (e.g. edges carried forward from before the G11
13920/// edge-vector pipeline, which is what auto-registers the kind) counted as
13921/// outstanding work the scheduler would NEVER take. `dense_readiness` reported
13922/// `embedding` forever and `drain` could never report idle — both the mirror
13923/// image of R-20-DR's property. Building both edge arms from this one fragment
13924/// makes a repeat drift unrepresentable.
13925///
13926/// Emits the `FROM`/`JOIN` clauses plus the shared `WHERE` predicates, with the
13927/// edge table aliased `ce` and the projection terminal aliased `pt`; `now_idx`
13928/// is the 1-based bind index of the `:now` seam that [`edge_validity_sql`]
13929/// consumes. Callers may append further `AND` predicates.
13930///
13931/// **The one predicate deliberately NOT shared** is the scheduler's
13932/// `write_cursor > :cursor` watermark filter, which the scheduler appends and
13933/// the probe must not: per the G11 (Slice 15) fix-1 note on the probe, the
13934/// probe has to see edge bodies left un-projected BELOW the watermark when the
13935/// engine closed mid-flight, or `drain` would report idle with edge vectors
13936/// still missing on reopen. That asymmetry is intentional and load-bearing; the
13937/// row-eligibility predicates above are not, and are shared.
13938fn pending_edge_projection_from_where(now_idx: usize) -> String {
13939 format!(
13940 "FROM canonical_edges ce
13941 JOIN _fathomdb_vector_kinds
13942 ON _fathomdb_vector_kinds.kind = 'edge_fact'
13943 LEFT JOIN _fathomdb_projection_terminal pt
13944 ON pt.write_cursor = ce.write_cursor
13945 WHERE ce.body IS NOT NULL
13946 AND ce.superseded_at IS NULL{}
13947 AND pt.write_cursor IS NULL",
13948 edge_validity_sql("ce", now_idx)
13949 )
13950}
13951
13952/// The SCHEDULER's scan: the next `max_jobs` pending projection jobs in
13953/// `write_cursor` order.
13954///
13955/// `dense_arm_live` is `ProjectionRuntimeShared::embedder.is_some()`, read once
13956/// per dispatcher because it is fixed for the session's lifetime.
13957///
13958/// # fix-5 (codex §9 round 4 [P1]) — why the node exclusion is IN the SQL
13959///
13960/// With no usable dense runtime a NODE job can only come back
13961/// [`ProjectionOutcome::Deferred`], which by design records no terminal (fix-4),
13962/// so dispatching one would re-fetch the SAME cursor forever: a hot loop for the
13963/// whole life of the session. fix-4 suppressed that by filtering the vector this
13964/// function RETURNS — i.e. after the `ORDER BY … LIMIT`, so the `LIMIT` still
13965/// applied to the UNFILTERED set. More than `PROJECTION_SCAN_FETCH` pending node
13966/// rows ordered before a pending EDGE body therefore filled the entire window
13967/// with jobs that were then all dropped, the dispatcher went back to sleep with
13968/// `pending_scan` already consumed, and the edge body was never scheduled at all
13969/// — permanently, since those node rows stay pending for the session's life. The
13970/// exclusion belongs here, where the `LIMIT` applies to the ALREADY-FILTERED set
13971/// and a later edge job is always reachable.
13972///
13973/// Edges are NOT excluded: `'edge_fact'` is auto-registered by the edge write
13974/// itself, un-gated on the embedder (`project_canonical_edge_row`, G11, and the
13975/// note in [`Engine::enrol_batch_vector_kinds`]), so an edge body still
13976/// TERMINATES on an absent embedder exactly as it has shipped since G11. Making
13977/// edges recoverable too needs that enrolment gated first (OOS-13).
13978fn next_pending_projection_jobs(
13979 connection: &Connection,
13980 in_flight: &BTreeSet<u64>,
13981 max_jobs: usize,
13982 dense_arm_live: bool,
13983) -> rusqlite::Result<Vec<ProjectionJob>> {
13984 if max_jobs == 0 {
13985 return Ok(Vec::new());
13986 }
13987 let cursor = load_projection_cursor(connection)?;
13988 // Over-fetch by `in_flight.len()` so the post-filter still returns
13989 // up to `max_jobs` after skipping cursors already in-flight.
13990 let sql_limit = max_jobs.saturating_add(in_flight.len()).min(256);
13991 // G11 (Slice 15) — UNION extends the projection queue to include edge bodies.
13992 // Edge bodies use kind `'edge_fact'` so `resolve_source_type` maps them to
13993 // `source_type = 'edge_fact'` in `vector_default` (partition correctness).
13994 // The UNION is ordered by write_cursor so projection proceeds in
13995 // insertion order across nodes and edges.
13996 //
13997 // fix-5 [P1]: with no dense arm the NODE arm is omitted outright rather than
13998 // predicated false, so the planner never walks it. The edge arm keeps both
13999 // binds (`?1` the cursor, `?2` the `:now` seam), so the bound parameter set
14000 // is identical either way.
14001 let node_arm = if dense_arm_live {
14002 "SELECT canonical_nodes.write_cursor AS write_cursor,
14003 canonical_nodes.kind AS kind,
14004 canonical_nodes.body AS body
14005 FROM canonical_nodes
14006 JOIN _fathomdb_vector_kinds
14007 ON _fathomdb_vector_kinds.kind = canonical_nodes.kind
14008 LEFT JOIN _fathomdb_projection_terminal
14009 ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
14010 WHERE canonical_nodes.write_cursor > ?1
14011 AND _fathomdb_projection_terminal.write_cursor IS NULL
14012
14013 UNION ALL
14014
14015 "
14016 } else {
14017 ""
14018 };
14019 let sql = format!(
14020 "SELECT write_cursor, kind, body FROM (
14021 {node_arm}SELECT ce.write_cursor AS write_cursor,
14022 'edge_fact' AS kind,
14023 ce.body AS body
14024 {edge_arm}
14025 AND ce.write_cursor > ?1
14026 ) ORDER BY write_cursor
14027 LIMIT {sql_limit}",
14028 // fix-1 [P2]: the edge arm's row-eligibility predicates come from the
14029 // shared fragment so this and `connection_has_pending_projection_work`
14030 // cannot disagree about what is outstanding. The `write_cursor > ?1`
14031 // watermark is appended here and ONLY here — see the fragment's doc.
14032 // TC-33: `?1` is the projection cursor ⇒ the edge `:now` binds at `?2`.
14033 edge_arm = pending_edge_projection_from_where(2)
14034 );
14035 let mut statement = connection.prepare_cached(&sql)?;
14036 let rows = statement.query_map(params![cursor, current_epoch_seconds()], |row| {
14037 Ok(ProjectionJob { cursor: row.get(0)?, kind: row.get(1)?, body: row.get(2)? })
14038 })?;
14039 let mut jobs = Vec::with_capacity(max_jobs);
14040 for row in rows {
14041 let job = row?;
14042 if in_flight.contains(&job.cursor) {
14043 continue;
14044 }
14045 jobs.push(job);
14046 if jobs.len() >= max_jobs {
14047 break;
14048 }
14049 }
14050 Ok(jobs)
14051}
14052
14053fn database_has_pending_projection_work(path: &Path) -> rusqlite::Result<bool> {
14054 let connection = open_runtime_connection(path)?;
14055 connection_has_pending_projection_work(&connection)
14056}
14057
14058/// 0.8.20 Slice 20 (R-20-DR) — the body of
14059/// [`database_has_pending_projection_work`], lifted so it can also run on a
14060/// connection the caller ALREADY holds (the engine's own connection, inside
14061/// [`Engine::read_projections`]) instead of opening a runtime connection from a
14062/// path. Both callers run the same two arms and the same predicates — which is
14063/// the point. Readiness and `drain`/`wait_for_idle` must key off ONE definition
14064/// of "outstanding embed", or readiness could report `ready` for work `drain`
14065/// still waits on.
14066///
14067/// fix-1 (codex §9 [P2]) — the edge arm is no longer a hand-copied mirror of
14068/// the scheduler's: both are built from
14069/// [`pending_edge_projection_from_where`]. The copy had lost the
14070/// `_fathomdb_vector_kinds` join, so this probe reported permanent pending work
14071/// for edge bodies the scheduler would never schedule. That was PRE-EXISTING —
14072/// it reached `Engine::drain` through `wait_for_idle` before readiness existed.
14073fn connection_has_pending_projection_work(connection: &Connection) -> rusqlite::Result<bool> {
14074 let cursor = load_projection_cursor(connection)?;
14075 // Check canonical_nodes for un-projected work.
14076 let has_node_work: bool = connection
14077 .query_row(
14078 "SELECT 1
14079 FROM canonical_nodes
14080 JOIN _fathomdb_vector_kinds ON _fathomdb_vector_kinds.kind = canonical_nodes.kind
14081 LEFT JOIN _fathomdb_projection_terminal
14082 ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
14083 WHERE canonical_nodes.write_cursor > ?1
14084 AND _fathomdb_projection_terminal.write_cursor IS NULL
14085 LIMIT 1",
14086 [cursor],
14087 |_row| Ok(true),
14088 )
14089 .or_else(|err| match err {
14090 rusqlite::Error::QueryReturnedNoRows => Ok(false),
14091 _ => Err(err),
14092 })?;
14093 if has_node_work {
14094 return Ok(true);
14095 }
14096 // G11 (Slice 15) fix-1 [P2] — also check canonical_edges for edge bodies
14097 // that were not projected before the engine closed. Without this check,
14098 // drain() returns idle while edge vectors remain unembedded on reopen.
14099 // fix-31 [P2]: exclude superseded edges from the pending check so the
14100 // scheduler does not pick up stale tombstoned rows as projection work.
14101 // 0.8.12 Slice A (R-CON-2 named default-ON blocker; Slice-20 codex §9
14102 // [P2]) — also exclude t_invalid-excluded (recency-consolidated) edges,
14103 // mirroring `next_pending_projection_jobs`'s edge arm. Required: without
14104 // this mirror, a rebuild-truncated t_invalid edge that
14105 // `next_pending_projection_jobs` now correctly skips would never gain a
14106 // `_fathomdb_projection_terminal` row, so this probe would flag it as
14107 // phantom-pending forever and `drain()`/`wait_for_idle` would hang.
14108 // Slice-20 fix-1 [P2]: the mirror is now STRUCTURAL — the arm is built from
14109 // `pending_edge_projection_from_where`, the same fragment the scheduler
14110 // uses — because the hand-copied mirror had already lost the
14111 // `_fathomdb_vector_kinds` join and produced exactly the phantom-pending
14112 // hang described above for edge bodies under an unregistered `edge_fact`.
14113 connection
14114 .query_row(
14115 // TC-33: no other parameter here ⇒ the edge `:now` binds at `?1`.
14116 // No `write_cursor > cursor` filter — see the fragment's doc for
14117 // why the probe deliberately looks BELOW the watermark too.
14118 &format!("SELECT 1 {} LIMIT 1", pending_edge_projection_from_where(1)),
14119 params![current_epoch_seconds()],
14120 |_row| Ok(true),
14121 )
14122 .or_else(|err| match err {
14123 rusqlite::Error::QueryReturnedNoRows => Ok(false),
14124 _ => Err(err),
14125 })
14126}
14127
14128/// 0.8.20 Slice 20 (R-20-DR) — the `dense_readiness` of the `searchable→vector`
14129/// projection, DERIVED. There is no stored flag and this feature adds no schema
14130/// step or `MIGRATIONS` entry; later unrelated migrations do not affect that
14131/// property.
14132///
14133/// **Why derived is the design, not a shortcut.** §4.1 invariant 1 requires
14134/// `{ vector-insert ∧ dense_readiness := ready }` to be ONE transaction, with a
14135/// torn `ready`-without-vector FORBIDDEN. A stored flag is precisely the thing
14136/// that can tear. Deriving it makes the invariant true **by construction**:
14137/// readiness is a pure function of state that
14138/// [`commit_projection_outcomes`] already writes inside a single transaction —
14139/// the `vector_default` / `_fathomdb_vector_rows` INSERTs, the
14140/// `_fathomdb_projection_terminal` row ([`record_projection_terminal`]) and the
14141/// readiness watermark ([`advance_projection_cursor`], which only ever steps
14142/// over cursors that ALREADY hold a terminal) all commit together or not at all.
14143/// So `ready` cannot be observed before the vector is durable, and the only
14144/// reachable torn state is the tolerated one (`embedding` with the vector
14145/// absent — the dense arm simply reads as partial).
14146///
14147/// It reuses the EXACT predicate `drain`/`wait_for_idle` use
14148/// ([`connection_has_pending_projection_work`]), so "readiness is `ready`" and
14149/// "`drain` reports idle" cannot disagree.
14150///
14151/// **Scope note (honest boundary).** The predicate is corpus-wide, not
14152/// per-attribute, because Slice 15d persists the `searchable→vector` sub-object
14153/// but DEFERS building any per-attribute embedding (`ProjectionDelta::deferred`)
14154/// — every declared vector projection is served by the one engine vector
14155/// pipeline, so per-projection scoping has no distinct meaning yet. A stored
14156/// column would not have been more specific; it would only have been tearable.
14157/// When per-attribute embedding lands, this function is where the scoping goes.
14158///
14159/// **Failure boundary.** A row whose embed FAILED terminally records a `failed`
14160/// terminal (no vector row), so it stops being outstanding. With a usable dense
14161/// runtime, readiness returns to `ready`: the row will never embed, so reporting
14162/// `embedding` forever would be a lie. The usable-runtime predicate takes
14163/// precedence: when no usable runtime exists the state is `Unavailable`; when
14164/// it exists this function selects `Embedding` / `Ready` without changing the
14165/// failed-terminal boundary. Failures stay
14166/// separately observable through the `projection_failures` collection. A
14167/// `ready` corpus can therefore lack a vector row only for a failed terminal; it
14168/// is NOT a torn write because no `up_to_date` terminal exists for it.
14169fn derive_dense_readiness(
14170 connection: &Connection,
14171 dense_runtime_usable: bool,
14172) -> Result<DenseReadiness, EngineError> {
14173 if !dense_runtime_usable {
14174 return Ok(DenseReadiness::Unavailable);
14175 }
14176 if connection_has_pending_projection_work(connection).map_err(|_| EngineError::Storage)? {
14177 Ok(DenseReadiness::Embedding)
14178 } else {
14179 Ok(DenseReadiness::Ready)
14180 }
14181}
14182
14183struct CanonicalNodeRow {
14184 cursor: u64,
14185 kind: String,
14186 body: String,
14187 row_kind: RowKind,
14188 /// fix-2 [P2] — whether this row is in the attribute projection's row set
14189 /// (`state = 'active' AND superseded_at IS NULL`, the exact `backfill_attribute`
14190 /// predicate). A projector-replay rebuild uses this to gate the attribute
14191 /// projection so it does not re-surface a pending / superseded node's values.
14192 /// Node-FTS / vector shadows are rebuilt for every row (their stale versions
14193 /// are excluded by the read-side lifecycle join, unchanged from before).
14194 attr_projected: bool,
14195}
14196
14197/// 0.8.0 Slice 5 (G1) — re-tokenize `search_index` from the canonical source
14198/// rows after the step-11 tokenizer-default upgrade drops + recreates the FTS5
14199/// virtual table. Projection-only: it reads `canonical_nodes` (the source of
14200/// truth, untouched) and rewrites the FTS shadow; it performs **no**
14201/// source-record migration. Every canonical node already carries an FTS row at
14202/// write time (the projection-time INSERT is unconditional), so reinserting
14203/// every node exactly reproduces the prior index content under the new
14204/// tokenizer. Runs in a single transaction on the writer connection before
14205/// readers spawn.
14206///
14207/// Crash-retryable (fix-1): the reindex and its durable completion marker
14208/// (`SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY` in `_fathomdb_open_state`)
14209/// commit together in ONE `BEGIN IMMEDIATE…COMMIT`. A crash before the commit
14210/// rolls both back, leaving no marker; the next open re-runs. A crash after
14211/// the commit finds the marker present and skips. Idempotent.
14212fn reproject_search_index_after_tokenizer_upgrade(connection: &Connection) -> rusqlite::Result<()> {
14213 let rows = canonical_node_rows(connection)?;
14214 connection.execute_batch("BEGIN IMMEDIATE")?;
14215 let result = (|| {
14216 // 0.8.20 Slice 5a (R-20-E1) — registry-driven: re-tokenize EVERY
14217 // node-FTS projection, not just `search_index`. `search_index_v2` uses
14218 // the SAME tokenizer (`porter unicode61 remove_diacritics 2`), so it is
14219 // equally invalidated by a tokenizer-default upgrade; before this slice
14220 // it was neither cleared nor re-tokenized here. Edge FTS is out of scope
14221 // for this open-path repair (it postdates the step-11 upgrade and is
14222 // rebuilt by `rebuild_projections`).
14223 truncate_row_projections_in(connection, &[ProjectionClass::NodeFts])?;
14224 for row in &rows {
14225 project_canonical_node_row(
14226 connection,
14227 row.cursor,
14228 &row.kind,
14229 &row.body,
14230 row.row_kind,
14231 ProjectionPass::FtsOnly,
14232 // FtsOnly never touches the attribute store (predates step 24), so
14233 // `node_active` is inert here; forward the row's flag anyway (it is
14234 // the backfill's active-and-non-superseded predicate) so the field
14235 // has a reader in every build configuration.
14236 row.attr_projected,
14237 )?;
14238 }
14239 connection.execute(
14240 "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
14241 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
14242 params![SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY, "1"],
14243 )?;
14244 Ok(())
14245 })();
14246 match result {
14247 Ok(()) => connection.execute_batch("COMMIT"),
14248 Err(err) => {
14249 let _ = connection.execute_batch("ROLLBACK");
14250 Err(err)
14251 }
14252 }
14253}
14254
14255/// 0.8.0 Slice 5 (G1) fix-1 — has the post-tokenizer-upgrade re-tokenization
14256/// committed durably on this DB? Keys off the
14257/// `SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY` row written inside the reindex
14258/// transaction; its absence on a v11 DB means the reindex never committed
14259/// (fresh-after-step-11 or crash-in-window) and must (re-)run.
14260///
14261/// A MISSING `_fathomdb_open_state` table is reported as "complete" (skip the
14262/// reproject): that table is created by migration step 1, so its absence means
14263/// the DB never ran our migrations (e.g. a synthetic DB whose `user_version`
14264/// was stamped to 11 by hand, or a legacy/foreign shape). Such DBs are
14265/// rejected by the downstream embedder-identity/integrity probes; the reproject
14266/// must not run — and must not mask those errors — on them. On a genuinely
14267/// migrated DB the table always exists, so the crash-repair path is unaffected.
14268fn search_index_tokenizer_reproject_complete(connection: &Connection) -> rusqlite::Result<bool> {
14269 match connection.query_row(
14270 "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
14271 [SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY],
14272 |row| row.get::<_, String>(0),
14273 ) {
14274 Ok(value) => Ok(value == "1"),
14275 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
14276 Err(rusqlite::Error::SqliteFailure(_, Some(ref message)))
14277 if message.contains("no such table") =>
14278 {
14279 Ok(true)
14280 }
14281 Err(err) => Err(err),
14282 }
14283}
14284
14285/// 0.8.20 Slice 15c (TC-33) fix-6 — has the one-time edge-vector prune committed
14286/// durably on this DB? Keys off the [`EDGE_VECTOR_PRUNE_MARKER_KEY`] row written
14287/// inside the prune transaction; its absence means the prune never ran (a DB
14288/// upgraded before this fix shipped, or a crash between the step-23 commit and
14289/// the prune commit) and must (re-)run.
14290///
14291/// A MISSING `_fathomdb_open_state` table is reported as "complete" (skip the
14292/// prune) — that table is created by migration step 1, so its absence means the
14293/// DB never ran our migrations (a synthetic/foreign shape rejected downstream);
14294/// the prune must not run, and must not mask those errors, on it. Mirrors
14295/// [`search_index_tokenizer_reproject_complete`].
14296fn edge_vector_prune_complete(connection: &Connection) -> rusqlite::Result<bool> {
14297 match connection.query_row(
14298 "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
14299 [EDGE_VECTOR_PRUNE_MARKER_KEY],
14300 |row| row.get::<_, String>(0),
14301 ) {
14302 Ok(value) => Ok(value == "1"),
14303 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
14304 Err(rusqlite::Error::SqliteFailure(_, Some(ref message)))
14305 if message.contains("no such table") =>
14306 {
14307 Ok(true)
14308 }
14309 Err(err) => Err(err),
14310 }
14311}
14312
14313/// 0.8.20 Slice 15c (TC-33) fix-6 — delete every `vector_default` (vec0) row that
14314/// has NO `_fathomdb_vector_rows` sidecar entry, then record the durable
14315/// completion marker, all in one `BEGIN IMMEDIATE` transaction (crash-retryable:
14316/// a crash before COMMIT leaves no marker and the next open re-runs).
14317///
14318/// A vec0 row and its sidecar row are written and deleted TOGETHER (same
14319/// transaction) on every steady-state path, so a sidecar-less vec0 row is ONLY
14320/// ever produced by the step-23 recreate, which drops the edge rows and their
14321/// sidecar entries but cannot reach the engine-created vec0 table. So this
14322/// targets exactly the dropped edges' orphans and touches NOTHING on a healthy
14323/// corpus. Node vec0 rows keep their sidecar entry, so they are never pruned —
14324/// node recall is unaffected.
14325///
14326/// The orphans are gathered with plain scans (both proven vec0 forms — a full
14327/// `SELECT rowid FROM vector_default` and per-`rowid` `DELETE`) and diffed in
14328/// Rust, rather than relying on a compound `DELETE ... WHERE rowid NOT IN (...)`
14329/// over the virtual table.
14330fn prune_orphaned_edge_vectors(connection: &Connection) -> rusqlite::Result<()> {
14331 connection.execute_batch("BEGIN IMMEDIATE")?;
14332 let result = (|| {
14333 let sidecar: std::collections::HashSet<i64> = {
14334 let mut statement =
14335 connection.prepare("SELECT write_cursor FROM _fathomdb_vector_rows")?;
14336 let rows = statement.query_map([], |row| row.get::<_, i64>(0))?;
14337 let mut set = std::collections::HashSet::new();
14338 for r in rows {
14339 set.insert(r?);
14340 }
14341 set
14342 };
14343 let vec_rowids: Vec<i64> = {
14344 let mut statement = connection.prepare("SELECT rowid FROM vector_default")?;
14345 let rows = statement.query_map([], |row| row.get::<_, i64>(0))?;
14346 let mut out = Vec::new();
14347 for r in rows {
14348 out.push(r?);
14349 }
14350 out
14351 };
14352 for rowid in vec_rowids {
14353 if !sidecar.contains(&rowid) {
14354 // vec0 rowid IS the canonical write_cursor; delete by rowid (the
14355 // proven vec0 delete form, as `prune_edge_projection_shadows`),
14356 // through the one TC-76-safe vec0-delete primitive.
14357 delete_vector_partition_row(connection, rowid)?;
14358 }
14359 }
14360 connection.execute(
14361 "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
14362 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
14363 params![EDGE_VECTOR_PRUNE_MARKER_KEY, "1"],
14364 )?;
14365 Ok(())
14366 })();
14367 match result {
14368 Ok(()) => connection.execute_batch("COMMIT"),
14369 Err(err) => {
14370 let _ = connection.execute_batch("ROLLBACK");
14371 Err(err)
14372 }
14373 }
14374}
14375
14376fn canonical_node_rows(connection: &Connection) -> rusqlite::Result<Vec<CanonicalNodeRow>> {
14377 // fix-2 [P2] — also read `state` + `superseded_at` so a replay rebuild can gate
14378 // the attribute projection to the backfill's row set. `attr_projected` mirrors
14379 // the exact `backfill_attribute` predicate (`state = 'active' AND
14380 // superseded_at IS NULL`): a NULL/foreign state is NOT 'active' and so is
14381 // excluded, identical to the SQL equality.
14382 let mut statement = connection.prepare(
14383 "SELECT write_cursor, kind, body, row_kind, state, superseded_at \
14384 FROM canonical_nodes ORDER BY write_cursor",
14385 )?;
14386 let rows = statement.query_map([], |row| {
14387 let state: Option<String> = row.get::<_, Option<String>>(4)?;
14388 let superseded_at: Option<i64> = row.get::<_, Option<i64>>(5)?;
14389 Ok(CanonicalNodeRow {
14390 cursor: row.get::<_, u64>(0)?,
14391 kind: row.get::<_, String>(1)?,
14392 body: row.get::<_, String>(2)?,
14393 row_kind: row_kind_from_column(&row.get::<_, String>(3)?),
14394 attr_projected: state.as_deref() == Some("active") && superseded_at.is_none(),
14395 })
14396 })?;
14397 rows.collect()
14398}
14399
14400/// 0.8.20 Slice 5a — inverse of [`RowKind::as_str`] for the stored
14401/// `canonical_nodes.row_kind` column. An unrecognized spelling degrades to
14402/// `Leaf`, the column DEFAULT and the shape every pre-EXP-S row carries; that
14403/// keeps a projector replay behavior-identical to the pre-registry rebuild,
14404/// which ignored `row_kind` entirely.
14405fn row_kind_from_column(value: &str) -> RowKind {
14406 match value {
14407 "coverage" => RowKind::Coverage,
14408 "graph" => RowKind::Graph,
14409 _ => RowKind::Leaf,
14410 }
14411}
14412
14413#[cfg(feature = "operator")]
14414fn hex_encode(bytes: &[u8]) -> String {
14415 let mut out = String::with_capacity(bytes.len() * 2);
14416 for byte in bytes {
14417 out.push(hex_nibble(byte >> 4));
14418 out.push(hex_nibble(byte & 0x0f));
14419 }
14420 out
14421}
14422
14423#[cfg(feature = "operator")]
14424fn hex_nibble(value: u8) -> char {
14425 match value {
14426 0..=9 => (b'0' + value) as char,
14427 10..=15 => (b'a' + value - 10) as char,
14428 _ => unreachable!(),
14429 }
14430}
14431
14432#[cfg(feature = "operator")]
14433fn physical_section(connection: &Connection, full: bool) -> Section {
14434 let mut findings = Vec::new();
14435 if let Err(err) = connection.query_row("PRAGMA page_count", [], |row| row.get::<_, i64>(0)) {
14436 findings.push(Finding {
14437 code: "E_CORRUPT_HEADER",
14438 stage: "PhysicalProbe",
14439 locator: locator_from_rusqlite_error(&err),
14440 doc_anchor: "design/recovery.md#header-malformed",
14441 detail: format!("page_count probe failed: {err}"),
14442 });
14443 }
14444 if full {
14445 match collect_integrity_check_findings(connection) {
14446 Ok(rows) => findings.extend(rows),
14447 Err(err) => findings.push(Finding {
14448 code: "E_CORRUPT_INTEGRITY_CHECK",
14449 stage: "IntegrityCheck",
14450 locator: locator_from_rusqlite_error(&err),
14451 doc_anchor: "design/recovery.md#integrity-check-full-findings",
14452 detail: format!("PRAGMA integrity_check failed: {err}"),
14453 }),
14454 }
14455 }
14456 if findings.is_empty() {
14457 Section::Clean
14458 } else {
14459 Section::Findings(findings)
14460 }
14461}
14462
14463#[cfg(feature = "operator")]
14464fn logical_section(connection: &Connection) -> Section {
14465 let mut findings = Vec::new();
14466 if let Err(err) = connection.query_row("PRAGMA schema_version", [], |row| row.get::<_, i64>(0))
14467 {
14468 findings.push(Finding {
14469 code: "E_CORRUPT_SCHEMA",
14470 stage: "SchemaProbe",
14471 locator: locator_from_rusqlite_error(&err),
14472 doc_anchor: "design/recovery.md#schema-inconsistent",
14473 detail: format!("schema_version probe failed: {err}"),
14474 });
14475 }
14476 match connection.query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)) {
14477 Ok(0) => findings.push(Finding {
14478 code: "E_CORRUPT_SCHEMA",
14479 stage: "SchemaProbe",
14480 locator: CorruptionLocator::MigrationStep { from: 0, to: 0 },
14481 doc_anchor: "design/recovery.md#schema-inconsistent",
14482 detail: "user_version is zero".to_string(),
14483 }),
14484 Ok(_) => {}
14485 Err(err) => findings.push(Finding {
14486 code: "E_CORRUPT_SCHEMA",
14487 stage: "SchemaProbe",
14488 locator: locator_from_rusqlite_error(&err),
14489 doc_anchor: "design/recovery.md#schema-inconsistent",
14490 detail: format!("user_version probe failed: {err}"),
14491 }),
14492 }
14493 if findings.is_empty() {
14494 Section::Clean
14495 } else {
14496 Section::Findings(findings)
14497 }
14498}
14499
14500#[cfg(feature = "operator")]
14501fn semantic_section(connection: &Connection) -> Section {
14502 match load_default_profile(connection) {
14503 Ok(_) => Section::Clean,
14504 Err(rusqlite::Error::QueryReturnedNoRows) => Section::Findings(vec![Finding {
14505 code: "E_CORRUPT_EMBEDDER_IDENTITY",
14506 stage: "EmbedderIdentity",
14507 locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
14508 doc_anchor: "design/recovery.md#embedder-identity-drift",
14509 detail: "default embedder profile row is missing".to_string(),
14510 }]),
14511 Err(err) => Section::Findings(vec![Finding {
14512 code: "E_CORRUPT_EMBEDDER_IDENTITY",
14513 stage: "EmbedderIdentity",
14514 locator: locator_from_rusqlite_error(&err),
14515 doc_anchor: "design/recovery.md#embedder-identity-drift",
14516 detail: format!("default embedder profile probe failed: {err}"),
14517 }]),
14518 }
14519}
14520
14521#[cfg(feature = "operator")]
14522fn collect_integrity_check_findings(connection: &Connection) -> rusqlite::Result<Vec<Finding>> {
14523 let mut statement = connection.prepare("PRAGMA integrity_check")?;
14524 let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
14525 let mut findings = Vec::new();
14526 for row in rows {
14527 let message = row?;
14528 if message == "ok" {
14529 continue;
14530 }
14531 findings.push(Finding {
14532 code: "E_CORRUPT_INTEGRITY_CHECK",
14533 stage: "IntegrityCheck",
14534 locator: CorruptionLocator::OpaqueSqliteError {
14535 sqlite_extended_code: rusqlite::ffi::SQLITE_CORRUPT,
14536 },
14537 doc_anchor: "design/recovery.md#integrity-check-full-findings",
14538 detail: message,
14539 });
14540 }
14541 Ok(findings)
14542}
14543
14544#[cfg(feature = "operator")]
14545fn locator_from_rusqlite_error(err: &rusqlite::Error) -> CorruptionLocator {
14546 let extended = err.sqlite_error().map(|inner| inner.extended_code).unwrap_or(0);
14547 CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: extended }
14548}
14549
14550fn open_runtime_connection(path: &Path) -> rusqlite::Result<Connection> {
14551 let connection = Connection::open(path)?;
14552 connection.pragma_update(None, "journal_mode", "WAL")?;
14553 // OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — `secure_delete=ON` at
14554 // EVERY open. The projection/vector-rewrite runtime connection performs
14555 // DELETEs (shadow-table rewrites), so its freed pages must be scrubbed too;
14556 // setting the pragma only on the writer left a GDPR-erasure leak here.
14557 connection.pragma_update(None, "secure_delete", "ON")?;
14558 Ok(connection)
14559}
14560
14561fn load_projection_cursor(connection: &Connection) -> rusqlite::Result<u64> {
14562 connection
14563 .query_row(
14564 "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
14565 [PROJECTION_CURSOR_KEY],
14566 |row| row.get::<_, String>(0),
14567 )
14568 .map(|value| value.parse::<u64>().unwrap_or(0))
14569 .or_else(|err| match err {
14570 rusqlite::Error::QueryReturnedNoRows => Ok(0),
14571 _ => Err(err),
14572 })
14573}
14574
14575fn store_projection_cursor(connection: &Connection, cursor: u64) -> rusqlite::Result<()> {
14576 connection.execute(
14577 "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
14578 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
14579 params![PROJECTION_CURSOR_KEY, cursor.to_string()],
14580 )?;
14581 Ok(())
14582}
14583
14584fn record_projection_terminal(
14585 connection: &Connection,
14586 cursor: u64,
14587 state: &str,
14588) -> rusqlite::Result<()> {
14589 connection.execute(
14590 "INSERT OR IGNORE INTO _fathomdb_projection_terminal(write_cursor, state) VALUES(?1, ?2)",
14591 params![cursor, state],
14592 )?;
14593 Ok(())
14594}
14595
14596fn terminal_state_for_cursor(
14597 connection: &Connection,
14598 cursor: u64,
14599) -> rusqlite::Result<Option<String>> {
14600 connection
14601 .query_row(
14602 "SELECT state FROM _fathomdb_projection_terminal WHERE write_cursor = ?1",
14603 [cursor],
14604 |row| row.get::<_, String>(0),
14605 )
14606 .map(Some)
14607 .or_else(|err| match err {
14608 rusqlite::Error::QueryReturnedNoRows => Ok(None),
14609 _ => Err(err),
14610 })
14611}
14612
14613fn advance_projection_cursor(connection: &Connection) -> rusqlite::Result<u64> {
14614 let mut cursor = load_projection_cursor(connection)?;
14615 loop {
14616 let next = cursor.saturating_add(1);
14617 if terminal_state_for_cursor(connection, next)?.is_some() {
14618 cursor = next;
14619 } else {
14620 break;
14621 }
14622 }
14623 store_projection_cursor(connection, cursor)?;
14624 Ok(cursor)
14625}
14626
14627fn commit_projection_outcomes(
14628 connection: &mut Connection,
14629 outcomes: &[ProjectionOutcome],
14630 shared: &ProjectionRuntimeShared,
14631) -> rusqlite::Result<()> {
14632 let embedder_identity = &shared.embedder_identity;
14633 let mc = identity_requires_mean_centering(embedder_identity);
14634 // EU-5f — serialize the whole commit across workers so the at-pin
14635 // re-quantize sees a totally-ordered history (see `commit_gate`).
14636 let _gate = shared.commit_gate.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
14637 // Take the WAL write lock before the reads below. A deferred transaction
14638 // would need a read-to-write promotion while a concurrent Engine::write
14639 // holds its own immediate transaction, which SQLite rejects without
14640 // invoking the busy handler and forces the worker to recompute the batch.
14641 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
14642 // The accumulator is mutable process state coupled to this transaction.
14643 // Keep the shared value untouched while building a candidate so rollback
14644 // cannot count a vector or consume the pin threshold prematurely.
14645 let mut shared_accumulator =
14646 shared.mean_accumulator.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
14647 let mut candidate_accumulator = shared_accumulator.clone();
14648 // EU-5a2/EU-5f — the live pinned mean. Read once at the top; may pin
14649 // mid-batch (set to `Some` after a threshold-crossing row below).
14650 let mut current_mean: Option<Vec<f32>> = if mc {
14651 tx.query_row(
14652 "SELECT mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
14653 [],
14654 |row| row.get::<_, Option<Vec<u8>>>(0),
14655 )
14656 .ok()
14657 .flatten()
14658 .map(|bytes| decode_vector_blob(&bytes))
14659 } else {
14660 None
14661 };
14662 let mut staged_events: Vec<EmbedderEvent> = Vec::new();
14663 for outcome in outcomes {
14664 match outcome {
14665 ProjectionOutcome::Success { cursor, kind, blob, bin_blob } => {
14666 if terminal_state_for_cursor(&tx, *cursor)?.is_some() {
14667 continue;
14668 }
14669 // Build the threshold decision in the transaction-local
14670 // candidate. The shared accumulator changes only after commit.
14671 let pin_mean: Option<Vec<f32>> = if mc && current_mean.is_none() {
14672 match candidate_accumulator.as_mut() {
14673 Some(a) => {
14674 a.add(&decode_vector_blob(bin_blob));
14675 if a.count() >= MEAN_VEC_PIN_THRESHOLD {
14676 let mean = a.materialize();
14677 candidate_accumulator = None;
14678 Some(mean)
14679 } else {
14680 None
14681 }
14682 }
14683 None => None,
14684 }
14685 } else {
14686 None
14687 };
14688
14689 let source_type = resolve_source_type(kind).map_err(|_| {
14690 rusqlite::Error::SqliteFailure(
14691 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
14692 Some(format!("unknown kind for source_type mapping: {kind}")),
14693 )
14694 })?;
14695 let now_unix =
14696 SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
14697 as i64;
14698 tx.execute(
14699 "INSERT OR IGNORE INTO _fathomdb_vector_rows(rowid, kind, write_cursor) VALUES(?1, ?2, ?3)",
14700 params![cursor, kind, cursor],
14701 )?;
14702 // EU-5a2/EU-5f — sign-quant input is the mean-subtracted
14703 // vector iff a mean is live (`current_mean`); otherwise the
14704 // un-centered `bin_blob`. A row inserted just before the
14705 // crossing is centered retroactively by the re-quantize
14706 // pass below.
14707 let centered_blob: Vec<u8> = match ¤t_mean {
14708 Some(mean) if mean.len() * 4 == bin_blob.len() => {
14709 encode_vector_blob(&subtract_mean(&decode_vector_blob(bin_blob), mean))
14710 }
14711 _ => bin_blob.clone(),
14712 };
14713 // Slice 10 / G10 — `status` ships the empty-string sentinel (vec0
14714 // TEXT metadata is NOT NULL-able); no real population source yet
14715 // (reserved-gap candidate 13).
14716 //
14717 // 0.8.20 Slice 15e — when the live `vector_default` carries
14718 // `filterable` `attr_<hex>` columns, EVERY column must be bound
14719 // (vec0 rejects a partial-column INSERT). Bind each from the node
14720 // body's scalar extraction (or the `''` sentinel). The body is not
14721 // carried on the embed job, so it is read from `canonical_nodes` by
14722 // `write_cursor` (== rowid) — but ONLY when attr columns exist, so
14723 // the common no-filterable hot path stays byte-identical and does
14724 // NO extra lookup.
14725 if actual_vector_attr_columns(&tx)?.is_empty() {
14726 tx.execute(
14727 "INSERT OR IGNORE INTO vector_default(
14728 rowid, embedding, embedding_bin, source_type, kind, created_at, status
14729 ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, '')",
14730 params![cursor, blob, centered_blob, source_type, kind, now_unix],
14731 )?;
14732 } else {
14733 let body: String = tx
14734 .query_row(
14735 "SELECT body FROM canonical_nodes WHERE write_cursor = ?1 LIMIT 1",
14736 [*cursor as i64],
14737 |row| row.get(0),
14738 )
14739 .optional()?
14740 .unwrap_or_default();
14741 let (cols_sql, ph_sql, attr_vals) =
14742 vector_attr_insert_fragments(&tx, &body, 7)?;
14743 let sql = format!(
14744 "INSERT OR IGNORE INTO vector_default(
14745 rowid, embedding, embedding_bin, source_type, kind, created_at, status{cols_sql}
14746 ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, ''{ph_sql})"
14747 );
14748 let mut pv: Vec<rusqlite::types::Value> = vec![
14749 rusqlite::types::Value::Integer(*cursor as i64),
14750 rusqlite::types::Value::Blob(blob.clone()),
14751 rusqlite::types::Value::Blob(centered_blob.clone()),
14752 rusqlite::types::Value::Text(source_type.to_string()),
14753 rusqlite::types::Value::Text(kind.to_string()),
14754 rusqlite::types::Value::Integer(now_unix),
14755 ];
14756 pv.extend(attr_vals);
14757 tx.execute(&sql, rusqlite::params_from_iter(pv.iter()))?;
14758 }
14759 record_projection_terminal(&tx, *cursor, "up_to_date")?;
14760
14761 // EU-5f — this row crossed the threshold: pin the mean and
14762 // re-quantize every row written so far (incl. earlier rows
14763 // in this same tx, which are visible to the SELECT) within
14764 // the same transaction so the pin is atomic.
14765 if let Some(mean) = pin_mean {
14766 tx.execute(
14767 "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
14768 params![encode_vector_blob(&mean)],
14769 )?;
14770 let rows: Vec<(i64, Vec<u8>)> = {
14771 let mut statement = tx.prepare(
14772 "SELECT rowid, embedding FROM vector_default ORDER BY rowid",
14773 )?;
14774 let mapped = statement.query_map([], |row| {
14775 Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?))
14776 })?;
14777 let mut out = Vec::new();
14778 for r in mapped {
14779 out.push(r?);
14780 }
14781 out
14782 };
14783 let (doc_count, _) =
14784 run_pin_and_requantize_pass(&tx, &rows, &mean).map_err(|_| {
14785 rusqlite::Error::SqliteFailure(
14786 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
14787 Some("mean-centering re-quantize pass failed".to_string()),
14788 )
14789 })?;
14790 staged_events.push(EmbedderEvent::MeanVecPinned {
14791 dim: u32::try_from(mean.len()).unwrap_or(u32::MAX),
14792 doc_count,
14793 });
14794 current_mean = Some(mean);
14795 }
14796 }
14797 ProjectionOutcome::Failure { cursor, failure_code } => {
14798 if terminal_state_for_cursor(&tx, *cursor)?.is_some() {
14799 continue;
14800 }
14801 let existing: u64 = tx.query_row(
14802 "SELECT COUNT(*) FROM operational_mutations
14803 WHERE collection_name = 'projection_failures'
14804 AND json_extract(payload_json, '$.write_cursor') = ?1",
14805 [cursor],
14806 |row| row.get(0),
14807 )?;
14808 if existing == 0 {
14809 let payload = format!(
14810 r#"{{"write_cursor":{cursor},"failure_code":"{failure_code}","recorded_at":0}}"#
14811 );
14812 tx.execute(
14813 "INSERT INTO operational_mutations(
14814 collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
14815 ) VALUES('projection_failures', ?1, 'append', ?2, NULL, ?3)",
14816 params![cursor.to_string(), payload, cursor],
14817 )?;
14818 }
14819 record_projection_terminal(&tx, *cursor, "failed")?;
14820 }
14821 // 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — record NOTHING.
14822 //
14823 // No `projection_failures` audit row (an ABSENT embedder is an
14824 // environment fact, not an embed failure) and, decisively, no
14825 // terminal: the row keeps `terminal IS NULL`, so
14826 // `advance_projection_cursor` below cannot step over it, the shared
14827 // `connection_has_pending_projection_work` predicate still reports it
14828 // outstanding, and `derive_dense_readiness` therefore reads
14829 // `embedding`. That is the ONLY torn state
14830 // `dev/design/record-lifecycle-protocol/projection-registry-and-async-embed.md`
14831 // §4.1 invariant 1 tolerates; the alternative — the enqueue-side gate
14832 // — puts an `'up_to_date'` terminal on an ENROLLED row with no
14833 // vector, which is the torn `ready` that invariant calls FORBIDDEN.
14834 //
14835 // Q6a graceful-absent governs ROLE DECLARATION ("you declared a
14836 // projection I cannot build yet" -> defer + graft), i.e. the
14837 // NOT-yet-enrolled case fix-1/fix-2 handle. Once a kind IS enrolled,
14838 // §4.1 invariant 1 governs. (HITL ruling, 0.8.20 Slice 20c fix-4.)
14839 //
14840 // Consumer-visible consequence, accepted deliberately and pinned by
14841 // `slice20c_flush_barrier`: for the REST of that no-embedder session
14842 // `dense_readiness` stays `embedding` and `drain` burns its timeout
14843 // into `EngineError::Scheduler`. Loud and recoverable, rather than
14844 // silent and lost.
14845 ProjectionOutcome::Deferred => {}
14846 }
14847 }
14848 // 0.7.2 PR-2bc S2 — the AUTOMATIC in-ingest drift detector (EWMA recent
14849 // mean + cos-threshold + debounce + 200k cap + `MeanRecomputeDeferred`)
14850 // was CARVED OUT and DEFERRED to 0.8.x; its recall premise was refuted
14851 // (the mean is a non-lever) and the benefit is unmeasured. The mean is
14852 // refreshed only on demand via `Engine::recompute_mean` (the
14853 // `doctor recompute-mean` verb). See `dev/design/embedder.md` §0.3 and
14854 // `dev/plans/prompts/0.8.x-auto-mean-drift-DEFERRED.md`. Nothing here
14855 // mutates `mean_vec` after the initial pin.
14856
14857 advance_projection_cursor(&tx)?;
14858 #[cfg(debug_assertions)]
14859 match shared.force_projection_commit_failure.swap(0, Ordering::SeqCst) {
14860 1 => {
14861 return Err(rusqlite::Error::SqliteFailure(
14862 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
14863 Some("forced projection commit failure".to_string()),
14864 ));
14865 }
14866 2 => return Err(rusqlite::Error::InvalidQuery),
14867 _ => {}
14868 }
14869 tx.commit()?;
14870 // Commit and the accumulator transition become visible together. On every
14871 // earlier error the transaction and the local candidate drop, leaving the
14872 // runtime state exactly as it was before this attempt.
14873 *shared_accumulator = candidate_accumulator;
14874 // EU-5f — publish MeanVecPinned only after the pin tx is durable, so a
14875 // rolled-back pin never emits a spurious event.
14876 if !staged_events.is_empty() {
14877 if let Ok(mut events) = shared.pending_events.lock() {
14878 events.extend(staged_events);
14879 }
14880 }
14881 Ok(())
14882}
14883
14884/// EU-5f — open-time recovery pin (`dev/design/embedder.md` §0.3, Hazard 4).
14885/// Derives the corpus mean from the existing un-centered `vector_default`
14886/// rows, pins it, and re-quantizes every row, all in one transaction on the
14887/// single-threaded open connection (no workers running yet, so no gate is
14888/// needed). Called only when MC is required, no mean is pinned, and the row
14889/// count already meets the threshold.
14890fn recover_mean_vec_pin(
14891 connection: &mut Connection,
14892 identity: &EmbedderIdentity,
14893) -> Result<(), EngineError> {
14894 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
14895 recompute_mean_in_tx(&tx, identity)?;
14896 tx.commit().map_err(|_| EngineError::Storage)?;
14897 Ok(())
14898}
14899
14900/// 0.7.2 PR-2b — shared mean (re)compute core, run INSIDE the caller's
14901/// transaction. Derives the FULL-corpus mean from the un-centered
14902/// `vector_default.embedding` BLOBs, writes `mean_vec`, and re-quantizes
14903/// EVERY row via the existing [`run_pin_and_requantize_pass`] so no row is
14904/// left under a stale centering.
14905///
14906/// This generalizes the EU-5f open-time recovery pin: it has NO "no mean
14907/// pinned yet" guard, so it equally serves the FIRST pin (recovery) and a
14908/// REFRESH of an already-pinned mean (PR-2b drift / `doctor recompute-mean`).
14909/// The caller owns the transaction boundary, which is what makes a fault
14910/// between the `mean_vec` UPDATE and re-quantize completion roll back
14911/// wholesale (`dev/design/embedder.md` §0.5 atomicity). It does NOT publish
14912/// any event — that is the caller's job, strictly post-durable-commit.
14913fn recompute_mean_in_tx(
14914 tx: &rusqlite::Transaction<'_>,
14915 identity: &EmbedderIdentity,
14916) -> Result<MeanRecomputeReport, EngineError> {
14917 recompute_mean_in_tx_inner(tx, identity, false)
14918}
14919
14920/// 0.7.2 PR-2b — recompute core with an optional fault-injection point. The
14921/// `fail_after_mean_update` flag (debug builds only, set via a test seam)
14922/// errors AFTER the `mean_vec` UPDATE but BEFORE the re-quantize completes,
14923/// so the caller's tx rolls back the partial recentering.
14924fn recompute_mean_in_tx_inner(
14925 tx: &rusqlite::Transaction<'_>,
14926 identity: &EmbedderIdentity,
14927 fail_after_mean_update: bool,
14928) -> Result<MeanRecomputeReport, EngineError> {
14929 let started = Instant::now();
14930 let dim = identity.dimension as usize;
14931 // The previously-pinned mean (if any) is read first so we can report
14932 // the pre-recompute drift cosine.
14933 let old_mean = read_pinned_mean_vec(tx, identity.dimension)?;
14934 let rows: Vec<(i64, Vec<u8>)> = {
14935 let mut statement = tx
14936 .prepare("SELECT rowid, embedding FROM vector_default ORDER BY rowid")
14937 .map_err(|_| EngineError::Storage)?;
14938 let mapped = statement
14939 .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)))
14940 .map_err(|_| EngineError::Storage)?;
14941 let mut out = Vec::new();
14942 for r in mapped {
14943 out.push(r.map_err(|_| EngineError::Storage)?);
14944 }
14945 out
14946 };
14947 let mut accumulator = MeanAccumulator::new(dim);
14948 for (_rowid, blob) in &rows {
14949 if blob.len() != dim * 4 {
14950 return Err(EngineError::Storage);
14951 }
14952 accumulator.add(&decode_vector_blob(blob));
14953 }
14954 let old_doc_count = accumulator.count();
14955 let mean = accumulator.materialize();
14956 let drift_cos_before = match &old_mean {
14957 Some(old) => cosine_similarity(&mean, old),
14958 None => 1.0,
14959 };
14960 tx.execute(
14961 "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
14962 params![encode_vector_blob(&mean)],
14963 )
14964 .map_err(|_| EngineError::Storage)?;
14965 if fail_after_mean_update {
14966 // Injected fault: bail before re-quantizing so the caller's tx
14967 // rolls back the `mean_vec` UPDATE too (crash-atomicity proof).
14968 return Err(EngineError::Storage);
14969 }
14970 let (doc_count, _) = run_pin_and_requantize_pass(tx, &rows, &mean)?;
14971 Ok(MeanRecomputeReport {
14972 dim: u32::try_from(dim).unwrap_or(u32::MAX),
14973 old_doc_count,
14974 doc_count_requantized: doc_count,
14975 drift_cos_before,
14976 mean_was_pinned: old_mean.is_some(),
14977 elapsed_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
14978 })
14979}
14980
14981/// Cap sweep over the op-store mutation log: keeps the newest `cap` SWEEPABLE
14982/// rows, dropping the oldest by `id`.
14983///
14984/// **Pending-redaction exemption (0.8.20 Slice 5 fix-1).**
14985/// [`ERASURE_PENDING_REDACTION_COLLECTION`] is exempt on the same principle for a
14986/// stronger reason: that row is not a record of a discharged obligation but an
14987/// UNDISCHARGED one. Sweeping it would silently drop an erasure the engine still
14988/// owes, and the next retry would then report success with the leaked stable ids
14989/// still in the telemetry sink — exactly the R-20-E5 violation this mechanism
14990/// exists to prevent.
14991///
14992/// **Erasure-audit exemption (0.8.20 Slice 5b, design v5 §2 defect D-A;
14993/// HITL-ruled 2026-07-19: *"there must be an auditable record of deletion
14994/// event."*).** Rows in [`ERASURE_AUDIT_COLLECTIONS`] are excluded from BOTH the
14995/// count and the DELETE, and are therefore **never removed by retention
14996/// pressure**. Previously this swept `operational_mutations` cap-first,
14997/// oldest-`id`-first, with no collection filter — so the `excise_source_audit`
14998/// row proving an erasure occurred shared one retention pool with the very
14999/// payloads it must prove erased, and (being written before whatever workload
15000/// followed) was among the first evicted. Accountability is a distinct
15001/// obligation from erasure; a sweep must not silently discharge it.
15002///
15003/// Consequence of excluding audit rows from the count: `cap` is a cap on
15004/// SWEEPABLE rows, not on the physical table size. That is deliberate — the
15005/// alternative (counting exempt rows toward the cap) would let a growing audit
15006/// trail evict ordinary provenance ever more aggressively, and in the limit
15007/// leave nothing sweepable while the sweep churned every write.
15008fn enforce_provenance_retention(connection: &Connection, cap: u64) -> rusqlite::Result<()> {
15009 if cap == 0 {
15010 return Ok(());
15011 }
15012 // Static, engine-internal identifiers — no caller input reaches this SQL.
15013 let exempt = ERASURE_AUDIT_COLLECTIONS
15014 .iter()
15015 .copied()
15016 .chain(std::iter::once(ERASURE_PENDING_REDACTION_COLLECTION))
15017 .map(|name| format!("'{name}'"))
15018 .collect::<Vec<_>>()
15019 .join(", ");
15020 let slack = cap.max(20) / 20;
15021 let upper = cap.saturating_add(slack.max(1));
15022 let count: u64 = connection.query_row(
15023 &format!(
15024 "SELECT COUNT(*) FROM operational_mutations
15025 WHERE collection_name NOT IN ({exempt})"
15026 ),
15027 [],
15028 |row| row.get(0),
15029 )?;
15030 if count <= upper {
15031 return Ok(());
15032 }
15033 let to_delete = count.saturating_sub(cap);
15034 connection.execute(
15035 &format!(
15036 "DELETE FROM operational_mutations
15037 WHERE id IN (
15038 SELECT id FROM operational_mutations
15039 WHERE collection_name NOT IN ({exempt})
15040 ORDER BY id
15041 LIMIT ?1
15042 )"
15043 ),
15044 [to_delete],
15045 )?;
15046 Ok(())
15047}
15048
15049/// 0.8.20 Slice 5b (R-20-E6) — the prefixed stable ids
15050/// ([`IdSpace::to_prefixed`]) of the canonical rows an erasure verb is about to
15051/// delete, so they can be redacted from the telemetry sink.
15052///
15053/// Must be called INSIDE the erasing transaction and BEFORE the DELETEs — after
15054/// them the rows, and with them the `logical_id`/`body` the ids derive from, are
15055/// gone. Both queries take one bound parameter (`?1`), applied to nodes and
15056/// edges respectively; `derive_stable_id` reproduces exactly what
15057/// `capture_telemetry` wrote into `result_stable_ids`.
15058fn collect_erased_stable_ids(
15059 tx: &Connection,
15060 node_sql: &str,
15061 edge_sql: &str,
15062 bind: &str,
15063) -> Result<Vec<String>, EngineError> {
15064 let mut ids = Vec::new();
15065 for sql in [node_sql, edge_sql] {
15066 let mut stmt = tx.prepare(sql).map_err(|_| EngineError::Storage)?;
15067 let rows = stmt
15068 .query_map(params![bind], |row| {
15069 Ok((row.get::<_, Option<String>>(0)?, row.get::<_, Option<String>>(1)?))
15070 })
15071 .map_err(|_| EngineError::Storage)?;
15072 for row in rows {
15073 let (logical_id, body) = row.map_err(|_| EngineError::Storage)?;
15074 ids.push(
15075 derive_stable_id(logical_id.as_deref(), body.as_deref().unwrap_or(""))
15076 .to_prefixed(),
15077 );
15078 }
15079 }
15080 ids.sort_unstable();
15081 ids.dedup();
15082 Ok(ids)
15083}
15084
15085/// 0.8.20 Slice 5 fix-1 (codex §9 P2) — record, INSIDE the erasing transaction,
15086/// that a telemetry redaction is owed for `erased_stable_ids`.
15087///
15088/// Must be called in the same transaction as the DELETEs. That is the whole
15089/// point: "the rows are gone" and "a redaction is owed for them" then commit
15090/// atomically, so no crash or failure can leave the first true and the second
15091/// unrecorded. [`Engine::discharge_pending_redactions`] drains the queue and
15092/// deletes the entry only once the sink has actually been rewritten.
15093///
15094/// `record_key` is the VERB, never a stable id — the ids live in the payload,
15095/// which is deleted on discharge.
15096fn enqueue_pending_redaction(
15097 tx: &Connection,
15098 verb: &str,
15099 erased_stable_ids: &[String],
15100 write_cursor: u64,
15101) -> Result<(), EngineError> {
15102 if erased_stable_ids.is_empty() {
15103 return Ok(());
15104 }
15105 let payload =
15106 serde_json::json!({ "verb": verb, "erased_stable_ids": erased_stable_ids }).to_string();
15107 tx.execute(
15108 "INSERT INTO operational_mutations(
15109 collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
15110 ) VALUES(?1, ?2, 'append', ?3, NULL, ?4)",
15111 params![ERASURE_PENDING_REDACTION_COLLECTION, verb, payload, write_cursor],
15112 )
15113 .map_err(|_| EngineError::Storage)?;
15114 Ok(())
15115}
15116
15117/// 0.8.20 Slice 5b (R-20-E7) — the audit handle for an erased op-store record:
15118/// `SHA-256(collection + 0x1F + record_key)`, lowercase hex.
15119///
15120/// A record key is arbitrary caller-supplied text and may itself be the
15121/// identifier being erased, so a durable audit row must not echo it. `0x1F`
15122/// (ASCII unit separator) is the delimiter because it cannot appear in a
15123/// well-formed collection name, keeping the pairing unambiguous.
15124#[cfg(feature = "operator")]
15125fn digest_record_identity(collection: &str, record_key: &str) -> String {
15126 let mut hasher = Sha256::new();
15127 hasher.update(collection.as_bytes());
15128 hasher.update([0x1f_u8]);
15129 hasher.update(record_key.as_bytes());
15130 hasher.finalize().iter().map(|b| format!("{b:02x}")).collect()
15131}
15132
15133fn projection_status(
15134 connection: &Connection,
15135 kind: &str,
15136) -> Result<lifecycle::ProjectionStatus, EngineError> {
15137 let latest = connection
15138 .query_row(
15139 "SELECT COALESCE(MAX(write_cursor), 0) FROM canonical_nodes WHERE kind = ?1",
15140 [kind],
15141 |row| row.get::<_, u64>(0),
15142 )
15143 .map_err(|_| EngineError::Storage)?;
15144 if latest == 0 {
15145 return Ok(lifecycle::ProjectionStatus::UpToDate);
15146 }
15147 let pending: u64 = connection
15148 .query_row(
15149 "SELECT COUNT(*)
15150 FROM canonical_nodes
15151 LEFT JOIN _fathomdb_projection_terminal
15152 ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
15153 WHERE canonical_nodes.kind = ?1
15154 AND _fathomdb_projection_terminal.write_cursor IS NULL",
15155 [kind],
15156 |row| row.get(0),
15157 )
15158 .map_err(|_| EngineError::Storage)?;
15159 if pending > 0 {
15160 return Ok(lifecycle::ProjectionStatus::Pending);
15161 }
15162 match terminal_state_for_cursor(connection, latest).map_err(|_| EngineError::Storage)? {
15163 Some(state) if state == "failed" => Ok(lifecycle::ProjectionStatus::Failed),
15164 _ => Ok(lifecycle::ProjectionStatus::UpToDate),
15165 }
15166}
15167
15168fn canonical_database_path(path: &Path) -> Result<PathBuf, EngineOpenError> {
15169 let parent = path
15170 .parent()
15171 .filter(|parent| !parent.as_os_str().is_empty())
15172 .unwrap_or_else(|| Path::new("."));
15173 let canonical_parent = parent.canonicalize().map_err(|_| EngineOpenError::Io {
15174 message: "database parent directory is not accessible".to_string(),
15175 })?;
15176 let file_name = path.file_name().ok_or_else(|| EngineOpenError::Io {
15177 message: "database path has no file name".to_string(),
15178 })?;
15179
15180 Ok(canonical_parent.join(file_name))
15181}
15182
15183fn acquire_lock(path: &Path) -> Result<File, EngineOpenError> {
15184 let lock_path = lock_path(path);
15185 let mut options = OpenOptions::new();
15186 options.read(true).write(true).create(true);
15187 #[cfg(unix)]
15188 options.mode(0o600);
15189
15190 let mut file = options.open(&lock_path).map_err(|_| EngineOpenError::Io {
15191 message: "could not open database lock file".to_string(),
15192 })?;
15193
15194 match file.try_lock() {
15195 Ok(()) => {
15196 let pid = std::process::id().to_string();
15197 let _ = file.set_len(0);
15198 let _ = file.seek(SeekFrom::Start(0));
15199 let _ = file.write_all(pid.as_bytes());
15200 Ok(file)
15201 }
15202 Err(std::fs::TryLockError::WouldBlock) => {
15203 Err(EngineOpenError::DatabaseLocked { holder_pid: read_holder_pid(&lock_path) })
15204 }
15205 Err(_) => {
15206 Err(EngineOpenError::Io { message: "could not acquire database lock".to_string() })
15207 }
15208 }
15209}
15210
15211fn lock_path(path: &Path) -> PathBuf {
15212 let mut lock_path = path.as_os_str().to_os_string();
15213 lock_path.push(LOCK_SUFFIX);
15214 PathBuf::from(lock_path)
15215}
15216
15217fn read_holder_pid(path: &Path) -> Option<u32> {
15218 std::fs::read_to_string(path).ok()?.trim().parse().ok()
15219}
15220
15221fn map_migration_error(err: SchemaMigrationError) -> EngineOpenError {
15222 match err {
15223 SchemaMigrationError::IncompatibleSchemaVersion { seen, supported } => {
15224 EngineOpenError::IncompatibleSchemaVersion { seen, supported }
15225 }
15226 SchemaMigrationError::MigrationError(report) => EngineOpenError::MigrationError {
15227 schema_version_before: report.schema_version_before,
15228 schema_version_current: report.schema_version_current,
15229 step_id: report.migration_steps.last().map_or(0, |step| step.step_id),
15230 },
15231 SchemaMigrationError::Storage { message } => {
15232 EngineOpenError::Io { message: message.to_string() }
15233 }
15234 }
15235}
15236
15237/// 0.7.0 perf-experiments hook: process-start `sqlite3_config` calls.
15238/// Runs exactly once per process; must precede any `Connection::open`.
15239/// Gated on `FATHOMDB_PERF_EXPERIMENTS=1`. Each individual config
15240/// option is opt-in via its own env var so unrelated experiments do
15241/// not implicitly co-fire.
15242///
15243/// Currently supports:
15244/// - `FATHOMDB_PERF_SQLITE_MEMSTATUS_OFF=1`:
15245/// `sqlite3_config(SQLITE_CONFIG_MEMSTATUS, 0)` — drops the
15246/// allocator stats locking surface (whitepaper § 7.4). Composes
15247/// with other levers; small payoff alone.
15248///
15249/// Pattern: shutdown → config → initialize, mirroring B.1 attempt #2
15250/// (`d448263`, reverted). The captured rc for each config call is
15251/// logged to stderr so experiments can verify the call took effect.
15252fn init_perf_experiments_runtime() {
15253 static INIT: Once = Once::new();
15254 INIT.call_once(|| {
15255 if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
15256 return;
15257 }
15258 let memstatus_off =
15259 std::env::var_os("FATHOMDB_PERF_SQLITE_MEMSTATUS_OFF").is_some_and(|v| v == "1");
15260 // FATHOMDB_PERF_SQLITE_PAGECACHE=<page_size_bytes>:<page_count>
15261 // E.g. "4096:5000" => pre-allocate 4096 B × 5000 pages = 20 MB
15262 // global page-cache backing. SQLite distributes this across
15263 // connections; reduces global allocator pressure for page
15264 // cache fills.
15265 let pagecache = std::env::var("FATHOMDB_PERF_SQLITE_PAGECACHE").ok();
15266 // FATHOMDB_PERF_SQLITE_PCACHE2=1 installs the per-instance
15267 // custom page-cache allocator (pcache2.rs). Targets AC-020
15268 // residual contention on the default pcache1 mutex.
15269 let pcache2_on =
15270 std::env::var_os("FATHOMDB_PERF_SQLITE_PCACHE2").is_some_and(|v| v == "1");
15271 if !memstatus_off && pagecache.is_none() && !pcache2_on {
15272 return;
15273 }
15274 // SAFETY: sqlite3_shutdown / sqlite3_initialize are documented
15275 // as safe to call before any other SQLite API; sqlite3_config
15276 // must be called between shutdown and initialize. We pre-empt
15277 // rusqlite's lazy first-call sqlite3_initialize via this
15278 // explicit shutdown-then-config-then-initialize sequence,
15279 // identical to B.1 attempt #2's plumbing.
15280 unsafe {
15281 let rc_shutdown = rusqlite::ffi::sqlite3_shutdown();
15282 let rc_memstatus = if memstatus_off {
15283 rusqlite::ffi::sqlite3_config(rusqlite::ffi::SQLITE_CONFIG_MEMSTATUS, 0_i32)
15284 } else {
15285 -1
15286 };
15287 // SQLITE_CONFIG_PAGECACHE = 7 per sqlite3.h. With buffer=NULL,
15288 // SQLite allocates the backing memory itself but still
15289 // partitions it for use as the page-cache pool.
15290 let rc_pagecache = if let Some(spec) = pagecache.as_ref() {
15291 let mut parts = spec.split(':');
15292 let sz = parts.next().and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
15293 let n = parts.next().and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
15294 if sz > 0 && n > 0 {
15295 rusqlite::ffi::sqlite3_config(
15296 7, // SQLITE_CONFIG_PAGECACHE
15297 std::ptr::null_mut::<std::ffi::c_void>(),
15298 sz,
15299 n,
15300 )
15301 } else {
15302 eprintln!(
15303 "perf-experiment: bad FATHOMDB_PERF_SQLITE_PAGECACHE spec '{spec}' (expect '<bytes>:<count>')"
15304 );
15305 -1
15306 }
15307 } else {
15308 -1
15309 };
15310 let rc_pcache2 = if pcache2_on {
15311 // SQLITE_CONFIG_PCACHE2 = 18 per sqlite3.h. The methods
15312 // table must outlive the SQLite engine; we pass a
15313 // pointer to our static.
15314 rusqlite::ffi::sqlite3_config(
15315 rusqlite::ffi::SQLITE_CONFIG_PCACHE2,
15316 &raw const pcache2::PCACHE2_METHODS.0,
15317 )
15318 } else {
15319 -1
15320 };
15321 let rc_init = rusqlite::ffi::sqlite3_initialize();
15322 eprintln!(
15323 "perf-experiment: runtime-config rcs shutdown={rc_shutdown} \
15324 memstatus={rc_memstatus} pagecache={rc_pagecache} pcache2={rc_pcache2} \
15325 initialize={rc_init} (0=SQLITE_OK; 21=SQLITE_MISUSE; -1=not configured)"
15326 );
15327 }
15328 });
15329}
15330
15331fn register_sqlite_vec_extension() {
15332 static REGISTER: Once = Once::new();
15333 REGISTER.call_once(|| unsafe {
15334 let entrypoint: unsafe extern "C" fn(
15335 *mut rusqlite::ffi::sqlite3,
15336 *mut *mut std::os::raw::c_char,
15337 *const rusqlite::ffi::sqlite3_api_routines,
15338 ) -> std::os::raw::c_int = std::mem::transmute(sqlite3_vec_init as *const ());
15339 rusqlite::ffi::sqlite3_auto_extension(Some(entrypoint));
15340 });
15341}
15342
15343fn probe_open_integrity(connection: &Connection) -> Result<(), EngineOpenError> {
15344 // `SELECT COUNT(*) FROM sqlite_schema` forces a full traversal of the
15345 // sqlite_schema b-tree; this surfaces page-1 b-tree corruption that a
15346 // bare `PRAGMA schema_version` (which only reads the schema cookie
15347 // out of the file header) would miss.
15348 connection
15349 .query_row("SELECT COUNT(*) FROM sqlite_schema", [], |row| row.get::<_, i64>(0))
15350 .map(|_| ())
15351 .map_err(|err| map_open_sqlite_error(err, OpenStage::SchemaProbe))
15352}
15353
15354fn probe_database_header(connection: &Connection) -> Result<(), EngineOpenError> {
15355 connection
15356 .query_row("PRAGMA application_id", [], |row| row.get::<_, i64>(0))
15357 .map(|_| ())
15358 .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))
15359}
15360
15361/// Pre-`pragma WAL` sidecar validation. SQLite silently discards a WAL
15362/// file whose header magic is wrong or whose advertised page size is
15363/// outside `[512, SQLITE_MAX_PAGE_SIZE]`, which would cause us to lose
15364/// committed frames at open time. AC-035a requires that we instead
15365/// refuse to open with `Corruption(WalReplayFailure)` rather than
15366/// silently rebuild from a truncated WAL.
15367fn probe_wal_sidecar(db_path: &Path) -> Result<(), EngineOpenError> {
15368 let mut wal_path = db_path.as_os_str().to_owned();
15369 wal_path.push("-wal");
15370 let wal_path = PathBuf::from(wal_path);
15371 // Bounded read: the WAL header is fixed-layout in the first 32
15372 // bytes (magic + format + page-size + checkpoint-seq + salts +
15373 // checksums); frame data starts at offset 32 and is irrelevant to
15374 // the magic + page-size pre-check. A `std::fs::read` of the whole
15375 // sidecar would force an unclean-shutdown open path to allocate
15376 // and copy the entire WAL into memory before SQLite touches
15377 // recovery — a real latency + RSS regression on AC-035.
15378 use std::io::Read;
15379 let mut file = match std::fs::File::open(&wal_path) {
15380 Ok(file) => file,
15381 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
15382 Err(_) => return Ok(()),
15383 };
15384 let mut bytes = [0u8; 32];
15385 if file.read_exact(&mut bytes).is_err() {
15386 // A short (< 32-byte) sidecar carries no committed frames;
15387 // SQLite treats it as empty and re-initializes WAL state.
15388 return Ok(());
15389 }
15390 let magic = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
15391 let page_size = u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
15392 // WAL_MAGIC mask per SQLite `walIndexRecover`: low bit distinguishes
15393 // big-endian vs little-endian checksum encoding; the rest of the
15394 // magic is fixed.
15395 const WAL_MAGIC_MASK: u32 = 0xFFFF_FFFE;
15396 const WAL_MAGIC: u32 = 0x377F_0682;
15397 const SQLITE_MAX_PAGE_SIZE: u32 = 65536;
15398 let magic_ok = (magic & WAL_MAGIC_MASK) == WAL_MAGIC;
15399 let page_size_ok =
15400 page_size.is_power_of_two() && (512..=SQLITE_MAX_PAGE_SIZE).contains(&page_size);
15401 if magic_ok && page_size_ok {
15402 return Ok(());
15403 }
15404 Err(EngineOpenError::Corruption(CorruptionDetail {
15405 kind: CorruptionKind::WalReplayFailure,
15406 stage: OpenStage::WalReplay,
15407 locator: CorruptionLocator::FileOffset { offset: if !magic_ok { 0 } else { 8 } },
15408 recovery_hint: RecoveryHint {
15409 code: "E_CORRUPT_WAL_REPLAY",
15410 doc_anchor: "design/recovery.md#wal-replay-failures",
15411 },
15412 }))
15413}
15414
15415fn reject_legacy_shape(connection: &Connection) -> Result<(), EngineOpenError> {
15416 let has_legacy_table = table_exists(connection, "fathom_nodes")
15417 || table_exists(connection, "fathom_edges")
15418 || table_exists(connection, "fathom_chunks");
15419 if !has_legacy_table {
15420 return Ok(());
15421 }
15422
15423 let seen =
15424 connection.query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)).unwrap_or(0);
15425 Err(EngineOpenError::IncompatibleSchemaVersion { seen, supported: SCHEMA_VERSION })
15426}
15427
15428fn table_exists(connection: &Connection, table: &str) -> bool {
15429 connection
15430 .query_row(
15431 "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?1",
15432 [table],
15433 |_row| Ok(()),
15434 )
15435 .is_ok()
15436}
15437
15438#[cfg(feature = "operator")]
15439fn read_schema_objects(
15440 connection: &Connection,
15441 obj_type: &str,
15442) -> Result<Vec<SchemaObject>, EngineError> {
15443 let mut stmt = connection
15444 .prepare(
15445 "SELECT name, sql FROM sqlite_schema
15446 WHERE type = ?1 AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL
15447 ORDER BY name",
15448 )
15449 .map_err(|_| EngineError::Storage)?;
15450 let rows = stmt
15451 .query_map([obj_type], |row| {
15452 Ok(SchemaObject { name: row.get::<_, String>(0)?, sql: row.get::<_, String>(1)? })
15453 })
15454 .map_err(|_| EngineError::Storage)?;
15455 let mut out = Vec::new();
15456 for row in rows {
15457 out.push(row.map_err(|_| EngineError::Storage)?);
15458 }
15459 Ok(out)
15460}
15461
15462#[cfg(feature = "operator")]
15463fn order_canonical_first(mut objects: Vec<SchemaObject>) -> Vec<SchemaObject> {
15464 let mut canonical: Vec<SchemaObject> = Vec::new();
15465 for name in CANONICAL_TABLES {
15466 if let Some(pos) = objects.iter().position(|o| o.name == *name) {
15467 canonical.push(objects.remove(pos));
15468 }
15469 }
15470 canonical.extend(objects);
15471 canonical
15472}
15473
15474fn load_default_profile(connection: &Connection) -> rusqlite::Result<EmbedderIdentity> {
15475 connection.query_row(
15476 "SELECT name, revision, dimension FROM _fathomdb_embedder_profiles WHERE profile = ?1",
15477 [DEFAULT_VECTOR_PROFILE],
15478 |row| {
15479 Ok(EmbedderIdentity::new(
15480 row.get::<_, String>(0)?,
15481 row.get::<_, String>(1)?,
15482 row.get::<_, u32>(2)?,
15483 ))
15484 },
15485 )
15486}
15487
15488fn default_profile_dimension(connection: &Connection) -> Result<u32, EngineError> {
15489 load_default_profile(connection)
15490 .map(|identity| identity.dimension)
15491 .map_err(|_| EngineError::Storage)
15492}
15493
15494fn kind_is_vector_indexed(connection: &Connection, kind: &str) -> Result<bool, EngineError> {
15495 connection
15496 .query_row("SELECT 1 FROM _fathomdb_vector_kinds WHERE kind = ?1", [kind], |_row| Ok(()))
15497 .map(|_| true)
15498 .or_else(|err| match err {
15499 rusqlite::Error::QueryReturnedNoRows => Ok(false),
15500 _ => Err(EngineError::Storage),
15501 })
15502}
15503
15504fn ensure_vector_partition(connection: &mut Connection, dimension: u32) -> rusqlite::Result<()> {
15505 // 0.7.0 Pack 1 schema per dev/design/0.7.0-vector-quant-pack1.md D1/D2:
15506 // f32 `embedding` + binary-quant sibling `embedding_bin` + `source_type`
15507 // partition key + `kind` + `created_at`. The vec0 column type is
15508 // dim-parameterized, so the reshape lives here rather than in the
15509 // SQL-only migration framework — see fathomdb-schema migration step 9
15510 // and dev/plans/runs/0.7.0-PVQ-P1-IMPL-output.json for the deviation
15511 // from the design memo's "Choose (a)" guidance.
15512 //
15513 // Three paths:
15514 // (1) no vector_default -> CREATE at new shape.
15515 // (2) old single-column shape -> stage + drop + recreate at new shape
15516 // + repopulate with vec_quantize_binary.
15517 // (3) already new shape -> no-op.
15518 let existing_sql: Option<String> = connection
15519 .query_row(
15520 "SELECT sql FROM sqlite_master WHERE type='table' AND name=?1",
15521 [DEFAULT_VECTOR_PARTITION],
15522 |row| row.get::<_, String>(0),
15523 )
15524 .optional()?;
15525
15526 // Slice 10 / G10 — 3-way shape-sentinel (fixes the prior
15527 // `contains("embedding_bin")` no-op that hid the `status` column from
15528 // existing Pack-1 DBs):
15529 // `status` present -> Pack-2 (current) shape, no-op.
15530 // `embedding_bin` present -> Pack-1 -> stage + recreate + back-fill status.
15531 // neither -> legacy single-column -> migrate to current.
15532 match existing_sql {
15533 None => create_vector_partition(connection, dimension),
15534 Some(sql) if sql.contains("status") => Ok(()),
15535 Some(sql) if sql.contains("embedding_bin") => {
15536 migrate_vector_partition_pack1_to_pack2(connection, dimension)
15537 }
15538 Some(_) => migrate_vector_partition_to_pack1(connection, dimension),
15539 }
15540}
15541
15542/// The current (Pack-2) `vector_default` vec0 shape. Slice 10 / G10 adds a plain
15543/// `status TEXT` metadata column — **not** aux (`+status`): aux columns
15544/// hard-error under a KNN `WHERE`, and the G10 filter constrains `status` in the
15545/// phase-1 KNN statement. `status` ships NULL plumbing only (no population source
15546/// yet).
15547///
15548/// 0.8.20 Slice 15e — `attr_cols` are the declared-`filterable` attribute columns
15549/// (byte-safe `attr_<hex>` identifiers, see [`attr_vec0_column`]), each a PLAIN
15550/// `TEXT` metadata column (never aux `+`), appended after `status`. **When
15551/// `attr_cols` is empty the produced SQL is byte-identical to the shipped shape**
15552/// — every existing caller passes `&[]`, so no shipped behaviour changes.
15553fn vector_partition_create_sql(
15554 dimension: u32,
15555 if_not_exists: bool,
15556 attr_cols: &[String],
15557) -> String {
15558 let guard = if if_not_exists { "IF NOT EXISTS " } else { "" };
15559 let mut attrs = String::new();
15560 for col in attr_cols {
15561 attrs.push_str(&format!(",{col} TEXT"));
15562 }
15563 format!(
15564 "CREATE VIRTUAL TABLE {guard}{DEFAULT_VECTOR_PARTITION} USING vec0(\
15565 embedding float[{dimension}],\
15566 embedding_bin bit[{dimension}],\
15567 source_type TEXT partition key,\
15568 kind TEXT,\
15569 created_at INTEGER,\
15570 status TEXT{attrs}\
15571 )"
15572 )
15573}
15574
15575fn create_vector_partition(connection: &Connection, dimension: u32) -> rusqlite::Result<()> {
15576 connection.execute_batch(&vector_partition_create_sql(dimension, true, &[]))
15577}
15578
15579/// 0.8.20 Slice 15e — encode an arbitrary registry attribute NAME into a vec0-safe
15580/// column identifier: `attr_` + lowercase hex of the name's UTF-8 bytes.
15581///
15582/// vec0 rejects quoted column identifiers, and a Slice-15d-validated attribute
15583/// name may contain spaces / unicode / `-`, so the raw name cannot be a column
15584/// identifier. Hex is injective (so the map is reversible by
15585/// [`decode_attr_vec0_column`]), matches `^attr_[0-9a-f]+$`, and can never collide
15586/// with a built-in metadata column (`embedding`, `embedding_bin`, `source_type`,
15587/// `kind`, `created_at`, `status` — none carry the `attr_` prefix followed by an
15588/// even-length hex string of the name).
15589fn attr_vec0_column(name: &str) -> String {
15590 let mut s = String::from("attr_");
15591 for b in name.as_bytes() {
15592 s.push_str(&format!("{b:02x}"));
15593 }
15594 s
15595}
15596
15597/// 0.8.20 Slice 15e — inverse of [`attr_vec0_column`]. Returns the original
15598/// attribute name for an `attr_<hex>` column, or `None` if `col` is not a
15599/// well-formed encoded attribute column (so the built-in metadata columns and any
15600/// vec0 shadow columns are skipped when enumerating a live table's attribute set).
15601fn decode_attr_vec0_column(col: &str) -> Option<String> {
15602 let hex = col.strip_prefix("attr_")?;
15603 if hex.is_empty() || hex.len() % 2 != 0 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
15604 return None;
15605 }
15606 let mut bytes = Vec::with_capacity(hex.len() / 2);
15607 let raw = hex.as_bytes();
15608 let mut i = 0;
15609 while i < raw.len() {
15610 let hi = (raw[i] as char).to_digit(16)?;
15611 let lo = (raw[i + 1] as char).to_digit(16)?;
15612 bytes.push((hi * 16 + lo) as u8);
15613 i += 2;
15614 }
15615 String::from_utf8(bytes).ok()
15616}
15617
15618/// 0.8.20 Slice 15e — the DESIRED `attr_<hex>` columns implied by the durable
15619/// projection registry: one per `filterable` projection, sorted by attribute name
15620/// (⇒ sorted by column, since hex encoding preserves byte order). This is the
15621/// derived-cache source the reshape reconciles the live vec0 shape against.
15622fn desired_vector_attr_columns(conn: &Connection) -> rusqlite::Result<Vec<String>> {
15623 let registry = load_projection_registry(conn)?;
15624 let mut cols: Vec<String> = registry
15625 .iter()
15626 .filter(|(_, stored)| stored.roles.contains(&ProjectionRole::Filterable))
15627 .map(|(name, _)| attr_vec0_column(name))
15628 .collect();
15629 cols.sort();
15630 Ok(cols)
15631}
15632
15633/// 0.8.20 Slice 15e — the `attr_<hex>` columns actually present on the live
15634/// `vector_default` vec0 table, parsed from its `CREATE VIRTUAL TABLE` SQL and
15635/// sorted. Empty when the table is absent. Parsing the SQL (rather than a PRAGMA)
15636/// keeps this robust across vec0 versions and shadow-table layouts.
15637fn actual_vector_attr_columns(conn: &Connection) -> rusqlite::Result<Vec<String>> {
15638 let sql: Option<String> = conn
15639 .query_row(
15640 "SELECT sql FROM sqlite_master WHERE type='table' AND name=?1",
15641 [DEFAULT_VECTOR_PARTITION],
15642 |row| row.get::<_, String>(0),
15643 )
15644 .optional()?;
15645 let Some(sql) = sql else {
15646 return Ok(Vec::new());
15647 };
15648 let mut cols: Vec<String> = Vec::new();
15649 // Tokenize on any non-identifier byte; a token is an attribute column iff it
15650 // decodes as a well-formed `attr_<hex>` identifier.
15651 let mut token = String::new();
15652 let flush = |token: &mut String, cols: &mut Vec<String>| {
15653 if !token.is_empty() {
15654 if decode_attr_vec0_column(token).is_some() && !cols.contains(token) {
15655 cols.push(token.clone());
15656 }
15657 token.clear();
15658 }
15659 };
15660 for ch in sql.chars() {
15661 if ch.is_ascii_alphanumeric() || ch == '_' {
15662 token.push(ch);
15663 } else {
15664 flush(&mut token, &mut cols);
15665 }
15666 }
15667 flush(&mut token, &mut cols);
15668 cols.sort();
15669 Ok(cols)
15670}
15671
15672/// TC-76 deletes one `vector_default` row by rowid. The bare vec0 regression
15673/// test proves sqlite-vec 0.1.9 removes long TEXT metadata without a workaround.
15674fn delete_vector_partition_row(conn: &Connection, rowid: i64) -> rusqlite::Result<usize> {
15675 conn.execute(&format!("DELETE FROM {DEFAULT_VECTOR_PARTITION} WHERE rowid = ?1"), [rowid])
15676}
15677
15678/// 0.8.20 Slice 15e — reconcile the live `vector_default` attribute columns with
15679/// the registry's `filterable` set (TC-46: HITL-ratified NON-DESTRUCTIVE reshape,
15680/// following the shipped `migrate_vector_partition_pack1_to_pack2` precedent).
15681///
15682/// Diffs the DESIRED columns (from the registry) against the ACTUAL columns (on
15683/// the live table). When they already match — which is EVERY idempotent
15684/// re-registration and every boot re-derive that replays the same set — this is a
15685/// pure no-op: no reshape, no re-insert, vec0 untouched (so boot never silently
15686/// wipes a corpus). When they differ, performs ONE non-destructive reshape.
15687///
15688/// Returns `true` iff a reshape was performed. Runs the DDL directly on the passed
15689/// connection/transaction (no nested transaction), so a caller already inside a
15690/// write transaction (`configure_projections`) gets the reshape atomically with
15691/// its registry mutation. A no-op (and returns `false`) when `vector_default` does
15692/// not exist (a DB opened without an embedder).
15693fn reconcile_vector_attr_columns(conn: &Connection, dimension: u32) -> rusqlite::Result<bool> {
15694 let table_exists: bool = conn
15695 .query_row(
15696 "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1",
15697 [DEFAULT_VECTOR_PARTITION],
15698 |_| Ok(true),
15699 )
15700 .optional()?
15701 .unwrap_or(false);
15702 if !table_exists {
15703 return Ok(false);
15704 }
15705 let desired = desired_vector_attr_columns(conn)?;
15706 let actual = actual_vector_attr_columns(conn)?;
15707 if desired == actual {
15708 return Ok(false);
15709 }
15710 reshape_vector_partition_nondestructive(conn, dimension, &desired, &actual, false)?;
15711 Ok(true)
15712}
15713
15714/// Rebuild vec0 attribute metadata from the canonical EAV projection while
15715/// preserving the existing column set. A changed nested source can leave the
15716/// `attr_<hex>` schema untouched while changing every value behind that column.
15717fn refresh_vector_attr_values(conn: &Connection, dimension: u32) -> rusqlite::Result<()> {
15718 let desired = desired_vector_attr_columns(conn)?;
15719 let actual = actual_vector_attr_columns(conn)?;
15720 if desired != actual || !desired.is_empty() {
15721 reshape_vector_partition_nondestructive(conn, dimension, &desired, &actual, true)?;
15722 }
15723 Ok(())
15724}
15725
15726/// Refresh one reactivated node's vec0 metadata without reshaping the corpus.
15727///
15728/// Activation re-projects this node's canonical attributes after a source change
15729/// that could have happened while it was deleted. vec0 accepts metadata `UPDATE`s,
15730/// so only this row needs to be refreshed; a full partition reshape belongs to
15731/// registry shape/source reconciliation, not the ordinary lifecycle path.
15732fn refresh_vector_attr_values_for_row(
15733 conn: &Connection,
15734 rowid: i64,
15735 body: &str,
15736) -> rusqlite::Result<()> {
15737 let (cols_sql, _, mut values) = vector_attr_insert_fragments(conn, body, 1)?;
15738 if cols_sql.is_empty() {
15739 return Ok(());
15740 }
15741 let assignments = cols_sql
15742 .trim_start_matches(", ")
15743 .split(", ")
15744 .enumerate()
15745 .map(|(index, col)| format!("{col} = ?{}", index + 1))
15746 .collect::<Vec<_>>()
15747 .join(", ");
15748 values.push(rusqlite::types::Value::Integer(rowid));
15749 let rowid_index = values.len();
15750 conn.execute(
15751 &format!(
15752 "UPDATE {DEFAULT_VECTOR_PARTITION} SET {assignments} WHERE rowid = ?{rowid_index}"
15753 ),
15754 rusqlite::params_from_iter(values.iter()),
15755 )?;
15756 Ok(())
15757}
15758
15759/// 0.8.20 Slice 15e — the NON-DESTRUCTIVE reshape itself. Stages every live row
15760/// (base columns + all ACTUAL attribute columns), drops + recreates
15761/// `vector_default` at the DESIRED shape, then re-inserts each row.
15762///
15763/// THE FOUR LOAD-BEARING CONDITIONS (any one broken ⇒ silently wrong results):
15764/// 1. `rowid` is listed EXPLICITLY in the re-insert (a vec0 row maps to its node
15765/// by `rowid == write_cursor`; auto-assigned rowids would decouple every
15766/// embedding from its node);
15767/// 2. each attribute column is PLAIN `TEXT` metadata (via
15768/// [`vector_partition_create_sql`]), never a vec0 `aux`/`+` column (aux
15769/// hard-errors a filtered KNN);
15770/// 3. a DESIRED column with no ACTUAL predecessor back-fills each row from the
15771/// already-populated `canonical_attributes` (fix-1 finding 2) so a
15772/// pre-existing row whose body carries the attribute is immediately
15773/// filterable; the `''` sentinel (vec0 TEXT metadata is NOT-NULL-able) is
15774/// used ONLY where the attribute is genuinely absent, so an absent row
15775/// cleanly fails-to-match instead of erroring;
15776/// 4. `embedding_bin` is copied VERBATIM via `vec_bit(...)` — NOT re-quantized —
15777/// so old rows keep their (possibly mean-centered) bits and stay Hamming-
15778/// comparable to new rows.
15779///
15780/// Runs on `conn` directly (the caller owns the transaction). Transactional
15781/// atomicity + reader isolation are the caller's responsibility, exactly as for
15782/// `migrate_vector_partition_pack1_to_pack2`.
15783fn reshape_vector_partition_nondestructive(
15784 conn: &Connection,
15785 dimension: u32,
15786 desired_cols: &[String],
15787 actual_cols: &[String],
15788 refresh_values: bool,
15789) -> rusqlite::Result<()> {
15790 // Stage: base columns + every ACTUAL attribute column (so no at-rest value is
15791 // lost, even for a column being dropped).
15792 let mut stage_defs = String::new();
15793 let mut stage_names =
15794 String::from("rowid, embedding, embedding_bin, source_type, kind, created_at, status");
15795 for col in actual_cols {
15796 stage_defs.push_str(&format!(",\n {col} TEXT"));
15797 stage_names.push_str(&format!(", {col}"));
15798 }
15799 conn.execute_batch(&format!(
15800 "CREATE TABLE _fathomdb_vector_reshape_stage (
15801 rowid INTEGER PRIMARY KEY,
15802 embedding BLOB NOT NULL,
15803 embedding_bin BLOB NOT NULL,
15804 source_type TEXT,
15805 kind TEXT,
15806 created_at INTEGER,
15807 status TEXT{stage_defs}
15808 );
15809 INSERT INTO _fathomdb_vector_reshape_stage({stage_names})
15810 SELECT {stage_names} FROM {DEFAULT_VECTOR_PARTITION};
15811 DROP TABLE {DEFAULT_VECTOR_PARTITION};"
15812 ))?;
15813
15814 // Recreate at the DESIRED shape (plain TEXT attr columns — condition #2).
15815 conn.execute_batch(&vector_partition_create_sql(dimension, false, desired_cols))?;
15816
15817 // Re-insert. `rowid` explicit (condition #1); `vec_bit(embedding_bin)`
15818 // verbatim, never re-quantized (condition #4); `status` and each surviving
15819 // attribute column carried forward; each NEW desired column back-fills from
15820 // `canonical_attributes` (the `''` sentinel only where genuinely absent —
15821 // condition #3).
15822 let mut insert_cols =
15823 String::from("rowid, embedding, embedding_bin, source_type, kind, created_at, status");
15824 let mut select_exprs = String::from(
15825 "rowid, embedding, vec_bit(embedding_bin), source_type, kind, created_at, status",
15826 );
15827 for col in desired_cols {
15828 insert_cols.push_str(&format!(", {col}"));
15829 if actual_cols.iter().any(|a| a == col) && !refresh_values {
15830 // Surviving column — carry its value forward (never NULL: vec0 TEXT
15831 // metadata is `''`-sentinelled, but COALESCE defends the stage table).
15832 select_exprs.push_str(&format!(", COALESCE({col}, '')"));
15833 } else {
15834 // New column — back-fill from the ALREADY-populated `canonical_attributes`
15835 // (fix-1 finding 2 [P2]). `configure_projections` runs `backfill_attribute`
15836 // (which fills `canonical_attributes` from each active row's body) BEFORE
15837 // this reshape, so a pre-existing row whose body carries the attribute is
15838 // immediately filterable — no false negative until a re-embed. The `''`
15839 // sentinel (condition #3) is used ONLY where the attribute is genuinely
15840 // ABSENT for that row (no `canonical_attributes` row ⇒ COALESCE → '').
15841 // The EAV value equals the vec0 write-time value by construction (both go
15842 // through `extract_scalar_attribute`), so pre-existing and freshly-written
15843 // rows share one filter semantics.
15844 match decode_attr_vec0_column(col) {
15845 Some(name) => {
15846 // vec0/execute_batch takes no bind params; embed the decoded name
15847 // as a SQL string literal, escaping single quotes.
15848 //
15849 // fix-3 [P2] — a PRESENT row (a canonical_attributes row exists)
15850 // encodes its RAW `attr_value` as `\x01 || attr_value` (`char(1) ||
15851 // ca.attr_value`), matching the write-time vec0 encoding so a
15852 // pre-existing present-empty row (attr_value='') becomes the bare
15853 // marker, NOT `''`. An ABSENT row (no canonical_attributes row) is
15854 // the COALESCE default `''` (condition #3). `canonical_attributes`
15855 // itself stays RAW — only this vec0 column is encoded.
15856 let escaped = name.replace('\'', "''");
15857 select_exprs.push_str(&format!(
15858 ", COALESCE((SELECT char(1) || ca.attr_value FROM canonical_attributes ca \
15859 WHERE ca.write_cursor = _fathomdb_vector_reshape_stage.rowid \
15860 AND ca.attr_name = '{escaped}' LIMIT 1), '')"
15861 ));
15862 }
15863 // A desired column always decodes (built by `attr_vec0_column`); if it
15864 // somehow does not, fall back to the sentinel rather than panic.
15865 None => select_exprs.push_str(", ''"),
15866 }
15867 }
15868 }
15869 conn.execute_batch(&format!(
15870 "INSERT INTO {DEFAULT_VECTOR_PARTITION}({insert_cols})
15871 SELECT {select_exprs} FROM _fathomdb_vector_reshape_stage;
15872 DROP TABLE _fathomdb_vector_reshape_stage;"
15873 ))?;
15874 Ok(())
15875}
15876
15877/// Slice 10 / G10 — stage + recreate + back-fill upgrade of an existing
15878/// **Pack-1** `vector_default` (has `embedding_bin`, lacks `status`) to the
15879/// Pack-2 shape. The existing `embedding_bin` blob is preserved verbatim (it may
15880/// be mean-centered; re-quantizing from `embedding` would drop the centering),
15881/// and `status` back-fills NULL. Same transactional discipline as
15882/// `migrate_vector_partition_to_pack1`: a single `Connection::transaction()`;
15883/// reader handles are not opened until `ensure_vector_partition` returns, and
15884/// cross-process access is serialized by the sidecar lock, so readers never see
15885/// a partial reshape.
15886fn migrate_vector_partition_pack1_to_pack2(
15887 connection: &mut Connection,
15888 dimension: u32,
15889) -> rusqlite::Result<()> {
15890 let tx = connection.transaction()?;
15891 tx.execute_batch(
15892 "CREATE TABLE _fathomdb_vector_pack2_stage (
15893 rowid INTEGER PRIMARY KEY,
15894 embedding BLOB NOT NULL,
15895 embedding_bin BLOB NOT NULL,
15896 source_type TEXT,
15897 kind TEXT,
15898 created_at INTEGER
15899 );
15900 INSERT INTO _fathomdb_vector_pack2_stage(
15901 rowid, embedding, embedding_bin, source_type, kind, created_at
15902 )
15903 SELECT rowid, embedding, embedding_bin, source_type, kind, created_at
15904 FROM vector_default;
15905 DROP TABLE vector_default;",
15906 )?;
15907 tx.execute_batch(&vector_partition_create_sql(dimension, false, &[]))?;
15908 // `vec_bit(...)` re-tags the staged blob with the BIT subtype vec0's bit
15909 // column requires (a raw blob loses the subtype and fails the type check).
15910 // This preserves the existing (possibly mean-centered) bits verbatim — no
15911 // re-quantize, so centering survives the upgrade. `status` back-fills the
15912 // empty-string sentinel (vec0 TEXT metadata is NOT NULL-able; reserved-gap
15913 // candidate 13).
15914 tx.execute_batch(
15915 "INSERT INTO vector_default(
15916 rowid, embedding, embedding_bin, source_type, kind, created_at, status
15917 )
15918 SELECT rowid, embedding, vec_bit(embedding_bin), source_type, kind, created_at, ''
15919 FROM _fathomdb_vector_pack2_stage;
15920 DROP TABLE _fathomdb_vector_pack2_stage;",
15921 )?;
15922 tx.commit()
15923}
15924
15925/// SQL fragment implementing the D3 `kind -> source_type` map.
15926/// Used both by the Pack 1 reshape migration and by the drift-detection
15927/// unit test that pins it to [`resolve_source_type`].
15928const KIND_TO_SOURCE_TYPE_CASE_SQL: &str = "CASE s.kind
15929 WHEN 'email' THEN 'email'
15930 WHEN 'article' THEN 'article'
15931 WHEN 'paper' THEN 'paper'
15932 WHEN 'meeting' THEN 'meeting'
15933 WHEN 'note' THEN 'note'
15934 WHEN 'todo' THEN 'todo'
15935 WHEN 'doc' THEN 'article'
15936 ELSE 'article'
15937END";
15938
15939/// Pack 1 in-place reshape of `vector_default`. Stages the existing
15940/// f32 corpus + each row's `kind`, drops the old single-column vec0
15941/// table, recreates at the runtime `dimension` with the Pack 1
15942/// columns, then repopulates with SQL-side `vec_quantize_binary` +
15943/// the D3 `kind -> source_type` mapping. The preflight CHECK on
15944/// unknown kinds has already run as migration step 9 by the time we
15945/// get here.
15946///
15947/// Atomicity: the DROP+CREATE+repopulate sequence runs inside a
15948/// rusqlite `Connection::transaction()` (DEFERRED begin per rusqlite
15949/// `transaction.rs:417`). Cross-process serialization is provided by
15950/// the engine's sidecar `acquire_lock` at `open_with_migrations`
15951/// (`lib.rs:1127` area); reader handles are not opened until
15952/// `ensure_vector_partition` returns (`lib.rs:1241` area), so readers
15953/// never observe a partial reshape.
15954fn migrate_vector_partition_to_pack1(
15955 connection: &mut Connection,
15956 dimension: u32,
15957) -> rusqlite::Result<()> {
15958 let tx = connection.transaction()?;
15959 tx.execute_batch(
15960 "CREATE TABLE _fathomdb_vector_migration_v0_7_0 (
15961 rowid INTEGER PRIMARY KEY,
15962 embedding BLOB NOT NULL,
15963 kind TEXT NOT NULL
15964 );
15965 INSERT INTO _fathomdb_vector_migration_v0_7_0(rowid, embedding, kind)
15966 SELECT v.rowid, v.embedding, r.kind
15967 FROM vector_default v
15968 JOIN _fathomdb_vector_rows r ON r.rowid = v.rowid;
15969 DROP TABLE vector_default;",
15970 )?;
15971 // Slice 10 / G10 — recreate directly at the Pack-2 shape (adds `status`), so
15972 // a legacy single-column DB lands the current shape in one reshape.
15973 tx.execute_batch(&vector_partition_create_sql(dimension, false, &[]))?;
15974 // `status` back-fills the empty-string sentinel (vec0 TEXT metadata is NOT
15975 // NULL-able; reserved-gap candidate 13). Legacy single-column DBs predate
15976 // mean-centering, so re-quantizing from the un-centered `embedding` is
15977 // correct here.
15978 let repopulate_sql = format!(
15979 "INSERT INTO vector_default(
15980 rowid, embedding, embedding_bin, source_type, kind, created_at, status
15981 )
15982 SELECT
15983 s.rowid,
15984 s.embedding,
15985 vec_quantize_binary(s.embedding),
15986 {KIND_TO_SOURCE_TYPE_CASE_SQL},
15987 s.kind,
15988 strftime('%s', 'now'),
15989 ''
15990 FROM _fathomdb_vector_migration_v0_7_0 s;
15991 DROP TABLE _fathomdb_vector_migration_v0_7_0;"
15992 );
15993 tx.execute_batch(&repopulate_sql)?;
15994 tx.commit()
15995}
15996
15997fn encode_vector_blob(vector: &[f32]) -> Vec<u8> {
15998 vector.iter().flat_map(|value| value.to_le_bytes()).collect()
15999}
16000
16001fn decode_vector_blob(bytes: &[u8]) -> Vec<f32> {
16002 debug_assert_eq!(bytes.len() % 4, 0, "f32 BLOB length must be multiple of 4");
16003 bytes.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()
16004}
16005
16006/// EU-5a2 — does the live embedder identity request mean-centering?
16007/// Identity-name compare per EU-5a1's BGE_SMALL_EMBEDDER_NAME constant
16008/// (`dev/design/embedder.md` §0.6). NoopEmbedder returns `false`.
16009fn identity_requires_mean_centering(identity: &EmbedderIdentity) -> bool {
16010 identity.name == BGE_SMALL_EMBEDDER_NAME
16011}
16012
16013/// EU-5a2 — read the pinned mean vector from
16014/// `_fathomdb_embedder_profiles.mean_vec` for the default profile.
16015/// Returns `Ok(None)` when the column is NULL or the row is missing;
16016/// returns `Err(EngineError::Storage)` on dimension drift (the open-time
16017/// `check_embedder_profile` already fails closed for this, so a runtime
16018/// drift here would be an internal-inconsistency signal).
16019fn read_pinned_mean_vec(
16020 connection: &Connection,
16021 dimension: u32,
16022) -> Result<Option<Vec<f32>>, EngineError> {
16023 let bytes: Option<Vec<u8>> = connection
16024 .query_row(
16025 "SELECT mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
16026 [],
16027 |row| row.get::<_, Option<Vec<u8>>>(0),
16028 )
16029 .or_else(|err| match err {
16030 rusqlite::Error::QueryReturnedNoRows => Ok(None),
16031 other => Err(other),
16032 })
16033 .map_err(|_| EngineError::Storage)?;
16034 let Some(bytes) = bytes else { return Ok(None) };
16035 let expected_len = (dimension as usize).saturating_mul(4);
16036 if bytes.len() != expected_len {
16037 return Err(EngineError::Storage);
16038 }
16039 let mut out = Vec::with_capacity(dimension as usize);
16040 for chunk in bytes.chunks_exact(4) {
16041 let arr = [chunk[0], chunk[1], chunk[2], chunk[3]];
16042 out.push(f32::from_le_bytes(arr));
16043 }
16044 Ok(Some(out))
16045}
16046
16047/// EU-5a2 — pointwise `v - mean`. Length-checked debug-assert; caller
16048/// guarantees equal length via `read_pinned_mean_vec` + dimension check.
16049fn subtract_mean(v: &[f32], mean: &[f32]) -> Vec<f32> {
16050 debug_assert_eq!(v.len(), mean.len(), "subtract_mean dim mismatch");
16051 v.iter().zip(mean.iter()).map(|(a, b)| *a - *b).collect()
16052}
16053
16054/// 0.8.18 Slice 5 (#5 vector-equivalence probe) — parse the committed 45-probe
16055/// fixture into an ordered `Vec<&str>` (one probe per non-empty, non-`#`-comment
16056/// line). Order is stable so `probe_ordinal` is deterministic across opens.
16057fn vector_equivalence_probes() -> Vec<&'static str> {
16058 VECTOR_EQUIVALENCE_PROBE_FIXTURE
16059 .lines()
16060 .map(str::trim_end)
16061 .filter(|line| {
16062 let t = line.trim_start();
16063 !t.is_empty() && !t.starts_with('#')
16064 })
16065 .collect()
16066}
16067
16068/// 0.8.18 Slice 5 — outcome of the open-time #5 self-check.
16069struct VectorEquivalenceOutcome {
16070 dense_disabled: bool,
16071 reason: Option<String>,
16072}
16073
16074/// A dense runtime can schedule, repair, and report readiness only when an
16075/// embedder is attached and the open-time equivalence guard accepted it.
16076fn usable_dense_runtime(embedder: Option<&dyn Embedder>, dense_disabled: bool) -> bool {
16077 embedder.is_some() && !dense_disabled
16078}
16079
16080/// 0.8.18 Slice 5 — embed one probe under panic isolation. The probe runs at
16081/// open time on the writer connection BEFORE the projection workers spawn, so a
16082/// caller-supplied embedder that PANICS (or returns an error / a wrong-dimension
16083/// vector) must never wedge `Engine::open`. A panic/error/shape-mismatch yields
16084/// `None`; the CALLERS then fail-SAFE (fix-1 DEFECT #1) — a `None` at population
16085/// or check time means the vector arm cannot be established/verified, so dense is
16086/// REFUSED (`dense_disabled=true`), never silently served. `Engine::open` still
16087/// succeeds (no wedge; ADR-0.6.0 Invariant-5 posture, mirrored open-side).
16088fn probe_embed(embedder: &dyn Embedder, text: &str, dimension: usize) -> Option<Vec<f32>> {
16089 let embedded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| embedder.embed(text)));
16090 match embedded {
16091 Ok(Ok(vector)) if vector.len() == dimension => Some(vector),
16092 _ => None,
16093 }
16094}
16095
16096/// 0.8.18 Slice 5 (#5 vector-equivalence probe KEYSTONE) — the open-time
16097/// self-check. Per `dev/design/0.8.18-slice-0-vector-equivalence-publish-design.md`
16098/// §U1 + `dev/adr/ADR-0.8.18-vector-equivalence-self-check.md`.
16099///
16100/// Runs AFTER open-time mean-recovery/requantize + `ensure_vector_partition`
16101/// (U1-b) so it reads the FINAL live `mean_vec`. Two paths:
16102///
16103/// - **First accepted vector arm** (probe table empty): re-embed the 45
16104/// committed probes with the LIVE embedder and verify the in-memory
16105/// **UN-centered f32 reference vectors** + embedder identity (R-VEQ-1)
16106/// before persisting them. Store f32 ONLY — the P1 bits are NEVER persisted
16107/// (U1-d). A rejected prospective arm writes neither the baseline nor cache.
16108/// - **Subsequent open** (probe table populated): re-embed the 45 probes and
16109/// assert BOTH dense-pipeline representations against the stored references:
16110/// **(P1)** the Phase-1 mean-centered `embedding_bin` sign-flip count via the
16111/// SAME `vec_quantize_binary(sign(x − mean_vec))` path as
16112/// `build_vector_phase1_sql` (floor = 0, exact); **(P2)** the un-centered
16113/// Phase-2 L2 (`vec_distance_l2` semantics) within `VECTOR_EQUIVALENCE_L2_EPSILON`.
16114/// Divergence beyond EITHER floor ⇒ `dense_disabled=true` (R-VEQ-2/3).
16115///
16116/// Mean-centering is gated by `identity_requires_mean_centering(identity)` ∧
16117/// `mean_pinned`, applied symmetrically to reference + reembed (un-centered
16118/// fallback otherwise; NoopEmbedder no-op) — R-VEQ-3c.
16119///
16120/// Fail-SAFE, never fail-open (0.8.18 Slice 5 fix-1, DEFECT #1): any inability to
16121/// RUN or VERIFY the probe — a probe embed that panics/errors/returns wrong-dim, a
16122/// malformed/missing reference row, an unreadable pinned mean, or a
16123/// `vec_quantize_binary`/L2 SQL failure — yields `dense_disabled=true` with a clear
16124/// reason (refuse the un-verifiable dense/fused arm; the text-only/FTS path still
16125/// serves). `Engine::open` still SUCCEEDS (never wedges on a panicking caller
16126/// embedder). The distinct-identity cross-vendor refusal (`check_embedder_profile`)
16127/// remains the PRIMARY gate; this probe is ADDITIVE-ONLY (R-VEQ-5), but on the
16128/// vector arm it fails CLOSED, not open — same-identity backend drift on an
16129/// un-verifiable arm is exactly what #5 must catch (R-VEQ-4 "loud typed refuse,
16130/// never silent").
16131fn run_vector_equivalence_probe(
16132 connection: &Connection,
16133 embedder: Option<&dyn Embedder>,
16134 identity: &EmbedderIdentity,
16135 mean_pinned: bool,
16136 prospective_dense_arm: bool,
16137) -> VectorEquivalenceOutcome {
16138 let not_disabled = VectorEquivalenceOutcome { dense_disabled: false, reason: None };
16139
16140 // No runtime embedder means no dense arm to guard (EmbedderChoice::None).
16141 // The probe is inert; dense writes/queries already fail with
16142 // EmbedderNotConfigured.
16143 let Some(embedder) = embedder else { return not_disabled };
16144
16145 // Gate: the probe engages once the workspace has either a REGISTERED vector
16146 // kind (`_fathomdb_vector_kinds` non-empty) or a durable prospective vector
16147 // declaration that the safe boot graft would otherwise enrol. A workspace
16148 // with neither has no dense arm to guard, so the probe does ZERO embed work
16149 // at that open — this keeps `Engine::open` free of the 45-probe re-embed on
16150 // empty/vector-less workspaces (and inert for the pathological
16151 // single-session hang/panic embedder tests, which register their kind AFTER
16152 // open and never reopen).
16153 //
16154 // fix-1 DEFECT #4 — the baseline is established at OPEN, at the first open
16155 // where a vector kind already exists (population path below). This covers BOTH:
16156 // (b) the v18→v19 UPGRADE with pre-existing vector kinds: the baseline is
16157 // captured here, at the first v19 open, from the identity-matched
16158 // embedder (identity is already gated by `check_embedder_profile`, so the
16159 // baseline is the same *claimed* embedder; future backend drift is caught);
16160 // (a) a vector kind registered POST-OPEN in a prior session: the baseline is
16161 // captured at the NEXT open (this gate + population), again identity-gated.
16162 // It is deliberately NOT captured in the registering session's write path: a
16163 // write must NEVER block on the embedder (the async-projection invariant —
16164 // `ac_029_canonical_writes_complete_under_projection_stall` and the PR-9 embed
16165 // watchdog/thread-leak bounds), and 45 synchronous probe embeds there would
16166 // violate it and hang/degrade under a stalling embedder. Serving vector queries
16167 // in the registering session is SAFE regardless: the serving backend IS the
16168 // backend that built those vectors, so there is nothing to diverge from. The
16169 // residual — a same-*identity* backend that drifted between the registering
16170 // session and the next open is not retroactively caught — is IDENTICAL to the
16171 // accepted upgrade residual (R-VEQ-5 additive-only; U3 same-identity candle
16172 // CPU↔CUDA = 0/17280). See `dev/design/0.8.18-slice-5-vector-equivalence-probe.md`.
16173 let vector_kind_registered: bool = connection
16174 .query_row("SELECT EXISTS(SELECT 1 FROM _fathomdb_vector_kinds)", [], |r| r.get(0))
16175 .unwrap_or(false);
16176 if !vector_kind_registered && !prospective_dense_arm {
16177 return not_disabled;
16178 }
16179
16180 // A cold declared arm must earn its durable enrolment only after a successful
16181 // probe. Its preflight is read-only until acceptance: a refusal must leave no
16182 // reference baseline or verdict-cache mutation behind for a later open.
16183 let prospective_preflight = prospective_dense_arm && !vector_kind_registered;
16184 match probe_populate_or_check(
16185 connection,
16186 embedder,
16187 identity,
16188 mean_pinned,
16189 prospective_preflight,
16190 ) {
16191 Ok(()) => not_disabled,
16192 Err(reason) => VectorEquivalenceOutcome { dense_disabled: true, reason: Some(reason) },
16193 }
16194}
16195
16196/// 0.8.18 Slice 5 — either PERSIST the baseline (probe table empty) or CHECK
16197/// against it (probe table populated). `Err(reason)` ⇒ refuse the dense arm
16198/// (`dense_disabled=true`); `Ok(())` ⇒ dense served. Fail-SAFE throughout.
16199fn probe_populate_or_check(
16200 connection: &Connection,
16201 embedder: &dyn Embedder,
16202 identity: &EmbedderIdentity,
16203 mean_pinned: bool,
16204 prospective_preflight: bool,
16205) -> Result<(), String> {
16206 let probes = vector_equivalence_probes();
16207 if probes.is_empty() {
16208 // Fail-SAFE: the compiled-in probe fixture is empty ⇒ nothing to verify
16209 // the vector arm against. (Defensive; the fixture is drift-guarded
16210 // non-empty at 45 probes.)
16211 return Err(
16212 "vector-equivalence probe fixture is empty; cannot verify the dense arm".to_string()
16213 );
16214 }
16215
16216 let existing: i64 = connection
16217 .query_row("SELECT COUNT(*) FROM _fathomdb_embed_probe", [], |r| r.get(0))
16218 .map_err(|e| format!("could not read the probe reference table: {e}; cannot verify"))?;
16219
16220 if existing == 0 {
16221 let pending_baseline = collect_probe_baseline(embedder, identity, &probes)?;
16222 if prospective_preflight {
16223 // A declaration alone is not an enrolled arm. Verify the in-memory
16224 // reference set first; on every refusal path this has performed reads
16225 // and embed calls only. Persist both the baseline and its cache marker
16226 // only after the verdict has been accepted.
16227 let cache_update = probe_check_stored_baseline(
16228 connection,
16229 embedder,
16230 identity,
16231 mean_pinned,
16232 &probes,
16233 &pending_baseline,
16234 )?;
16235 persist_probe_baseline(connection, &pending_baseline)?;
16236 if let Some(fingerprint) = cache_update {
16237 record_probe_verification(connection, &fingerprint);
16238 }
16239 Ok(())
16240 } else {
16241 // A registered arm preserves the established Slice 5 behavior:
16242 // persist its first baseline, then confirm that durable baseline
16243 // before serving dense.
16244 persist_probe_baseline(connection, &pending_baseline)?;
16245 probe_check_against_baseline(connection, embedder, identity, mean_pinned, &probes, true)
16246 }
16247 } else {
16248 // A prospective refusal must preserve even a stale prior marker: the
16249 // proposed arm was never accepted, so it cannot mutate durable state.
16250 probe_check_against_baseline(
16251 connection,
16252 embedder,
16253 identity,
16254 mean_pinned,
16255 &probes,
16256 !prospective_preflight,
16257 )
16258 }
16259}
16260
16261/// Capture the 45 un-centered f32 reference vectors as rows not yet made durable.
16262/// This collection is deliberately side-effect free so a prospective dense arm can
16263/// be refused without changing the workspace it was merely considering joining.
16264fn collect_probe_baseline(
16265 embedder: &dyn Embedder,
16266 identity: &EmbedderIdentity,
16267 probes: &[&str],
16268) -> Result<Vec<StoredProbeRow>, String> {
16269 let dimension = identity.dimension as usize;
16270 let mut rows = Vec::with_capacity(probes.len());
16271 for (ordinal, probe) in probes.iter().enumerate() {
16272 match probe_embed(embedder, probe, dimension) {
16273 Some(vector) => rows.push((
16274 ordinal as i64,
16275 (*probe).to_string(),
16276 encode_vector_blob(&vector),
16277 identity.name.clone(),
16278 identity.revision.clone(),
16279 identity.dimension as i64,
16280 )),
16281 None => {
16282 return Err(format!(
16283 "embedder failed to produce a reference vector for probe {ordinal}; \
16284 cannot establish a vector-equivalence baseline (dense arm refused)"
16285 ));
16286 }
16287 }
16288 }
16289 Ok(rows)
16290}
16291
16292/// Persist a complete accepted baseline atomically. A failed transaction leaves no
16293/// partial reference set for a future open to trust.
16294fn persist_probe_baseline(connection: &Connection, rows: &[StoredProbeRow]) -> Result<(), String> {
16295 let tx = connection
16296 .unchecked_transaction()
16297 .map_err(|e| format!("could not open the probe-baseline transaction: {e}"))?;
16298 for (ordinal, probe, blob, name, revision, dim) in rows {
16299 tx.execute(
16300 "INSERT OR REPLACE INTO _fathomdb_embed_probe(
16301 probe_ordinal, probe_text, reference_vec,
16302 embedder_name, embedder_revision, dim
16303 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
16304 params![ordinal, probe, blob, name, revision, dim],
16305 )
16306 .map_err(|e| format!("could not persist the probe baseline: {e}"))?;
16307 }
16308 tx.commit().map_err(|e| format!("could not commit the probe baseline: {e}"))?;
16309 Ok(())
16310}
16311
16312/// 0.8.20 Slice 22 (TC-68) — one row of the stored probe baseline:
16313/// `(probe_ordinal, probe_text, reference_vec, embedder_name, embedder_revision, dim)`.
16314type StoredProbeRow = (i64, String, Vec<u8>, String, String, i64);
16315
16316/// 0.8.20 Slice 22 (TC-68) — length-prefixed field feed for the verdict
16317/// fingerprint. The `u64` length prefix makes the concatenation UNAMBIGUOUS: two
16318/// different input tuples can never produce the same byte stream by sliding a
16319/// delimiter (e.g. name `"ab"` + revision `"c"` vs name `"a"` + revision `"bc"`).
16320fn hash_fingerprint_field(hasher: &mut Sha256, bytes: &[u8]) {
16321 hasher.update((bytes.len() as u64).to_le_bytes());
16322 hasher.update(bytes);
16323}
16324
16325/// 0.8.20 Slice 22 (TC-68) — the **embedder-identity fingerprint** the cached
16326/// equivalence verdict is keyed on: a SHA-256 over EVERY input the probe's verdict
16327/// depends on. Two opens sharing a fingerprint would, by construction, compute the
16328/// same P1/P2 answer, so the second may reuse the first's.
16329///
16330/// The inputs, and why each is load-bearing:
16331///
16332/// - **the recipe tag** ([`VECTOR_EQUIVALENCE_FINGERPRINT_RECIPE`]) — bumping it
16333/// invalidates every cached verdict in the field at once;
16334/// - **`identity.{name, revision, dimension}`** — the nominal embedder. This is
16335/// *defence in depth only*: `check_embedder_profile` already REFUSES the open
16336/// with `EmbedderIdentityMismatch`/`EmbedderDimensionMismatch` before the probe
16337/// is reached, so an identity change is never observed here in practice;
16338/// - **the live pinned `mean_vec`** (and whether centering is applied at all) —
16339/// this one is NOT optional. P1 quantizes through
16340/// `vec_quantize_binary(sign(x − mean_vec))`, so rewriting the pinned mean
16341/// changes the verdict *for the same embedder and the same baseline*. A
16342/// fingerprint over the identity triple alone would be stale by construction,
16343/// because open-time mean-recovery/requantize and the operator `recompute_mean`
16344/// verb both rewrite it;
16345/// - **the committed probe fixture** — a cached verdict computed over a different
16346/// probe set means nothing. (`vector_equivalence_probe_fixture_drift.rs` does
16347/// NOT make this redundant: it pins the engine's copy equal to the embedder
16348/// crate's copy — it guards COPY drift between two committed files, not the
16349/// fixture's content across releases. The stored-baseline completeness check
16350/// below does fail closed first on a fixture edit, so this input is belt-and-
16351/// braces rather than the only guard; it is one hash of a ~3 KB constant.);
16352/// - **both D4 floors** — a verdict that passed under a loose ε must not be
16353/// inherited by a build that tightened it;
16354/// - **the STORED baseline rows, reference blobs included** — the 0.8.18 fix-2
16355/// completeness check pins each row's shape (count, ordinal, text, blob LENGTH,
16356/// identity) but not the blob CONTENT, which the re-embed comparison used to
16357/// catch. Hashing the blobs keeps that external-tamper closure intact at
16358/// negligible cost (~69 KB of SHA-256 against 45 model invocations).
16359fn probe_verification_fingerprint(
16360 identity: &EmbedderIdentity,
16361 mean_vec: Option<&[f32]>,
16362 stored: &[StoredProbeRow],
16363) -> String {
16364 let mut hasher = Sha256::new();
16365 hash_fingerprint_field(&mut hasher, VECTOR_EQUIVALENCE_FINGERPRINT_RECIPE.as_bytes());
16366 hash_fingerprint_field(&mut hasher, identity.name.as_bytes());
16367 hash_fingerprint_field(&mut hasher, identity.revision.as_bytes());
16368 hash_fingerprint_field(&mut hasher, &identity.dimension.to_le_bytes());
16369 match mean_vec {
16370 Some(mean) => {
16371 hash_fingerprint_field(&mut hasher, b"mean-centered");
16372 hash_fingerprint_field(&mut hasher, &encode_vector_blob(mean));
16373 }
16374 None => hash_fingerprint_field(&mut hasher, b"un-centered"),
16375 }
16376 hash_fingerprint_field(&mut hasher, VECTOR_EQUIVALENCE_PROBE_FIXTURE.as_bytes());
16377 hash_fingerprint_field(&mut hasher, &VECTOR_EQUIVALENCE_P1_FLIP_FLOOR.to_le_bytes());
16378 hash_fingerprint_field(&mut hasher, &VECTOR_EQUIVALENCE_L2_EPSILON.to_le_bytes());
16379 hash_fingerprint_field(&mut hasher, &(stored.len() as u64).to_le_bytes());
16380 for (ordinal, probe_text, reference_vec, name, revision, dim) in stored {
16381 hash_fingerprint_field(&mut hasher, &ordinal.to_le_bytes());
16382 hash_fingerprint_field(&mut hasher, probe_text.as_bytes());
16383 hash_fingerprint_field(&mut hasher, reference_vec);
16384 hash_fingerprint_field(&mut hasher, name.as_bytes());
16385 hash_fingerprint_field(&mut hasher, revision.as_bytes());
16386 hash_fingerprint_field(&mut hasher, &dim.to_le_bytes());
16387 }
16388 hasher.finalize().iter().map(|b| format!("{b:02x}")).collect()
16389}
16390
16391/// 0.8.20 Slice 22 (TC-68) — is `fingerprint` the fingerprint under which the
16392/// probe last RAN and PASSED on this workspace?
16393///
16394/// Fail-SAFE against ACCIDENT (R-VEQ-4): **every** failure mode answers `false`,
16395/// which means "run the probe". A missing `_fathomdb_open_state` table, an absent
16396/// row, a non-TEXT value, a truncated or garbled value, a stale fingerprint, any
16397/// SQL error — none of them can be mistaken for a pass.
16398///
16399/// # What a `true` does and does not mean (fix-1, codex §9 round 2 [P1])
16400///
16401/// `true` means: **the fingerprint inputs are unchanged since *some* engine
16402/// recorded a pass.** It does NOT mean "this engine verified this backend", and it
16403/// cannot: the fingerprint is a SHA-256 over deterministic, publicly derivable DB
16404/// and build inputs, so an actor with write access to the file can compute the
16405/// current digest and write it here, skipping the 45-probe verification. This
16406/// marker is not — and cannot be — an authenticated attestation; an embedded
16407/// local-first engine holds no secret with which to authenticate one, and a salt
16408/// would be readable by the same actor.
16409///
16410/// The same actor also defeats the same arm through the **pre-slice** path, by
16411/// re-baselining `_fathomdb_embed_probe`'s `reference_vec` blobs to their drifted
16412/// backend's own output — the probe then runs in full and verifies the drifted
16413/// backend against itself. Measured by
16414/// `tests/tc68_probe_fingerprint_cache.rs::a_forged_stored_baseline_defeats_the_probe_even_when_it_fully_runs`
16415/// (marker deleted, all 45 embeds performed, dense still enabled), with the
16416/// un-forged control caught.
16417///
16418/// **That is the same actor, NOT the same cost, and fix-2 struck the claim that it
16419/// was.** Forging this marker needs only a publicly computable digest — usually the
16420/// value already sitting in the row. Re-baselining additionally needs the target
16421/// backend's 45 exact embeddings, encoded into every row. **So the cache IS a
16422/// cheaper bypass** for a writer of the database file.
16423///
16424/// What bounds it is the ruled residual, not this marker. A same-identity backend
16425/// drift moves no fingerprint input, so a marker recorded by an **honest** earlier
16426/// open already skips the probe and already serves the drifted backend, with no
16427/// forgery anywhere
16428/// (`residual_same_identity_backend_drift_is_not_caught_on_a_cached_open`). Forgery
16429/// adds capability only on an open where no valid marker exists for the *current*
16430/// fingerprint — and a digest is valid only for the state it was computed over, so
16431/// it stops working at the next change to any fingerprint input.
16432///
16433/// The equivalence probe is a **correctness self-check against backend drift, not
16434/// tamper evidence**; `dense_disabled` is not a tamper signal. Threat model, with
16435/// the concession and the bound: §8.4/§8.5 of
16436/// `dev/design/0.8.20-tc68-equivalence-probe-fingerprint-cache.md`.
16437fn probe_verification_is_cached(connection: &Connection, fingerprint: &str) -> bool {
16438 connection
16439 .query_row(
16440 "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
16441 [VECTOR_EQUIVALENCE_VERDICT_CACHE_KEY],
16442 |row| row.get::<_, String>(0),
16443 )
16444 .map(|cached| cached == fingerprint)
16445 .unwrap_or(false)
16446}
16447
16448/// 0.8.20 Slice 22 (TC-68) — record that the probe RAN and PASSED under
16449/// `fingerprint`.
16450///
16451/// A write failure is deliberately SWALLOWED rather than turned into a verdict
16452/// failure. The arm has just been verified, so refusing dense because a marker
16453/// could not be persisted (read-only file, disk full) would be a false refusal;
16454/// and the consequence of the missing marker is simply that the next open re-runs
16455/// the probe — more work, never less. That is the fail-safe direction.
16456fn record_probe_verification(connection: &Connection, fingerprint: &str) {
16457 let _ = connection.execute(
16458 "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
16459 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
16460 params![VECTOR_EQUIVALENCE_VERDICT_CACHE_KEY, fingerprint],
16461 );
16462}
16463
16464/// 0.8.20 Slice 22 (TC-68) — drop any cached verdict.
16465///
16466/// Called on every failure path of an already registered arm, so that arm cannot
16467/// carry a marker a later registered open might match. A prospective arm has not
16468/// joined durable state yet and deliberately preserves its snapshot on refusal.
16469fn clear_probe_verification(connection: &Connection) {
16470 let _ = connection.execute(
16471 "DELETE FROM _fathomdb_open_state WHERE key = ?1",
16472 [VECTOR_EQUIVALENCE_VERDICT_CACHE_KEY],
16473 );
16474}
16475
16476/// 0.8.18 Slice 5 — SUBSEQUENT open: re-embed the 45 probes and assert BOTH
16477/// dense-pipeline representations against the stored references — **(P1)** the
16478/// mean-centered `embedding_bin` sign-flip count (floor 0, exact) and **(P2)** the
16479/// un-centered Phase-2 L2 (within `VECTOR_EQUIVALENCE_L2_EPSILON`).
16480///
16481/// 0.8.20 Slice 22 (TC-68) — this wrapper adds the failure half of the verdict
16482/// cache for an already registered arm: ANY `Err` drops the cached marker, so a
16483/// workspace that could not be verified never leaves a stale "verified" marker
16484/// behind for a later registered open to match. A prospective preflight passes
16485/// `false` for `clear_cache_on_error`: it has not joined the durable arm yet, so
16486/// rejection must remain globally mutation-free.
16487fn probe_check_against_baseline(
16488 connection: &Connection,
16489 embedder: &dyn Embedder,
16490 identity: &EmbedderIdentity,
16491 mean_pinned: bool,
16492 probes: &[&str],
16493 clear_cache_on_error: bool,
16494) -> Result<(), String> {
16495 let outcome = load_stored_probe_baseline(connection).and_then(|stored| {
16496 probe_check_stored_baseline(connection, embedder, identity, mean_pinned, probes, &stored)
16497 });
16498 match outcome {
16499 Ok(Some(fingerprint)) => {
16500 record_probe_verification(connection, &fingerprint);
16501 Ok(())
16502 }
16503 Ok(None) => Ok(()),
16504 Err(reason) => {
16505 if clear_cache_on_error {
16506 clear_probe_verification(connection);
16507 }
16508 Err(reason)
16509 }
16510 }
16511}
16512
16513fn load_stored_probe_baseline(connection: &Connection) -> Result<Vec<StoredProbeRow>, String> {
16514 let mut stmt = connection
16515 .prepare(
16516 "SELECT probe_ordinal, probe_text, reference_vec, embedder_name, embedder_revision, dim \
16517 FROM _fathomdb_embed_probe ORDER BY probe_ordinal",
16518 )
16519 .map_err(|e| format!("could not read the stored probe references: {e}; cannot verify"))?;
16520 stmt.query_map([], |row| {
16521 Ok((
16522 row.get::<_, i64>(0)?,
16523 row.get::<_, String>(1)?,
16524 row.get::<_, Vec<u8>>(2)?,
16525 row.get::<_, String>(3)?,
16526 row.get::<_, String>(4)?,
16527 row.get::<_, i64>(5)?,
16528 ))
16529 })
16530 .and_then(|rows| rows.collect::<rusqlite::Result<Vec<_>>>())
16531 .map_err(|e| format!("could not read the stored probe references: {e}; cannot verify"))
16532}
16533
16534/// 0.8.18 Slice 5 — the check proper. Fail-SAFE (fix-1 DEFECT #1): a probe embed
16535/// that panics/errors/returns wrong-dim, a malformed/missing reference row, an
16536/// unreadable pinned mean, or a `vec_quantize_binary`/L2 SQL failure each ⇒ `Err`
16537/// (cannot verify ⇒ refuse dense), never a silent skip-and-serve.
16538///
16539/// fix-2 (DEFECT #1 residual): BEFORE the divergence check, the STORED baseline is
16540/// validated to be EXACTLY the committed probe set — the expected row count, a
16541/// contiguous 0-based `probe_ordinal` per committed probe, each `probe_text` equal
16542/// to the committed fixture text at that ordinal, each `reference_vec` a well-formed
16543/// `4 * dim` f32 blob, and the stored embedder identity/dim matching the current
16544/// one. This closes the partial-baseline / external-tamper fail-open (a 44-of-45
16545/// table, or a re-attributed/mangled row, previously verified only the rows present
16546/// or re-embedded a tampered `probe_text` against itself). Any mismatch ⇒ `Err`.
16547///
16548/// 0.8.20 Slice 22 (TC-68) — the 45 re-embeds are CACHED against
16549/// [`probe_verification_fingerprint`]. Note WHERE the cache check sits: after the
16550/// mean resolution and after the fix-2 completeness validation, before the
16551/// re-embed loop. That split is deliberate — everything cheap keeps running on
16552/// EVERY open (so a short, re-attributed, mangled or fixture-mismatched baseline
16553/// still fails closed immediately), and only the expensive part, the 45 model
16554/// invocations, is skipped. The residual this buys is recorded in
16555/// `dev/design/0.8.20-tc68-equivalence-probe-fingerprint-cache.md`.
16556fn probe_check_stored_baseline(
16557 connection: &Connection,
16558 embedder: &dyn Embedder,
16559 identity: &EmbedderIdentity,
16560 mean_pinned: bool,
16561 probes: &[&str],
16562 stored: &[StoredProbeRow],
16563) -> Result<Option<String>, String> {
16564 let dimension = identity.dimension as usize;
16565
16566 // Resolve the live mean. Fail-SAFE: if centering is required + pinned but the
16567 // mean cannot be read, we cannot reproduce `embedding_bin` ⇒ refuse (P1
16568 // un-verifiable). NoopEmbedder / no-pin ⇒ un-centered on BOTH sides (R-VEQ-3c).
16569 let mean_vec = if identity_requires_mean_centering(identity) && mean_pinned {
16570 match read_pinned_mean_vec(connection, identity.dimension) {
16571 Ok(Some(mean)) => Some(mean),
16572 Ok(None) => {
16573 return Err("mean-centering is required and pinned but mean_vec is absent; \
16574 cannot verify P1 (dense arm refused)"
16575 .to_string());
16576 }
16577 Err(_) => {
16578 return Err(
16579 "could not read the pinned mean_vec; cannot verify P1 (dense arm refused)"
16580 .to_string(),
16581 );
16582 }
16583 }
16584 } else {
16585 None
16586 };
16587
16588 // fix-2 (DEFECT #1 residual) — COMPLETENESS validation of the STORED baseline.
16589 // `COUNT(*) > 0` is NOT proof of a complete, trustworthy baseline: a partially
16590 // populated or externally-tampered probe table (44 of 45 rows, a gap/dupe in the
16591 // ordinals, a mangled reference blob, a mismatched probe_text, or a foreign
16592 // embedder identity) is UNVERIFIABLE stored state. The prior code re-embedded
16593 // the STORED probe_text and compared it to its OWN reference, so a tampered
16594 // probe_text verified against itself and a short table verified only the rows
16595 // present — both fail-OPEN. Atomic population stops the ENGINE from writing a
16596 // partial set; this closes external corruption, a manual edit, and a future
16597 // migration bug the engine did not author. Any mismatch ⇒ fail CLOSED (dense
16598 // refused); the text-only/FTS path still serves. The stored baseline must be
16599 // EXACTLY the committed probe set, in order, under the current identity.
16600 if stored.len() != probes.len() {
16601 return Err(format!(
16602 "the probe reference table has {} rows but the committed fixture defines {}; \
16603 the stored baseline is incomplete or corrupt — cannot verify the dense arm (refused)",
16604 stored.len(),
16605 probes.len()
16606 ));
16607 }
16608 for (idx, (ordinal, probe_text, ref_blob, name, revision, dim)) in stored.iter().enumerate() {
16609 // Contiguous 0-based ordinals, one per committed probe (no gaps/dupes).
16610 if *ordinal != idx as i64 {
16611 return Err(format!(
16612 "probe reference ordinals are non-contiguous (row {idx} carries ordinal {ordinal}); \
16613 the stored baseline is corrupt — cannot verify the dense arm (refused)"
16614 ));
16615 }
16616 // The stored text MUST be the committed fixture text at this ordinal —
16617 // otherwise a tampered probe_text re-embeds and verifies against ITSELF,
16618 // masking drift (the exact fail-open this fix closes).
16619 if probe_text != probes[idx] {
16620 return Err(format!(
16621 "probe reference {ordinal} text does not match the committed fixture; \
16622 the stored baseline is tampered or corrupt — cannot verify the dense arm (refused)"
16623 ));
16624 }
16625 // Well-formed f32[dim] reference (4*dim little-endian bytes).
16626 if ref_blob.len() != dimension * 4 {
16627 return Err(format!(
16628 "probe reference {ordinal} is malformed (len {} != {}); \
16629 cannot verify the dense arm (refused)",
16630 ref_blob.len(),
16631 dimension * 4
16632 ));
16633 }
16634 // The stored embedder identity/dim must match the CURRENT expected identity
16635 // (defence-in-depth beyond `check_embedder_profile`: catches a baseline row
16636 // re-attributed to a foreign embedder by external edit/migration).
16637 if *dim != identity.dimension as i64
16638 || name != &identity.name
16639 || revision != &identity.revision
16640 {
16641 return Err(format!(
16642 "probe reference {ordinal} was captured under embedder {name}/{revision}/dim={dim} \
16643 but the current embedder is {}/{}/dim={}; the stored baseline does not match — \
16644 cannot verify the dense arm (refused)",
16645 identity.name, identity.revision, identity.dimension
16646 ));
16647 }
16648 }
16649
16650 // 0.8.20 Slice 22 (TC-68) — the CACHE gate. Everything above this line ran on
16651 // this open and still fails closed; everything below it is the 45 model
16652 // invocations that made `Engine::open` cost a flat 45 embeds FOREVER (measured
16653 // at `94bb33ef`: 0 with no enrolled kind, 90 on the one-time population open,
16654 // 45 on every open thereafter — independent of the enrolled-kind count, since
16655 // the probe gate is an `EXISTS` and the body never iterates kinds).
16656 //
16657 // If the probe already RAN and PASSED under this exact fingerprint, re-running
16658 // it is a pure re-computation of a known answer, so the verdict is reused.
16659 // Fail-SAFE: `probe_verification_is_cached` answers `false` for every failure
16660 // mode — missing table, absent row, garbled value, SQL error — so an
16661 // unreadable cache RUNS the probe, it never short-circuits to trusting it.
16662 let fingerprint = probe_verification_fingerprint(identity, mean_vec.as_deref(), stored);
16663 if probe_verification_is_cached(connection, &fingerprint) {
16664 return Ok(None);
16665 }
16666
16667 let mut total_flips: u64 = 0;
16668 let mut max_l2: f32 = 0.0;
16669 let mut worst_probe: Option<String> = None;
16670
16671 for (ordinal, probe_text, ref_blob, _, _, _) in stored {
16672 let reference = decode_vector_blob(ref_blob);
16673 let reembed = probe_embed(embedder, probe_text, dimension).ok_or_else(|| {
16674 format!(
16675 "embedder failed/panicked re-embedding probe {ordinal}; \
16676 cannot verify the dense arm (refused)"
16677 )
16678 })?;
16679
16680 // (P2) un-centered L2 — `vec_distance_l2(embedding, vec_f32(query))`.
16681 let l2 = l2_distance(&reembed, &reference);
16682 if l2 > max_l2 {
16683 max_l2 = l2;
16684 worst_probe = Some(probe_text.to_string());
16685 }
16686
16687 // (P1) mean-centered Phase-1 flip count — same
16688 // `vec_quantize_binary(sign(x − mean_vec))` path as build_vector_phase1_sql.
16689 let (ref_c, reembed_c) = match &mean_vec {
16690 Some(mean) => (subtract_mean(&reference, mean), subtract_mean(&reembed, mean)),
16691 None => (reference.clone(), reembed.clone()),
16692 };
16693 let ref_bits = quantize_binary_via_sql(connection, &ref_c).ok_or_else(|| {
16694 format!("vec_quantize_binary SQL failed for probe {ordinal}; cannot verify P1")
16695 })?;
16696 let reembed_bits = quantize_binary_via_sql(connection, &reembed_c).ok_or_else(|| {
16697 format!("vec_quantize_binary SQL failed for probe {ordinal}; cannot verify P1")
16698 })?;
16699 total_flips = total_flips.saturating_add(hamming_bytes(&ref_bits, &reembed_bits));
16700 }
16701
16702 let p1_tripped = total_flips > VECTOR_EQUIVALENCE_P1_FLIP_FLOOR;
16703 let p2_tripped = max_l2 > VECTOR_EQUIVALENCE_L2_EPSILON;
16704 if p1_tripped || p2_tripped {
16705 let probe_hint = worst_probe.as_deref().unwrap_or("<unknown>");
16706 return Err(format!(
16707 "P1 mean-centered embedding_bin flips={total_flips} (floor={VECTOR_EQUIVALENCE_P1_FLIP_FLOOR}), \
16708 P2 max un-centered L2={max_l2:.3e} (epsilon={VECTOR_EQUIVALENCE_L2_EPSILON:.3e}); \
16709 worst probe {probe_hint:?}"
16710 ));
16711 }
16712
16713 // The caller persists this accepted fingerprint only after its enclosing arm
16714 // has become durable. That ordering keeps a rejected prospective preflight
16715 // entirely read-only while preserving cache behavior for registered arms.
16716 Ok(Some(fingerprint))
16717}
16718
16719/// 0.8.18 Slice 5 — un-centered Euclidean (L2) distance, matching the
16720/// `vec_distance_l2` semantics used by the Phase-2 rerank.
16721fn l2_distance(a: &[f32], b: &[f32]) -> f32 {
16722 a.iter().zip(b.iter()).map(|(x, y)| (x - y) * (x - y)).sum::<f32>().sqrt()
16723}
16724
16725/// 0.8.18 Slice 5 — produce the packed 1-bit `embedding_bin` blob for a (possibly
16726/// mean-centered) f32 vector via the SAME SQL `vec_quantize_binary` the production
16727/// Phase-1 path uses, so the probe's bits are byte-equal to the engine's
16728/// `embedding_bin` production. `None` on any SQL/serialization error.
16729fn quantize_binary_via_sql(connection: &Connection, vector: &[f32]) -> Option<Vec<u8>> {
16730 let json = serde_json::to_string(vector).ok()?;
16731 connection
16732 .query_row("SELECT vec_quantize_binary(vec_f32(?1))", [json], |row| {
16733 row.get::<_, Vec<u8>>(0)
16734 })
16735 .ok()
16736}
16737
16738/// 0.8.18 Slice 5 — Hamming distance (differing bit count) between two equal-length
16739/// packed bit blobs. Unequal lengths ⇒ count every bit of the length delta as
16740/// differing (a shape divergence is a divergence).
16741fn hamming_bytes(a: &[u8], b: &[u8]) -> u64 {
16742 let common = a.len().min(b.len());
16743 let mut flips: u64 = 0;
16744 for i in 0..common {
16745 flips += u64::from((a[i] ^ b[i]).count_ones());
16746 }
16747 let extra = a.len().abs_diff(b.len());
16748 flips + (extra as u64) * 8
16749}
16750
16751/// Maps the writer-facing `kind` value to the locked Pack 1
16752/// `source_type` partition-key vocabulary. Must stay in lockstep with
16753/// the CASE WHEN inlined in migration step 9
16754/// (`fathomdb-schema/src/lib.rs`); the drift-detection unit test in
16755/// this module's `tests` mod enforces that. Per
16756/// `dev/design/0.7.0-vector-quant-pack1.md` D3.
16757fn resolve_source_type(kind: &str) -> Result<&'static str, EngineError> {
16758 Ok(match kind {
16759 "email" => "email",
16760 "article" => "article",
16761 "paper" => "paper",
16762 "meeting" => "meeting",
16763 "note" => "note",
16764 "todo" => "todo",
16765 // Synthetic AC-013 test fixture; coerced so the 6-value HITL lock holds.
16766 "doc" => "article",
16767 // G11 (Slice 15) — edge-body projection; separate `source_type` partition
16768 // key distinguishes edge vectors from node vectors in `vector_default`.
16769 "edge_fact" => "edge_fact",
16770 _ => return Err(EngineError::Storage),
16771 })
16772}
16773
16774/// 0.8.20 Slice 20c fix-2 (codex §9 [P1]) — **can the vector writer COMMIT a row
16775/// of this kind?** The ONE definition of the vector pipeline's kind domain, shared
16776/// by every enrolment path.
16777///
16778/// [`commit_projection_outcomes`] resolves `kind -> source_type` through
16779/// [`resolve_source_type`] and returns `Err` for anything outside its locked
16780/// vocabulary — *before* it records the row's terminal. `PreparedWrite::Node`, by
16781/// contrast, accepts ANY non-empty `kind` (`validate_write` constrains the body,
16782/// the identity and the validity window, never the kind against that vocabulary),
16783/// so a corpus can legitimately hold e.g. an `"invoice"` node.
16784///
16785/// Enrolling such a kind is therefore a permanent LIVENESS WEDGE for the whole
16786/// workspace: the scheduler picks the row up, the commit fails, no terminal is
16787/// ever written, the scanner re-enqueues it forever, `drain` burns its entire
16788/// timeout into [`EngineError::Scheduler`] and `dense_readiness` sticks on
16789/// `embedding` — starving the rows whose kinds ARE commit-able along with it.
16790///
16791/// So enrolment is RESTRICTED to this predicate rather than the vector writer
16792/// being taught arbitrary kinds (which would reach into `resolve_source_type`'s
16793/// locked Pack-1 partition-key semantics — `dev/design/0.7.0-vector-quant-pack1.md`
16794/// D3, a HITL lock). A non-commit-able kind simply gets NO dense arm, which is
16795/// precisely its pre-slice status quo; it is deliberately **not** a new typed
16796/// error and adds no governed surface.
16797///
16798/// It DELEGATES to `resolve_source_type` instead of restating the list. A
16799/// hand-copied second vocabulary is the TC-56 defect shape (a mirror that silently
16800/// drifts from its original), and here the drift would be silent in the worst
16801/// direction: a kind added to `resolve_source_type` but missing from a copied
16802/// filter would just never be embedded.
16803fn kind_is_vector_committable(kind: &str) -> bool {
16804 resolve_source_type(kind).is_ok()
16805}
16806
16807/// G11 (Slice 15) — derive a stable hex-encoded sha256 logical_id from a
16808/// `(kind, name)` pair. Both inputs are lowercased before hashing so that
16809/// entity identity is case-insensitive (`"Alice"` == `"alice"`). The
16810/// canonical form is `sha256("<kind>:<name>")` — identical to the
16811/// ADR-0.8.1-byo-llm derivation rule.
16812///
16813/// fix-34 [P1]: because `:` is the delimiter, a `:` in `kind` would let the
16814/// split point move and collide two distinct `(kind, name)` pairs onto one
16815/// identity (e.g. `("a:b","c")` and `("a","b:c")` both hash `"a:b:c"`),
16816/// silently dropping one entity via batch dedup / G0 supersession. An empty
16817/// `name` collapses every name-less entity of a kind onto `sha256("<kind>:")`.
16818/// We reject both at the boundary; this preserves the ADR derivation rule
16819/// (a colon-free `kind` makes the first `:` an unambiguous delimiter, so a `:`
16820/// in `name` stays safe — edge keys deliberately rely on that).
16821fn derive_logical_id(kind: &str, name: &str) -> Result<String, EngineError> {
16822 if kind.contains(':') || name.is_empty() {
16823 return Err(EngineError::Extractor);
16824 }
16825 let input = format!("{}:{}", kind.to_lowercase(), name.to_lowercase());
16826 let mut hasher = Sha256::new();
16827 hasher.update(input.as_bytes());
16828 // digest 0.11 returns `hybrid_array::Array`, which (unlike the old
16829 // `GenericArray`) does not implement `LowerHex`. Format the bytes
16830 // explicitly — byte-identical lowercase, zero-padded hex to the prior
16831 // `{:x}` rendering, preserving the load-bearing logical-id derivation.
16832 Ok(hasher.finalize().iter().map(|b| format!("{b:02x}")).collect())
16833}
16834
16835/// Cause-A (0.8.11.2) / C-2 (0.8.19, TC-8) — derive the typed **stable hit-id**
16836/// ([`IdSpace`]) carried on [`SearchHit::id`] for cross-session real-gold keying.
16837///
16838/// The stable id is the active canonical node's `logical_id` — the post-G0
16839/// supersession-stable identity, preserved across re-projection/re-ingest by the
16840/// tombstone-then-insert contract (whereas the engine-internal `write_cursor` is
16841/// reassigned on every re-ingest). When `logical_id` is NULL — the doc-seeded
16842/// node case, the *dominant* corpus hit type today — we fall back to a content
16843/// hash of the body so doc hits still carry a re-ingest-survivable key.
16844///
16845/// The result is a typed [`IdSpace`]; its `to_prefixed()` reproduces the pre-C-2
16846/// `stable_id` string byte-for-byte so real-gold keying is a no-op:
16847/// - [`IdSpace::logical`] (`"l:<logical_id>"`) — entities + edges (graph-arm,
16848/// vector-node, and edge hits when `logical_id` is present);
16849/// - [`IdSpace::content`] (`"h:<sha256(body)>"`) — doc nodes with NULL
16850/// `logical_id`, and any branch that cannot cheaply resolve a `logical_id`.
16851///
16852/// Behaviour-neutral: the value never participates in ranking/scoring (same
16853/// additive posture as `source_id` / `ce_score`).
16854fn derive_stable_id(logical_id: Option<&str>, body: &str) -> IdSpace {
16855 match logical_id {
16856 Some(lid) if !lid.is_empty() => IdSpace::logical(lid),
16857 _ => {
16858 let mut hasher = Sha256::new();
16859 hasher.update(body.as_bytes());
16860 IdSpace::content(
16861 hasher.finalize().iter().map(|b| format!("{b:02x}")).collect::<String>(),
16862 )
16863 }
16864 }
16865}
16866
16867/// fix-34 [P2]: dedup a batch of [`PreparedWrite`]s by `logical_id`, keeping the
16868/// first occurrence. Shared by the entity and edge arms of the BYO-LLM ingest
16869/// path so a harness that returns the same node/edge twice in one response does
16870/// not write a row that immediately supersedes its sibling.
16871///
16872/// **TC-32 (0.8.20) — single-provenance entity dedupe is INTENTIONAL and
16873/// ACCEPTED.** Because dedupe keeps the FIRST occurrence, same-name entities
16874/// collapse onto one `logical_id` row that carries only the FIRST document's
16875/// `source_id`; erasing a later document therefore does not remove the shared
16876/// entity row. The HITL has ruled this acceptable for now and explicitly
16877/// declined a multi-source-provenance model. Tracked as TC-32 — do not "fix"
16878/// this by changing dedupe behaviour without a fresh decision.
16879fn dedup_prepared_by_logical_id(batch: Vec<PreparedWrite>) -> Vec<PreparedWrite> {
16880 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
16881 batch
16882 .into_iter()
16883 .filter(|w| match w {
16884 PreparedWrite::Node { logical_id: Some(id), .. }
16885 | PreparedWrite::Edge { logical_id: Some(id), .. } => seen.insert(id.clone()),
16886 _ => true,
16887 })
16888 .collect()
16889}
16890
16891/// 0.8.6 Slice 5 (ADR-0.8.6) — the family of caller-supplied provider tasks that
16892/// ride the one NDJSON-over-stdio transport. Each task maps to a wire protocol
16893/// string `fathomdb.<task>.v1` and a task discriminator name. `Extract` shipped
16894/// in 0.8.6; `Consolidate` (0.8.12 Slice 15, OPP-2) is the SECOND consumer of
16895/// this one transport — it adds only a variant, a payload, and an `EngineError`
16896/// leaf, WITHOUT a second handshake or a second transport.
16897#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16898enum ProviderTask {
16899 Extract,
16900 /// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — consolidation / recency provider.
16901 Consolidate,
16902}
16903
16904impl ProviderTask {
16905 /// The wire task discriminator, e.g. `"extract"`. Used for `supported_tasks`
16906 /// negotiation and as the request envelope `type`.
16907 fn name(self) -> &'static str {
16908 match self {
16909 ProviderTask::Extract => "extract",
16910 ProviderTask::Consolidate => "consolidate",
16911 }
16912 }
16913
16914 /// The protocol string FathomDB sends in `hello`/requests and requires in
16915 /// `ready`. For `Extract` this is the UNCHANGED `fathomdb.extract.v1` —
16916 /// byte-identical back-compat for existing ELPS harnesses (ADR-0.8.6 §2.1).
16917 /// For `Consolidate` it is `fathomdb.consolidate.v1` (ADR-0.8.12 §2).
16918 fn protocol(self) -> &'static str {
16919 match self {
16920 ProviderTask::Extract => "fathomdb.extract.v1",
16921 ProviderTask::Consolidate => "fathomdb.consolidate.v1",
16922 }
16923 }
16924}
16925
16926/// 0.8.6 Slice 5 (ADR-0.8.6) — an open provider transport session: the spawned
16927/// caller subprocess, the buffered stdin writer, the detached stdout-drain
16928/// channel, the bounded-recv timeout, and the negotiated handshake state
16929/// (`model` provenance + `max_docs_per_request`). One session serves one task
16930/// family; the `request`/framing is identical across tasks. `Drop` reaps the
16931/// child (sends stdin EOF via the writer field's own drop, then kill/wait),
16932/// replacing the prior explicit outer kill/wait.
16933struct ProviderSession {
16934 task: ProviderTask,
16935 child: std::process::Child,
16936 writer: std::io::BufWriter<std::process::ChildStdin>,
16937 line_rx: Receiver<std::io::Result<String>>,
16938 io_timeout: Duration,
16939 /// `ready.model`, recorded as output-row provenance (`extractor_model_id`).
16940 model: Option<String>,
16941 max_docs_per_request: usize,
16942}
16943
16944impl Drop for ProviderSession {
16945 fn drop(&mut self) {
16946 // The detached stdout-drain thread exits when the child's stdout closes;
16947 // kill() guarantees that even for a child that ignores stdin EOF. The
16948 // `writer` field drops after this (declaration order) sending EOF too.
16949 let _ = self.child.kill();
16950 let _ = self.child.wait();
16951 }
16952}
16953
16954impl ProviderSession {
16955 /// Run the `hello` → `ready` handshake and `supported_tasks` negotiation.
16956 /// Validates protocol + schema_version (fix-23 [P2]); rejects a zero
16957 /// `max_docs_per_request` (fix-1 [P2]); and, when the harness advertises
16958 /// `supported_tasks`, refuses to proceed unless this session's task is in it.
16959 /// When `supported_tasks` is absent, the harness is assumed to serve the
16960 /// requested task (back-compat: existing extract-only harnesses unchanged).
16961 fn handshake(&mut self) -> Result<(), EngineError> {
16962 let protocol = self.task.protocol();
16963 let hello = serde_json::json!({
16964 "protocol": protocol,
16965 "type": "hello",
16966 "schema_version": 1,
16967 });
16968 let hello_line = serde_json::to_string(&hello).map_err(|_| EngineError::Extractor)?;
16969 writeln!(self.writer, "{hello_line}").map_err(|_| EngineError::Extractor)?;
16970 self.writer.flush().map_err(|_| EngineError::Extractor)?;
16971
16972 let line = recv_extractor_line(&self.line_rx, self.io_timeout)?;
16973 let ready: Value = serde_json::from_str(line.trim()).map_err(|_| EngineError::Extractor)?;
16974 // fix-23 [P2]: validate protocol + schema_version in the ready message per ADR.
16975 if ready.get("type").and_then(|v| v.as_str()) != Some("ready")
16976 || ready.get("protocol").and_then(|v| v.as_str()) != Some(protocol)
16977 || ready.get("schema_version").and_then(|v| v.as_u64()) != Some(1)
16978 {
16979 return Err(EngineError::Extractor);
16980 }
16981
16982 // 0.8.6 Slice 5 (ADR-0.8.6 §2.2): additive, optional `supported_tasks`
16983 // negotiation. If present, the harness must advertise this session's task
16984 // or FathomDB refuses to dispatch it. If absent, default to "serves the
16985 // requested task" so extract-only harnesses keep working unchanged.
16986 if let Some(supported) = ready.get("supported_tasks").and_then(|v| v.as_array()) {
16987 let task_name = self.task.name();
16988 let advertised = supported.iter().any(|t| t.as_str() == Some(task_name));
16989 if !advertised {
16990 return Err(EngineError::Extractor);
16991 }
16992 }
16993
16994 self.model = ready.get("model").and_then(|v| v.as_str()).map(|s| s.to_string());
16995 let max_docs =
16996 ready.get("max_docs_per_request").and_then(|v| v.as_u64()).unwrap_or(8) as usize;
16997 // fix-1 [P2]: reject zero max_docs_per_request to prevent chunks(0) panic.
16998 if max_docs == 0 {
16999 return Err(EngineError::Extractor);
17000 }
17001 self.max_docs_per_request = max_docs;
17002 Ok(())
17003 }
17004
17005 /// Send one framed request for this session's task and receive its matching
17006 /// response. `payload` carries the task-specific fields; the envelope keys
17007 /// (`protocol`, `type`, `request_id`) are added here. The response must have
17008 /// `type == "result"` and a matching `request_id` (fix-24 [P2]); anything
17009 /// else (error, wrong id, missing type) is a protocol fault. For `Extract`
17010 /// the serialized request bytes are identical to the pre-0.8.6 path (serde_json
17011 /// serializes map keys sorted, independent of insertion order).
17012 fn request(
17013 &mut self,
17014 request_id: &str,
17015 payload: Vec<(String, Value)>,
17016 ) -> Result<Value, EngineError> {
17017 let mut req = serde_json::Map::new();
17018 req.insert("protocol".to_string(), Value::from(self.task.protocol()));
17019 req.insert("type".to_string(), Value::from(self.task.name()));
17020 req.insert("request_id".to_string(), Value::from(request_id));
17021 for (k, v) in payload {
17022 req.insert(k, v);
17023 }
17024 let req_line =
17025 serde_json::to_string(&Value::Object(req)).map_err(|_| EngineError::Extractor)?;
17026 writeln!(self.writer, "{req_line}").map_err(|_| EngineError::Extractor)?;
17027 self.writer.flush().map_err(|_| EngineError::Extractor)?;
17028
17029 let result_line = recv_extractor_line(&self.line_rx, self.io_timeout)?;
17030 let result: Value =
17031 serde_json::from_str(result_line.trim()).map_err(|_| EngineError::Extractor)?;
17032 let resp_type = result.get("type").and_then(|v| v.as_str());
17033 let resp_id = result.get("request_id").and_then(|v| v.as_str());
17034 if resp_type != Some("result") || resp_id != Some(request_id) {
17035 return Err(EngineError::Extractor);
17036 }
17037 Ok(result)
17038 }
17039}
17040
17041/// fix-35 [P2]: BYO-LLM extractor I/O timeout. Defaults to 300s to accommodate
17042/// slow LLM harnesses; override (in milliseconds) via
17043/// `FATHOMDB_EXTRACTOR_TIMEOUT_MS` (tests use this to exercise the hung-harness
17044/// path quickly).
17045fn extractor_io_timeout() -> Duration {
17046 std::env::var("FATHOMDB_EXTRACTOR_TIMEOUT_MS")
17047 .ok()
17048 .and_then(|s| s.parse::<u64>().ok())
17049 .map(Duration::from_millis)
17050 .unwrap_or_else(|| Duration::from_secs(300))
17051}
17052
17053/// fix-35 [P1/P2]: receive one line from the stdout reader thread, bounded by
17054/// `timeout`. A timeout, a closed channel (reader thread ended / child EOF), or
17055/// an underlying io error all map to [`EngineError::Extractor`].
17056fn recv_extractor_line(
17057 rx: &Receiver<std::io::Result<String>>,
17058 timeout: Duration,
17059) -> Result<String, EngineError> {
17060 match rx.recv_timeout(timeout) {
17061 Ok(Ok(line)) => Ok(line),
17062 _ => Err(EngineError::Extractor),
17063 }
17064}
17065
17066fn map_runtime_embedder_error(err: RuntimeEmbedderError) -> EngineError {
17067 match err {
17068 RuntimeEmbedderError::Failed { .. } | RuntimeEmbedderError::Timeout => {
17069 EngineError::Embedder
17070 }
17071 }
17072}
17073
17074fn default_embedder_identity() -> EmbedderIdentity {
17075 EmbedderIdentity::new(
17076 DEFAULT_EMBEDDER_NAME,
17077 DEFAULT_EMBEDDER_REVISION,
17078 DEFAULT_EMBEDDER_DIMENSION,
17079 )
17080}
17081
17082fn check_embedder_profile(
17083 connection: &Connection,
17084 supplied: &EmbedderIdentity,
17085) -> Result<bool, EngineOpenError> {
17086 // Returns `true` iff `_fathomdb_embedder_profiles.mean_vec IS NOT NULL`
17087 // for the default profile (and its byte length matches `4 * dimension`
17088 // per `dev/design/embedder.md` §0.2). EU-5a2: column lands in step 10.
17089 let mut statement = match connection.prepare(
17090 "SELECT name, revision, dimension, mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
17091 ) {
17092 Ok(statement) => statement,
17093 Err(_) => return Ok(false),
17094 };
17095 let mut rows = statement.query([]).map_err(|_| {
17096 EngineOpenError::Corruption(CorruptionDetail {
17097 kind: CorruptionKind::EmbedderIdentityDrift,
17098 stage: OpenStage::EmbedderIdentity,
17099 locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
17100 recovery_hint: RecoveryHint {
17101 code: "E_CORRUPT_EMBEDDER_IDENTITY",
17102 doc_anchor: "design/recovery.md#embedder-identity-drift",
17103 },
17104 })
17105 })?;
17106
17107 let Some(row) = rows.next().map_err(|_| {
17108 EngineOpenError::Corruption(CorruptionDetail {
17109 kind: CorruptionKind::EmbedderIdentityDrift,
17110 stage: OpenStage::EmbedderIdentity,
17111 locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
17112 recovery_hint: RecoveryHint {
17113 code: "E_CORRUPT_EMBEDDER_IDENTITY",
17114 doc_anchor: "design/recovery.md#embedder-identity-drift",
17115 },
17116 })
17117 })?
17118 else {
17119 connection
17120 .execute(
17121 "INSERT INTO _fathomdb_embedder_profiles(profile, name, revision, dimension)
17122 VALUES(?1, ?2, ?3, ?4)",
17123 params![
17124 DEFAULT_VECTOR_PROFILE,
17125 supplied.name,
17126 supplied.revision,
17127 supplied.dimension
17128 ],
17129 )
17130 .map_err(|_| EngineOpenError::Io {
17131 message: "could not persist embedder profile".to_string(),
17132 })?;
17133 return Ok(false);
17134 };
17135
17136 let stored_name = row.get::<_, String>(0).map_err(|_| {
17137 EngineOpenError::Corruption(CorruptionDetail {
17138 kind: CorruptionKind::EmbedderIdentityDrift,
17139 stage: OpenStage::EmbedderIdentity,
17140 locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
17141 recovery_hint: RecoveryHint {
17142 code: "E_CORRUPT_EMBEDDER_IDENTITY",
17143 doc_anchor: "design/recovery.md#embedder-identity-drift",
17144 },
17145 })
17146 })?;
17147 let stored_revision = row.get::<_, String>(1).map_err(|_| {
17148 EngineOpenError::Corruption(CorruptionDetail {
17149 kind: CorruptionKind::EmbedderIdentityDrift,
17150 stage: OpenStage::EmbedderIdentity,
17151 locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
17152 recovery_hint: RecoveryHint {
17153 code: "E_CORRUPT_EMBEDDER_IDENTITY",
17154 doc_anchor: "design/recovery.md#embedder-identity-drift",
17155 },
17156 })
17157 })?;
17158 let dimension = row.get::<_, u32>(2).map_err(|_| {
17159 EngineOpenError::Corruption(CorruptionDetail {
17160 kind: CorruptionKind::EmbedderIdentityDrift,
17161 stage: OpenStage::EmbedderIdentity,
17162 locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
17163 recovery_hint: RecoveryHint {
17164 code: "E_CORRUPT_EMBEDDER_IDENTITY",
17165 doc_anchor: "design/recovery.md#embedder-identity-drift",
17166 },
17167 })
17168 })?;
17169
17170 let stored = EmbedderIdentity::new(stored_name, stored_revision, dimension);
17171
17172 if stored.name != supplied.name || stored.revision != supplied.revision {
17173 return Err(EngineOpenError::EmbedderIdentityMismatch {
17174 stored,
17175 supplied: supplied.clone(),
17176 });
17177 }
17178 if dimension != supplied.dimension {
17179 return Err(EngineOpenError::EmbedderDimensionMismatch {
17180 stored: dimension,
17181 supplied: supplied.dimension,
17182 });
17183 }
17184
17185 // EU-5a2 / `dev/design/embedder.md` §0.2 invariant: if `mean_vec` is
17186 // populated, byte length MUST equal `4 * dimension`. Debug builds
17187 // assert; release builds fail closed via EmbedderIdentityMismatch
17188 // (the same fail-closed channel the rest of profile drift takes).
17189 let mean_vec: Option<Vec<u8>> = row.get::<_, Option<Vec<u8>>>(3).map_err(|_| {
17190 EngineOpenError::Corruption(CorruptionDetail {
17191 kind: CorruptionKind::EmbedderIdentityDrift,
17192 stage: OpenStage::EmbedderIdentity,
17193 locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
17194 recovery_hint: RecoveryHint {
17195 code: "E_CORRUPT_EMBEDDER_IDENTITY",
17196 doc_anchor: "design/recovery.md#embedder-identity-drift",
17197 },
17198 })
17199 })?;
17200 let pinned = match mean_vec {
17201 Some(bytes) => {
17202 let expected_len = (dimension as usize).saturating_mul(4);
17203 // `dev/design/embedder.md` §0.2 invariant: when populated,
17204 // `mean_vec` byte length MUST equal `4 * dimension`. Fail
17205 // closed via the existing identity-drift channel in both
17206 // debug and release builds — tests deliberately poke
17207 // malformed values to exercise this branch.
17208 if bytes.len() != expected_len {
17209 return Err(EngineOpenError::EmbedderIdentityMismatch {
17210 stored,
17211 supplied: supplied.clone(),
17212 });
17213 }
17214 true
17215 }
17216 None => false,
17217 };
17218
17219 Ok(pinned)
17220}
17221
17222#[derive(Clone, Debug, Eq, PartialEq)]
17223enum WritePlan {
17224 Node,
17225 Edge,
17226 AppendOnlyLog,
17227 LatestState,
17228 AdminSchema,
17229}
17230
17231fn validate_batch(
17232 connection: &Connection,
17233 batch: &[PreparedWrite],
17234) -> Result<Vec<WritePlan>, EngineError> {
17235 batch.iter().map(|write| validate_write(connection, write)).collect()
17236}
17237
17238fn collect_projection_jobs(
17239 connection: &Connection,
17240 batch: &[PreparedWrite],
17241) -> Result<Vec<ProjectionJob>, EngineError> {
17242 let mut jobs = Vec::new();
17243 for write in batch {
17244 if let PreparedWrite::Node { kind, body, .. } = write {
17245 // 0.8.20 Slice 20c — this probe decides whether `notify_new_work` is
17246 // called, so `Engine::enrol_batch_vector_kinds` MUST already have run
17247 // on this batch: a kind enrolled after this point would be enqueued in
17248 // the database with the dispatcher left asleep on
17249 // `pending_scan == false`, and because `drain` is a passive barrier
17250 // (C4 rider: never a trigger) the next `drain` would burn its ENTIRE
17251 // timeout and return `EngineError::Scheduler` on work ready to run.
17252 if kind_is_vector_indexed(connection, kind)? {
17253 jobs.push(ProjectionJob { cursor: 0, kind: kind.clone(), body: body.clone() });
17254 }
17255 }
17256 }
17257 Ok(jobs)
17258}
17259
17260fn validate_write(
17261 connection: &Connection,
17262 write: &PreparedWrite,
17263) -> Result<WritePlan, EngineError> {
17264 match write {
17265 PreparedWrite::Node { kind, body, logical_id, valid_from, valid_until, .. } => {
17266 if kind.trim().is_empty() || body.trim().is_empty() {
17267 return Err(EngineError::WriteValidation);
17268 }
17269 // 0.8.20 Slice 15b (TC-34) — the validity window is HALF-OPEN
17270 // `[valid_from, valid_until)`, so a pair with `from >= until` selects
17271 // no instant at all: the row would be written but no default read
17272 // could ever return it. Silently accepting that is a trap, so it is a
17273 // typed refusal.
17274 //
17275 // 0.8.20 Slice 22 (R-20-VC) — **decision #18, SETTLED: one family.**
17276 // This site used to return `EngineError::InvalidArgument { msg }`
17277 // carrying both bounds, which made `validate_write` — ONE function —
17278 // reject across TWO error families, so the same `write` call raised
17279 // `InvalidArgumentError` for an inverted window and
17280 // `WriteValidationError` for a non-integer bound. `dev/design/errors.md`
17281 // (status: locked) defines `WriteValidationError` as "malformed typed
17282 // write shape" / "the submitted typed write is malformed **before**
17283 // schema-sensitive payload checks run" — which is exactly this
17284 // boundary — so the code now agrees with the taxonomy of record.
17285 // `InvalidArgument` stays the family for caller-argument rejections
17286 // OUTSIDE this boundary (see the errors.md 2026-07-28 amendment).
17287 //
17288 // **The cost, stated:** `WriteValidation` is a UNIT variant and both
17289 // bindings map it to a fixed message-less string, so the offending
17290 // bounds are no longer recoverable from the error. That is a breaking
17291 // behaviour change on a published surface (CHANGELOG 0.8.20) and it is
17292 // the diagnostic the prior split existed to preserve. Restoring it
17293 // needs a message-carrying `WriteValidation { msg }`, which is a
17294 // cross-cutting change across every engine + binding raise site and
17295 // both binding payload shapes — its own slice, not this one.
17296 //
17297 // Only the PAIR can be empty. A one-sided window is unbounded on the
17298 // missing side and can never be empty, so it is never refused.
17299 if let (Some(from), Some(until)) = (valid_from, valid_until) {
17300 if from >= until {
17301 return Err(EngineError::WriteValidation);
17302 }
17303 }
17304 // R-20-E3: `source_id` needs no emptiness check here — `SourceId`
17305 // cannot hold an empty or reserved id, so the check has moved from
17306 // this branch into the type's constructor.
17307 // G0 — an explicit logical_id must be non-empty (NULL/None is the
17308 // legacy default; an empty string is never a valid identity).
17309 // Also reject char(30) = \x1e (ASCII RS), which is the BFS cycle-guard
17310 // delimiter; allowing it would corrupt the visited-path substring test.
17311 if let Some(logical_id) = logical_id {
17312 if logical_id.is_empty() || logical_id.contains('\x1e') {
17313 return Err(EngineError::WriteValidation);
17314 }
17315 }
17316 Ok(WritePlan::Node)
17317 }
17318 PreparedWrite::Edge { kind, from, to, logical_id, t_valid, t_invalid, .. } => {
17319 if kind.trim().is_empty() || from.trim().is_empty() || to.trim().is_empty() {
17320 return Err(EngineError::WriteValidation);
17321 }
17322 // Reject char(30) in from/to: these become from_id/to_id in canonical_edges
17323 // and appear in BFS visited strings — an \x1e there would corrupt the guard.
17324 if from.contains('\x1e') || to.contains('\x1e') {
17325 return Err(EngineError::WriteValidation);
17326 }
17327 // R-20-E3: see the Node branch — emptiness is a `SourceId` invariant.
17328 if let Some(logical_id) = logical_id {
17329 if logical_id.is_empty() || logical_id.contains('\x1e') {
17330 return Err(EngineError::WriteValidation);
17331 }
17332 }
17333 // TC-33 fix-1 (codex §9 P2) — an epoch SQLite cannot render to
17334 // ISO-8601 must be UNSTORABLE. The governed integer surface is the
17335 // only way to reach one (inbound ISO normalisation maxes at year
17336 // 9999), so this write boundary is where it is stopped, before it
17337 // can render to a silent `null` on the consolidation wire and
17338 // resurrect an invalidated edge. Structural primary layer; the
17339 // render site keeps a defensive hard-assert as the backstop.
17340 reject_unrenderable_edge_epoch("t_valid", *t_valid)?;
17341 reject_unrenderable_edge_epoch("t_invalid", *t_invalid)?;
17342 Ok(WritePlan::Edge)
17343 }
17344 PreparedWrite::AdminSchema { name, kind, schema_json, retention_json } => {
17345 if name.trim().is_empty()
17346 || !matches!(kind.as_str(), "append_only_log" | "latest_state")
17347 || serde_json::from_str::<Value>(schema_json).is_err()
17348 || serde_json::from_str::<Value>(retention_json).is_err()
17349 || contains_external_ref(schema_json)
17350 {
17351 return Err(EngineError::SchemaValidation);
17352 }
17353 Ok(WritePlan::AdminSchema)
17354 }
17355 PreparedWrite::OpStore { collection, record_key, schema_id, body } => {
17356 if collection.trim().is_empty() || record_key.trim().is_empty() {
17357 return Err(EngineError::WriteValidation);
17358 }
17359 let (kind, schema_json) = collection_metadata(connection, collection)?;
17360 if let Some(schema_id) = schema_id {
17361 if schema_id != collection {
17362 return Err(EngineError::SchemaValidation);
17363 }
17364 validate_payload(&schema_json, body)?;
17365 } else if serde_json::from_str::<Value>(body).is_err() {
17366 return Err(EngineError::SchemaValidation);
17367 }
17368
17369 match kind.as_str() {
17370 "append_only_log" => Ok(WritePlan::AppendOnlyLog),
17371 "latest_state" => Ok(WritePlan::LatestState),
17372 _ => Err(EngineError::OpStore),
17373 }
17374 }
17375 }
17376}
17377
17378fn collection_metadata(
17379 connection: &Connection,
17380 collection: &str,
17381) -> Result<(String, String), EngineError> {
17382 connection
17383 .query_row(
17384 "SELECT kind, schema_json FROM operational_collections WHERE name = ?1",
17385 [collection],
17386 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
17387 )
17388 .map_err(|_| EngineError::OpStore)
17389}
17390
17391fn validate_payload(schema_json: &str, body: &str) -> Result<(), EngineError> {
17392 let schema =
17393 serde_json::from_str::<Value>(schema_json).map_err(|_| EngineError::SchemaValidation)?;
17394 let payload = serde_json::from_str::<Value>(body).map_err(|_| EngineError::SchemaValidation)?;
17395
17396 let compiled = JSONSchema::compile(&schema).map_err(|_| EngineError::SchemaValidation)?;
17397 compiled.validate(&payload).map_err(|_| EngineError::SchemaValidation)?;
17398
17399 Ok(())
17400}
17401
17402fn contains_external_ref(schema_json: &str) -> bool {
17403 let Ok(value) = serde_json::from_str::<Value>(schema_json) else {
17404 return false;
17405 };
17406 value_contains_external_ref(&value)
17407}
17408
17409fn value_contains_external_ref(value: &Value) -> bool {
17410 match value {
17411 Value::Object(object) => object.iter().any(|(key, value)| {
17412 if key == "$ref" {
17413 return value.as_str().is_some_and(|uri| !uri.starts_with('#'));
17414 }
17415 value_contains_external_ref(value)
17416 }),
17417 Value::Array(values) => values.iter().any(value_contains_external_ref),
17418 _ => false,
17419 }
17420}
17421
17422// fix-30 [P2]: helpers to collect active edge write_cursors BEFORE a supersession
17423// UPDATE so the callers can prune stale vector_default rows.
17424fn prior_edge_cursors_by_logical_id(
17425 tx: &rusqlite::Transaction<'_>,
17426 logical_id: &str,
17427) -> rusqlite::Result<Vec<i64>> {
17428 let mut s = tx.prepare_cached(
17429 "SELECT write_cursor FROM canonical_edges \
17430 WHERE logical_id = ?1 AND superseded_at IS NULL",
17431 )?;
17432 let rows = s.query_map(params![logical_id], |r| r.get(0))?;
17433 rows.collect()
17434}
17435
17436/// 0.8.20 Slice 15d fix-1 finding 2 [P2] — the active (non-superseded) NODE
17437/// cursors for a `logical_id`, collected BEFORE the tombstone-then-insert
17438/// supersession UPDATE so the caller can purge the about-to-be-superseded row's
17439/// row-owned attribute projections. Mirrors [`prior_edge_cursors_by_logical_id`].
17440/// The partial-unique-active index means this is at most one cursor; a `Vec`
17441/// keeps it robust and symmetric with the edge path.
17442fn prior_node_cursors_by_logical_id(
17443 tx: &rusqlite::Transaction<'_>,
17444 logical_id: &str,
17445) -> rusqlite::Result<Vec<i64>> {
17446 let mut s = tx.prepare_cached(
17447 "SELECT write_cursor FROM canonical_nodes \
17448 WHERE logical_id = ?1 AND superseded_at IS NULL",
17449 )?;
17450 let rows = s.query_map(params![logical_id], |r| r.get(0))?;
17451 rows.collect()
17452}
17453
17454fn prior_edge_cursors_by_triple(
17455 tx: &rusqlite::Transaction<'_>,
17456 from: &str,
17457 to: &str,
17458 kind: &str,
17459) -> rusqlite::Result<Vec<i64>> {
17460 let mut s = tx.prepare_cached(
17461 "SELECT write_cursor FROM canonical_edges \
17462 WHERE from_id = ?1 AND to_id = ?2 AND kind = ?3 AND superseded_at IS NULL",
17463 )?;
17464 let rows = s.query_map(params![from, to, kind], |r| r.get(0))?;
17465 rows.collect()
17466}
17467
17468/// EXP-S (0.8.14 Slice 5, D2) — the set of coexisting indexes a `row_kind`
17469/// projects into. `fts` = the FTS index (`search_index`), written SYNCHRONOUSLY
17470/// in the write transaction; `vector` = the vec0 vector index, written
17471/// ASYNCHRONOUSLY by the projection worker pool (and additionally gated per
17472/// doc-type `kind` by [`kind_is_vector_indexed`]).
17473#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17474struct IndexTargetSet {
17475 fts: bool,
17476 vector: bool,
17477}
17478
17479/// 0.8.20 Slice 5a (R-20-E1) — the class a row-owned projection table belongs
17480/// to, so the four maintenance sites can each truncate exactly the subset they
17481/// own without re-deriving a hand-rolled table list.
17482///
17483/// - `NodeFts` — same-txn lexical projection of a canonical NODE body.
17484/// - `EdgeFts` — same-txn lexical projection of a canonical EDGE body.
17485/// - `Vector` — the async vec0 materialization (written by the embed worker,
17486/// not by the write path — see [`project_canonical_node_row`]).
17487/// - `Readiness` — the terminal-cursor bookkeeping that lets
17488/// `advance_projection_cursor` walk past a row.
17489#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17490enum ProjectionClass {
17491 NodeFts,
17492 EdgeFts,
17493 Vector,
17494 Readiness,
17495 /// 0.8.20 Slice 15d (R-20-EAV) — the EAV attribute store (`filterable` +
17496 /// the value-at-rest for `searchable`). Same-transaction, row-owned.
17497 Attribute,
17498 /// 0.8.20 Slice 15d (R-20-EAV) — the property-FTS5 shadow of attribute
17499 /// values (`searchable→FTS`). Same-transaction, row-owned.
17500 PropertyFts,
17501}
17502
17503/// 0.8.20 Slice 5a (R-20-E1) — one ROW-OWNED projection table: a shadow whose
17504/// rows are 1:1 with a canonical row's `write_cursor` and therefore MUST die
17505/// with that row.
17506#[derive(Clone, Copy, Debug)]
17507struct RowOwnedProjection {
17508 /// Table name. `'static` and never caller-derived: safe to interpolate.
17509 table: &'static str,
17510 /// The column carrying the owning canonical row's `write_cursor`. For the
17511 /// vec0 table this is `rowid` — vec0 rowid IS the write_cursor (see the
17512 /// `_fathomdb_vector_rows.write_cursor UNIQUE` identity).
17513 cursor_column: &'static str,
17514 class: ProjectionClass,
17515}
17516
17517/// 0.8.20 Slice 5a (R-20-E1) — **the** registry of row-owned projections.
17518///
17519/// Every table here is 1:1 with a canonical `write_cursor` and is erased by
17520/// [`erase_row_projections`] whenever that canonical row is erased. Adding a
17521/// projection table WITHOUT registering it here re-opens the defect this slice
17522/// closes (`search_index_v2` was written by one site and deleted by one site,
17523/// out of five that maintain projections — so `excise_source` left the erased
17524/// body on disk in a content-storing FTS5 table). The `guard_row_owned_registry`
17525/// unit test introspects `sqlite_master` and fails if a `write_cursor`-keyed
17526/// table is missing from this list.
17527///
17528/// **NOT here, deliberately (design v5 §1.1): `_fathomdb_projection_state`.**
17529/// That table is KIND-owned — keyed by `kind`, holding a per-kind enqueue
17530/// watermark. Erasing one row must NOT rewind a whole kind's watermark, so it
17531/// must never be deleted per-cursor. A rebuild resets it deliberately; erasure
17532/// leaves it alone.
17533const ROW_OWNED_PROJECTIONS: &[RowOwnedProjection] = &[
17534 RowOwnedProjection {
17535 table: "search_index",
17536 cursor_column: "write_cursor",
17537 class: ProjectionClass::NodeFts,
17538 },
17539 RowOwnedProjection {
17540 table: "search_index_v2",
17541 cursor_column: "write_cursor",
17542 class: ProjectionClass::NodeFts,
17543 },
17544 RowOwnedProjection {
17545 table: "search_index_edges",
17546 cursor_column: "write_cursor",
17547 class: ProjectionClass::EdgeFts,
17548 },
17549 RowOwnedProjection {
17550 table: "vector_default",
17551 cursor_column: "rowid",
17552 class: ProjectionClass::Vector,
17553 },
17554 RowOwnedProjection {
17555 table: "_fathomdb_vector_rows",
17556 cursor_column: "write_cursor",
17557 class: ProjectionClass::Vector,
17558 },
17559 RowOwnedProjection {
17560 table: "_fathomdb_projection_terminal",
17561 cursor_column: "write_cursor",
17562 class: ProjectionClass::Readiness,
17563 },
17564 // 0.8.20 Slice 15d (R-20-EAV) — the EAV attribute store and its property-FTS
17565 // shadow both hold declared attribute VALUES at rest (potential PII), keyed
17566 // 1:1 with the owning node's write_cursor. They MUST be reachable by
17567 // `purge`/`excise_source`: registering them here is what makes
17568 // `erase_row_projections` delete them without a hand-rolled list (an
17569 // unregistered content-storing table is exactly the `search_index_v2` leak
17570 // class this registry closes). The `guard_row_owned_registry` unit test
17571 // FAILS if either is left unregistered.
17572 RowOwnedProjection {
17573 table: "canonical_attributes",
17574 cursor_column: "write_cursor",
17575 class: ProjectionClass::Attribute,
17576 },
17577 RowOwnedProjection {
17578 table: "property_search_index",
17579 cursor_column: "write_cursor",
17580 class: ProjectionClass::PropertyFts,
17581 },
17582];
17583
17584/// 0.8.20 Slice 5a (R-20-E1) — erase EVERY row-owned projection for one
17585/// canonical `write_cursor`. Returns the number of shadow rows deleted.
17586///
17587/// This is the single erasure primitive: `purge_inner` and `excise_source_inner`
17588/// both call it, so a new projection table becomes erasable by registering it in
17589/// [`ROW_OWNED_PROJECTIONS`] — not by remembering to patch two hand-rolled
17590/// delete lists (the omission that left erased bodies in `search_index_v2`).
17591fn erase_row_projections(tx: &Connection, write_cursor: i64) -> rusqlite::Result<u64> {
17592 let mut deleted: u64 = 0;
17593 for projection in ROW_OWNED_PROJECTIONS {
17594 deleted =
17595 saturating_add_u64(deleted, delete_row_owned_projection(tx, projection, write_cursor)?);
17596 }
17597 Ok(deleted)
17598}
17599
17600/// TC-76 — delete one row-owned projection's rows for one `write_cursor`.
17601/// The vec0 partition is routed through the shared direct-delete primitive;
17602/// every other table is the plain registry-driven statement.
17603fn delete_row_owned_projection(
17604 tx: &Connection,
17605 projection: &RowOwnedProjection,
17606 write_cursor: i64,
17607) -> rusqlite::Result<usize> {
17608 if projection.table == DEFAULT_VECTOR_PARTITION {
17609 return delete_vector_partition_row(tx, write_cursor);
17610 }
17611 let sql = format!("DELETE FROM {} WHERE {} = ?1", projection.table, projection.cursor_column);
17612 tx.execute(&sql, [write_cursor])
17613}
17614
17615fn saturating_add_u64(acc: u64, n: usize) -> u64 {
17616 acc.saturating_add(n as u64)
17617}
17618
17619/// 0.8.20 Slice 15d fix-1 finding 2 [P2] — purge the row-owned projections in
17620/// `classes` for ONE canonical `write_cursor`. Same registry-driven mechanism as
17621/// [`erase_row_projections`] (iterate [`ROW_OWNED_PROJECTIONS`], delete by the
17622/// declared cursor column) but scoped to a class SUBSET, so the write path can
17623/// drop a SUPERSEDED node's `Attribute` + `PropertyFts` rows — making the at-rest
17624/// property projection active-only — WITHOUT touching the `NodeFts`/`Vector`
17625/// shadows, whose stale rows the node read path already excludes by joining
17626/// `canonical_nodes WHERE superseded_at IS NULL`. Consistent with the erasure
17627/// model: an unregistered table is unreachable here, exactly as with erasure.
17628fn purge_row_projections_for_cursor_in(
17629 tx: &Connection,
17630 write_cursor: i64,
17631 classes: &[ProjectionClass],
17632) -> rusqlite::Result<u64> {
17633 let mut deleted: u64 = 0;
17634 for projection in ROW_OWNED_PROJECTIONS.iter().filter(|p| classes.contains(&p.class)) {
17635 deleted =
17636 saturating_add_u64(deleted, delete_row_owned_projection(tx, projection, write_cursor)?);
17637 }
17638 Ok(deleted)
17639}
17640
17641/// 0.8.20 Slice 5a (R-20-E1) — truncate the row-owned projections in `classes`.
17642/// Returns the number of shadow rows deleted.
17643fn truncate_row_projections_in(
17644 tx: &Connection,
17645 classes: &[ProjectionClass],
17646) -> rusqlite::Result<u64> {
17647 let mut deleted: u64 = 0;
17648 for projection in ROW_OWNED_PROJECTIONS.iter().filter(|p| classes.contains(&p.class)) {
17649 let sql = format!("DELETE FROM {}", projection.table);
17650 deleted = deleted.saturating_add(tx.execute(&sql, [])? as u64);
17651 }
17652 Ok(deleted)
17653}
17654
17655/// 0.8.20 Slice 5a (R-20-E1) — truncate EVERY row-owned projection (the full
17656/// `rebuild_projections` invalidation). Kind-owned watermark state
17657/// (`_fathomdb_projection_state`) is deliberately untouched; the rebuild resets
17658/// readiness by rewinding the projection cursor instead.
17659#[cfg(feature = "operator")]
17660fn truncate_all_row_projections(tx: &Connection) -> rusqlite::Result<u64> {
17661 truncate_row_projections_in(
17662 tx,
17663 &[
17664 ProjectionClass::NodeFts,
17665 ProjectionClass::EdgeFts,
17666 ProjectionClass::Vector,
17667 ProjectionClass::Readiness,
17668 ProjectionClass::Attribute,
17669 ProjectionClass::PropertyFts,
17670 ],
17671 )
17672}
17673
17674/// 0.8.20 Slice 5a (R-20-E1) — which half of a projector's work a call site
17675/// wants. The projectors are TOTAL (they own every row-owned projection for a
17676/// canonical row); the pass selects the subset a replay site is rebuilding, so
17677/// no call site re-implements projection SQL inline.
17678#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17679enum ProjectionPass {
17680 /// The write path: same-txn FTS **and** async vector enqueue / readiness
17681 /// termination.
17682 Write,
17683 /// Lexical replay only (the open-path tokenizer reproject). Readiness and
17684 /// vector state are already correct and must not be perturbed.
17685 FtsOnly,
17686 /// Readiness + async-vector replay only (`rebuild_vec0`, i.e. a rebuild with
17687 /// `include_fts = false`): the FTS shadows are not being rebuilt in this
17688 /// pass, so they must not be written.
17689 ///
17690 /// Only the `operator` rebuild seam constructs this pass, so the DEFAULT
17691 /// (recovery-clean) build sees it as unconstructed — same gate rationale as
17692 /// the operator methods themselves (feature = gate, not delete).
17693 #[cfg_attr(not(feature = "operator"), allow(dead_code))]
17694 VectorOnly,
17695}
17696
17697impl ProjectionPass {
17698 fn writes_fts(self) -> bool {
17699 matches!(self, ProjectionPass::Write | ProjectionPass::FtsOnly)
17700 }
17701
17702 fn writes_vector_state(self) -> bool {
17703 matches!(self, ProjectionPass::Write | ProjectionPass::VectorOnly)
17704 }
17705
17706 /// 0.8.20 Slice 15d (R-20-EAV) — whether this pass (re)projects the declared
17707 /// attribute set into the EAV store + property-FTS. Only the full `Write`
17708 /// pass does: the `FtsOnly` tokenizer-upgrade reproject predates step 24 (no
17709 /// registry/attribute tables exist at that migration point, so it must not
17710 /// touch them), and `VectorOnly` rebuilds only the async vector shadows. The
17711 /// operator FTS rebuild uses `Write`, so a full `rebuild_projections`
17712 /// re-derives attributes cleanly after `truncate_all_row_projections` clears
17713 /// the two attribute classes.
17714 fn writes_attributes(self) -> bool {
17715 matches!(self, ProjectionPass::Write)
17716 }
17717}
17718
17719/// 0.8.20 Slice 15d (R-20-PR) — the on-disk registry row for one declared
17720/// projection, read back from `_fathomdb_projection_registry`.
17721///
17722/// **On-disk encoding of the optional sub-objects.** The `fts_tokenizer` column
17723/// is tri-valued: SQL `NULL` = no `fts` sub-object; empty string `""` = `fts`
17724/// present with the engine-default tokenizer; a non-empty string = `fts` with a
17725/// custom tokenizer. This is what lets `searchable→FTS with default tokenizer`
17726/// be distinguished durably from `searchable` with no FTS sub-target. `vector`
17727/// mirrors it with an explicit `vector_declared` bit plus a nullable
17728/// `vector_embedder`.
17729#[derive(Clone, Debug, Eq, PartialEq)]
17730struct StoredProjection {
17731 roles: BTreeSet<ProjectionRole>,
17732 fts_present: bool,
17733 /// `Some(custom)` custom tokenizer; `None` = engine default (only
17734 /// meaningful when `fts_present`).
17735 fts_tokenizer: Option<String>,
17736 vector_declared: bool,
17737 vector_embedder: Option<String>,
17738 source: Option<Vec<String>>,
17739}
17740
17741impl StoredProjection {
17742 /// True iff the declared roles want the attribute VALUE stored at rest in
17743 /// the EAV store: `filterable` (the value IS the filter target) or
17744 /// `searchable` (the value is the retrievable meaning, and Slice 20's vector
17745 /// embed will read it from here). `rankable`-only wants no value at rest.
17746 fn wants_eav(&self) -> bool {
17747 self.roles.contains(&ProjectionRole::Filterable)
17748 || self.roles.contains(&ProjectionRole::Searchable)
17749 }
17750
17751 /// True iff a `searchable→FTS` property-FTS row should be written: the
17752 /// `searchable` role AND an `fts` sub-object.
17753 fn wants_property_fts(&self) -> bool {
17754 self.roles.contains(&ProjectionRole::Searchable) && self.fts_present
17755 }
17756
17757 /// 0.8.20 Slice 21c (ledger `TC-71`) — **THE `searchable→vector` predicate.**
17758 /// True iff this declaration puts the attribute on the dense arm: the
17759 /// `searchable` role AND a `vector` sub-object. The exact analogue of
17760 /// [`StoredProjection::wants_property_fts`], for the same reason — the
17761 /// sub-object SELECTS a sub-target of `searchable`; it does not confer one.
17762 ///
17763 /// [`vector_projection_declared`] — the corpus-wide predicate gating all
17764 /// three enrolment paths (declare-time backfill, its drop inverse, and the
17765 /// write path's late enrolment) — routes through this so no call site can
17766 /// re-derive the rule and drift. Before it existed, that predicate keyed off
17767 /// `vector_declared` ALONE, so `{roles: [filterable], vector: {}}` — the
17768 /// combination Slice 15d documented as inert-but-round-trippable — enrolled
17769 /// node kinds, backfilled the corpus and made every later write enqueue an
17770 /// embedding in any session with a live embedder.
17771 ///
17772 /// **0.8.20 Slice 23 (`R-20-SV`):** that combination is no longer DECLARABLE
17773 /// — [`apply_projection_config`] rejects it as an invalid spec. This
17774 /// predicate still governs, because the shape survives at rest in every
17775 /// database that declared it while the engine accepted it, and it is read
17776 /// from the registry, not from a caller's spec.
17777 ///
17778 /// **Deliberately NOT the same as [`StoredProjection::has_deferred`]**, which
17779 /// keys off `vector_declared` alone and must keep doing so: that one feeds
17780 /// `ProjectionDelta.deferred`, a REPORTING field, and the round-trip contract
17781 /// wants a stored-but-unbuilt `vector` sub-object reported however it was
17782 /// declared. TC-71 changes what the engine DOES, not what it says.
17783 fn wants_vector(&self) -> bool {
17784 self.roles.contains(&ProjectionRole::Searchable) && self.vector_declared
17785 }
17786
17787 /// The `fts_tokenizer` column value: `None` (SQL NULL) when no `fts`
17788 /// sub-object, else the custom tokenizer or `""` for engine-default.
17789 fn fts_column(&self) -> Option<String> {
17790 if self.fts_present {
17791 Some(self.fts_tokenizer.clone().unwrap_or_default())
17792 } else {
17793 None
17794 }
17795 }
17796
17797 /// Build from the public [`ProjectionSpec`].
17798 ///
17799 /// 0.8.20 Slice 20 (R-20-DR) — note what is DELIBERATELY not read here:
17800 /// `spec.vector.dense_readiness`. Readiness is engine-set READ METADATA, not
17801 /// part of the declaration, so it never reaches the durable registry. That
17802 /// is what makes a caller-supplied value INERT (the engine always reports the
17803 /// derived truth) and what keeps it out of the destructive-change diff — a
17804 /// readiness difference can never look like a projection change.
17805 fn from_spec(spec: &ProjectionSpec) -> Self {
17806 StoredProjection {
17807 roles: spec.roles.clone(),
17808 fts_present: spec.fts.is_some(),
17809 fts_tokenizer: spec
17810 .fts
17811 .as_ref()
17812 .and_then(|f| f.tokenizer.clone())
17813 .filter(|t| !t.is_empty()),
17814 vector_declared: spec.vector.is_some(),
17815 vector_embedder: spec
17816 .vector
17817 .as_ref()
17818 .and_then(|v| v.embedder.clone())
17819 .filter(|e| !e.is_empty()),
17820 source: spec.source.clone(),
17821 }
17822 }
17823
17824 /// Reconstruct the public [`ProjectionSpec`] for `read_projections`.
17825 fn to_spec(&self, name: &str) -> ProjectionSpec {
17826 ProjectionSpec {
17827 name: name.to_string(),
17828 roles: self.roles.clone(),
17829 fts: if self.fts_present {
17830 Some(ProjectionFts { tokenizer: self.fts_tokenizer.clone() })
17831 } else {
17832 None
17833 },
17834 vector: if self.vector_declared {
17835 // 0.8.20 Slice 20 (R-20-DR) — the registry knows nothing about
17836 // readiness (it is DERIVED, never stored), so the durable shape
17837 // reconstructs with `dense_readiness: None`.
17838 // [`Engine::read_projections`] fills it from
17839 // [`derive_dense_readiness`] on the way out.
17840 Some(ProjectionVector {
17841 embedder: self.vector_embedder.clone(),
17842 dense_readiness: None,
17843 })
17844 } else {
17845 None
17846 },
17847 source: self.source.clone(),
17848 }
17849 }
17850
17851 /// The set of ROLE spellings this declaration DEFERS rather than builds:
17852 /// `rankable` (F9 not live) and, since 15d builds no embedding, the
17853 /// `searchable→vector` sub-target. Used to populate `ProjectionDelta.deferred`.
17854 fn has_deferred(&self) -> bool {
17855 self.roles.contains(&ProjectionRole::Rankable) || self.vector_declared
17856 }
17857}
17858
17859/// 0.8.20 Slice 15d (R-20-PR) — is `name` a well-formed attribute name?
17860///
17861/// Establishes the invariant "a name that `configure_projections` ACCEPTS must be
17862/// POPULATABLE": the write-path extraction compiles the SQLite JSON path
17863/// `$."<name>"` (double-quoted key). A name must therefore round-trip through
17864/// that quoted-key form unchanged. Rejects:
17865/// - empty;
17866/// - a double-quote `"` (would terminate the quoted key early → malformed path,
17867/// ERRORing inside the write transaction);
17868/// - a BACKSLASH `\` (fix-4 finding 1 [P2]): SQLite treats `\` as an escape
17869/// introducer inside the double-quoted JSON-path key, so a body key literally
17870/// containing `\` (e.g. `a\b`) is NOT matched by `$."a\b"`. Pre-fix the name
17871/// was accepted yet the attribute silently NEVER populated
17872/// `canonical_attributes` — an accept-then-never-populate footgun. Rejecting
17873/// it keeps the accept ⟹ works contract (mirrors the TC-33 hard-reject
17874/// philosophy);
17875/// - any ASCII control char (incl. NUL): not a safe/legible key spelling and
17876/// not reliably matchable through the quoted-key form.
17877///
17878/// Projection names are app-declared identifiers, so this charset restriction is
17879/// a legitimate contract. Caller-supplied, so it is validated at
17880/// `configure_projections` time (spec names AND the `drop` list).
17881fn is_valid_attribute_name(name: &str) -> bool {
17882 !name.is_empty()
17883 && !name.contains('"')
17884 && !name.contains('\\')
17885 && !name.chars().any(|c| c.is_control())
17886}
17887
17888/// 0.8.20 Slice 15d — the SQLite JSON path that extracts attribute `name` from a
17889/// node body. `name` is pre-validated by [`is_valid_attribute_name`]; the path
17890/// is bound as a PARAMETER (never interpolated into SQL), so this is not an
17891/// injection surface even before that validation.
17892fn attribute_json_path(name: &str) -> String {
17893 format!("$.\"{name}\"")
17894}
17895
17896/// SQLite JSON path for one declared projection. A declared source is an ordered
17897/// list of literal object-member names; it is never a caller-provided JSONPath.
17898/// Every segment is validated before persistence, and the resulting path is
17899/// always bound as a parameter rather than interpolated into SQL.
17900fn projection_json_path(name: &str, stored: &StoredProjection) -> String {
17901 match &stored.source {
17902 None => attribute_json_path(name),
17903 Some(segments) => {
17904 let mut path = String::from("$");
17905 for segment in segments {
17906 path.push_str(".\"");
17907 path.push_str(segment);
17908 path.push('"');
17909 }
17910 path
17911 }
17912 }
17913}
17914
17915/// Refuse a source path that cannot be safely represented as SQLite quoted
17916/// member selectors. Empty paths would select the whole body rather than one
17917/// member and therefore do not meet the scalar-projection contract.
17918fn is_valid_projection_source(source: &[String]) -> bool {
17919 !source.is_empty() && source.iter().all(|segment| is_valid_attribute_name(segment))
17920}
17921
17922/// Reject only nested-source object/array terminals. Legacy top-level
17923/// projections retain their shipped skip-composite behaviour for compatibility.
17924fn nested_projection_terminal_is_composite(
17925 conn: &Connection,
17926 body: &str,
17927 name: &str,
17928 stored: &StoredProjection,
17929) -> rusqlite::Result<bool> {
17930 if stored.source.is_none() {
17931 return Ok(false);
17932 }
17933 let path = projection_json_path(name, stored);
17934 let terminal: Option<String> = conn.query_row(
17935 "SELECT CASE WHEN json_valid(?1) THEN json_type(?1, ?2) END",
17936 params![body, path],
17937 |row| row.get(0),
17938 )?;
17939 Ok(matches!(terminal.as_deref(), Some("object") | Some("array")))
17940}
17941
17942/// Validate nested source terminals across the active rows a declaration would
17943/// backfill. The caller owns the transaction, so `WriteValidation` aborts it
17944/// rather than leaving a partly reconfigured registry.
17945fn validate_projection_source_backfill(
17946 conn: &Connection,
17947 name: &str,
17948 stored: &StoredProjection,
17949) -> Result<(), EngineError> {
17950 if stored.source.is_none() {
17951 return Ok(());
17952 }
17953 let mut stmt = conn
17954 .prepare(
17955 "SELECT body FROM canonical_nodes
17956 WHERE superseded_at IS NULL AND state = 'active'",
17957 )
17958 .map_err(|_| EngineError::Storage)?;
17959 let bodies =
17960 stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|_| EngineError::Storage)?;
17961 for body in bodies {
17962 let body = body.map_err(|_| EngineError::Storage)?;
17963 if nested_projection_terminal_is_composite(conn, &body, name, stored)
17964 .map_err(|_| EngineError::Storage)?
17965 {
17966 return Err(EngineError::WriteValidation);
17967 }
17968 }
17969 Ok(())
17970}
17971
17972/// Validate every nested source present in a normal node write before the write
17973/// transaction starts. This keeps an object/array terminal in the existing
17974/// `WriteValidation` family and guarantees the whole batch is rejected.
17975fn validate_nested_projection_sources_for_write(
17976 conn: &Connection,
17977 batch: &[PreparedWrite],
17978) -> Result<(), EngineError> {
17979 let registry = load_projection_registry(conn).map_err(|_| EngineError::Storage)?;
17980 if registry.values().all(|stored| stored.source.is_none()) {
17981 return Ok(());
17982 }
17983 for write in batch {
17984 let PreparedWrite::Node { body, .. } = write else { continue };
17985 validate_nested_projection_sources_for_body_in_registry(conn, body, ®istry)?;
17986 }
17987 Ok(())
17988}
17989
17990fn validate_nested_projection_sources_for_body(
17991 conn: &Connection,
17992 body: &str,
17993) -> Result<(), EngineError> {
17994 let registry = load_projection_registry(conn).map_err(|_| EngineError::Storage)?;
17995 validate_nested_projection_sources_for_body_in_registry(conn, body, ®istry)
17996}
17997
17998/// Validate one body against a registry snapshot owned by the caller. Batch
17999/// writes load this snapshot once, keeping validation proportional to bodies
18000/// plus declarations rather than repeating registry I/O for every row.
18001fn validate_nested_projection_sources_for_body_in_registry(
18002 conn: &Connection,
18003 body: &str,
18004 registry: &BTreeMap<String, StoredProjection>,
18005) -> Result<(), EngineError> {
18006 for (name, stored) in registry {
18007 if nested_projection_terminal_is_composite(conn, body, name, stored)
18008 .map_err(|_| EngineError::Storage)?
18009 {
18010 return Err(EngineError::WriteValidation);
18011 }
18012 }
18013 Ok(())
18014}
18015
18016/// 0.8.20 Slice 15d (R-20-PR) — load the durable projection registry
18017/// (`_fathomdb_projection_registry`) into a name→[`StoredProjection`] map. This
18018/// is the derived-cache source (Q5) that boot re-derive and every
18019/// `configure_projections` diff read.
18020fn load_projection_registry(
18021 conn: &Connection,
18022) -> rusqlite::Result<BTreeMap<String, StoredProjection>> {
18023 let mut out = BTreeMap::new();
18024 // The registry table is created by schema step 24; a DB migrated to a
18025 // pre-24 head (e.g. a compatibility/partial-migration test open) does not
18026 // have it. Absent ⇒ no projections declared ⇒ empty registry, not an error.
18027 // This keeps boot re-derive and the write-path attribute projector safe on
18028 // every pre-24 schema.
18029 let table_exists: bool = conn
18030 .query_row(
18031 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_fathomdb_projection_registry'",
18032 [],
18033 |_| Ok(true),
18034 )
18035 .optional()?
18036 .unwrap_or(false);
18037 if !table_exists {
18038 return Ok(out);
18039 }
18040 let mut stmt = conn.prepare(
18041 "SELECT name, roles, fts_tokenizer, vector_embedder, vector_declared, source
18042 FROM _fathomdb_projection_registry",
18043 )?;
18044 let rows = stmt.query_map([], |row| {
18045 let name: String = row.get(0)?;
18046 let roles_json: String = row.get(1)?;
18047 let fts_tokenizer: Option<String> = row.get(2)?;
18048 let vector_embedder: Option<String> = row.get(3)?;
18049 let vector_declared: i64 = row.get(4)?;
18050 let source_json: Option<String> = row.get(5)?;
18051 Ok((name, roles_json, fts_tokenizer, vector_embedder, vector_declared, source_json))
18052 })?;
18053 for row in rows {
18054 let (name, roles_json, fts_col, vector_embedder, vector_declared, source_json) = row?;
18055 let roles: BTreeSet<ProjectionRole> = parse_roles_json(&roles_json);
18056 let fts_present = fts_col.is_some();
18057 let fts_tokenizer = fts_col.filter(|t| !t.is_empty());
18058 let source = source_json
18059 .map(|encoded| serde_json::from_str::<Vec<String>>(&encoded))
18060 .transpose()
18061 .map_err(|err| {
18062 rusqlite::Error::FromSqlConversionFailure(
18063 5,
18064 rusqlite::types::Type::Text,
18065 Box::new(err),
18066 )
18067 })?;
18068 out.insert(
18069 name,
18070 StoredProjection {
18071 roles,
18072 fts_present,
18073 fts_tokenizer,
18074 vector_declared: vector_declared != 0,
18075 vector_embedder,
18076 source,
18077 },
18078 );
18079 }
18080 Ok(out)
18081}
18082
18083/// Roles are persisted as a compact, sorted, comma-separated list (set
18084/// semantics; order-independent). Unknown tokens are ignored (forward-compat).
18085fn parse_roles_json(s: &str) -> BTreeSet<ProjectionRole> {
18086 s.split(',').filter_map(|t| ProjectionRole::from_str_opt(t.trim())).collect()
18087}
18088
18089fn roles_to_storage(roles: &BTreeSet<ProjectionRole>) -> String {
18090 roles.iter().map(|r| r.as_str()).collect::<Vec<_>>().join(",")
18091}
18092
18093/// 0.8.20 Slice 15d (R-20-PR) — write/overwrite one registry row.
18094fn persist_projection_row(
18095 tx: &Connection,
18096 name: &str,
18097 stored: &StoredProjection,
18098) -> rusqlite::Result<()> {
18099 let source = stored
18100 .source
18101 .as_ref()
18102 .map(serde_json::to_string)
18103 .transpose()
18104 .map_err(|err| rusqlite::Error::ToSqlConversionFailure(Box::new(err)))?;
18105 tx.execute(
18106 "INSERT INTO _fathomdb_projection_registry
18107 (name, roles, fts_tokenizer, vector_embedder, vector_declared, source)
18108 VALUES(?1, ?2, ?3, ?4, ?5, ?6)
18109 ON CONFLICT(name) DO UPDATE SET
18110 roles = excluded.roles,
18111 fts_tokenizer = excluded.fts_tokenizer,
18112 vector_embedder = excluded.vector_embedder,
18113 vector_declared = excluded.vector_declared,
18114 source = excluded.source",
18115 params![
18116 name,
18117 roles_to_storage(&stored.roles),
18118 stored.fts_column(),
18119 stored.vector_embedder,
18120 i64::from(stored.vector_declared),
18121 source,
18122 ],
18123 )?;
18124 Ok(())
18125}
18126
18127/// 0.8.20 Slice 15d (R-20-PR) — delete one registry row.
18128fn remove_projection_row(tx: &Connection, name: &str) -> rusqlite::Result<()> {
18129 tx.execute("DELETE FROM _fathomdb_projection_registry WHERE name = ?1", params![name])?;
18130 Ok(())
18131}
18132
18133/// 0.8.20 Slice 15d (R-20-EAV) — delete every EAV + property-FTS row for one
18134/// attribute `name` (all owning nodes). The idempotent-rebuild primitive: a
18135/// changed or dropped projection clears its rows before (re)backfill.
18136fn clear_attribute_projection(tx: &Connection, name: &str) -> rusqlite::Result<()> {
18137 tx.execute("DELETE FROM property_search_index WHERE attr_name = ?1", params![name])?;
18138 tx.execute("DELETE FROM canonical_attributes WHERE attr_name = ?1", params![name])?;
18139 Ok(())
18140}
18141
18142/// 0.8.20 Slice 15d (R-20-EAV) — project ONE attribute value for ONE node row
18143/// into the EAV store and (if `searchable→FTS`) the property-FTS shadow. Skips a
18144/// NULL/absent extraction (an absent attribute means no row, so a `filterable`
18145/// equality simply never matches it — correct). Shared by the write path and
18146/// the backfill so they cannot drift.
18147fn project_one_attribute(
18148 tx: &Connection,
18149 cursor: i64,
18150 body: &str,
18151 name: &str,
18152 stored: &StoredProjection,
18153) -> rusqlite::Result<()> {
18154 if !stored.wants_eav() {
18155 return Ok(());
18156 }
18157 // json_extract over a non-JSON body would error; guard with json_valid so a
18158 // plain-text body simply yields no attribute rows. The canonical scalar
18159 // extraction is shared with the Slice-15e vec0 pre-KNN column via
18160 // [`extract_scalar_attribute`], so the EAV value and the `attr_<hex>` value
18161 // are IDENTICAL by construction.
18162 //
18163 // fix-1 finding 1 [P2] — project EVERY JSON scalar type, not just strings.
18164 // The prior form read the extraction as `Option<String>`; for a JSON number
18165 // or bool, `json_extract` returns an INTEGER/REAL, the `get::<Option<String>>`
18166 // conversion FAILED, and `.unwrap_or(None)` silently treated the attribute as
18167 // absent — so a numeric/boolean filterable value never projected. We now
18168 // render a single canonical TEXT form per JSON type, keyed on `json_type` so
18169 // the stored value is deterministic and the SAME value flows to BOTH
18170 // `canonical_attributes` and `property_search_index` (consistency by
18171 // construction — one `value` binding below):
18172 // - string -> the text verbatim
18173 // - integer -> decimal text (CAST AS TEXT); e.g. 3 -> "3"
18174 // - real -> decimal text (CAST AS TEXT); e.g. 3.5 -> "3.5"
18175 // - true -> "true", false -> "false" (preserve the JSON literal, NOT the
18176 // SQLite `1`/`0` that a bare `CAST(json_extract(...) AS TEXT)`
18177 // would yield — so a bool filter matches the value the caller
18178 // wrote, and "true" never collides with the number 1).
18179 // - null / absent path -> SQL NULL -> no row (an absent attribute correctly
18180 // never matches a `filterable` equality).
18181 // - object / array -> DELIBERATELY SKIPPED (SQL NULL -> no row): a composite
18182 // value is not a scalar filter/FTS target in 15d; projecting its
18183 // raw JSON text would be a footgun (nested-field filtering is the
18184 // >=0.9.x multi-field work). Skipping is deliberate, not an
18185 // accidental type-conversion drop — no scalar type is dropped.
18186 let Some(value) = extract_scalar_attribute(tx, body, name, stored)? else {
18187 return Ok(());
18188 };
18189 tx.execute(
18190 "INSERT INTO canonical_attributes(write_cursor, attr_name, attr_value)
18191 VALUES(?1, ?2, ?3)",
18192 params![cursor, name, value],
18193 )?;
18194 if stored.wants_property_fts() {
18195 tx.execute(
18196 "INSERT INTO property_search_index(attr_value, attr_name, write_cursor)
18197 VALUES(?1, ?2, ?3)",
18198 params![value, name, cursor],
18199 )?;
18200 }
18201 Ok(())
18202}
18203
18204/// 0.8.20 Slice 15e fix-3 [P2] — the leading marker byte that the vec0
18205/// `attr_<hex>` FILTER column prepends to every PRESENT scalar value, so that
18206/// PRESENT and ABSENT are DISJOINT in a NOT-NULL TEXT column.
18207///
18208/// The `''` empty-string sentinel used to mean BOTH "attribute absent" AND
18209/// "attribute present with value `''`", so a `status == ""` equality filter
18210/// false-matched every absent row. vec0 TEXT metadata is NOT-NULL-able (TC-46
18211/// condition #3), so absent cannot be `NULL`; instead absent stays `''` and every
18212/// PRESENT value `V` is encoded `enc(V) = "\x01" || V`. This is injective and
18213/// non-empty for ALL `V` (including `V=""`, whose encoding is the bare marker),
18214/// so `attr_<hex> = enc("")` matches present-empty but NEVER the `''`-absent rows.
18215///
18216/// This encoding is CONFINED to the vec0 filter column and the filter-value
18217/// lowering ([`vector_filter_values`]). `property_search_index` (the searchable→FTS
18218/// projection) and `canonical_attributes.attr_value` keep the RAW value — the FTS
18219/// arm distinguishes absent from present-empty by canonical_attributes row
18220/// EXISTENCE instead (see [`hit_attributes_pass_filter`]).
18221const ATTR_VEC0_PRESENT_MARKER: char = '\u{1}';
18222
18223/// 0.8.20 Slice 15e fix-3 — encode a PRESENT scalar value for the vec0 filter
18224/// column / filter-value lowering (see [`ATTR_VEC0_PRESENT_MARKER`]). ABSENT is
18225/// NOT encoded (it stays the bare `''` sentinel), so this is only ever called on a
18226/// value known to be present.
18227fn encode_attr_vec0_present(value: &str) -> String {
18228 let mut s = String::with_capacity(value.len() + 1);
18229 s.push(ATTR_VEC0_PRESENT_MARKER);
18230 s.push_str(value);
18231 s
18232}
18233
18234/// 0.8.20 Slice 15e — extract the canonical TEXT form of attribute `name` from a
18235/// node `body`, using the SAME `json_type` CASE as [`project_one_attribute`] so
18236/// the vec0 pre-KNN `attr_<hex>` column value equals the EAV
18237/// `canonical_attributes` value (consistency by construction — a `filterable`
18238/// filter routed pre-KNN sees exactly what the EAV path stored). Returns `None`
18239/// for an absent / null / object / array / non-JSON extraction (⇒ the `''`
18240/// sentinel at the vec0 column, ⇒ fail-to-match).
18241fn extract_scalar_attribute(
18242 conn: &Connection,
18243 body: &str,
18244 name: &str,
18245 stored: &StoredProjection,
18246) -> rusqlite::Result<Option<String>> {
18247 let path = projection_json_path(name, stored);
18248 let value: Option<String> = conn
18249 .query_row(
18250 "SELECT CASE WHEN json_valid(?1) THEN
18251 CASE json_type(?1, ?2)
18252 WHEN 'true' THEN 'true'
18253 WHEN 'false' THEN 'false'
18254 WHEN 'null' THEN NULL
18255 WHEN 'object' THEN NULL
18256 WHEN 'array' THEN NULL
18257 ELSE CAST(json_extract(?1, ?2) AS TEXT)
18258 END
18259 END",
18260 params![body, path],
18261 |row| row.get::<_, Option<String>>(0),
18262 )
18263 .unwrap_or(None);
18264 Ok(value)
18265}
18266
18267/// 0.8.20 Slice 15e — for a node `body`, build the `, attr_<hex>` column suffix,
18268/// the `, ?N` placeholder suffix (numbered from `start_idx`), and the bound TEXT
18269/// values for EVERY attribute column CURRENTLY on the live `vector_default` (read
18270/// from the table's own SQL, so the INSERT always matches the table shape exactly —
18271/// vec0 rejects a partial-column INSERT). Each value is the body's canonical
18272/// scalar extraction, or the `''` sentinel when absent. Returns empty fragments
18273/// (and no values) when the table has no attribute columns, so the INSERT stays
18274/// byte-identical to the shipped statement.
18275fn vector_attr_insert_fragments(
18276 conn: &Connection,
18277 body: &str,
18278 start_idx: usize,
18279) -> rusqlite::Result<(String, String, Vec<rusqlite::types::Value>)> {
18280 let cols = actual_vector_attr_columns(conn)?;
18281 let registry = load_projection_registry(conn)?;
18282 let mut col_sql = String::new();
18283 let mut ph_sql = String::new();
18284 let mut values: Vec<rusqlite::types::Value> = Vec::new();
18285 for (i, col) in cols.iter().enumerate() {
18286 let name = decode_attr_vec0_column(col).unwrap_or_default();
18287 // fix-3 [P2] — a PRESENT scalar value is encoded `\x01 || V` so it is
18288 // DISJOINT from the `''`-absent sentinel (present-empty ⇒ the bare marker,
18289 // never `''`). Absent stays the bare `''` sentinel.
18290 let scalar = match registry.get(&name) {
18291 Some(stored) => extract_scalar_attribute(conn, body, &name, stored)?,
18292 None => None,
18293 };
18294 let value = match scalar {
18295 Some(v) => encode_attr_vec0_present(&v),
18296 None => String::new(),
18297 };
18298 col_sql.push_str(&format!(", {col}"));
18299 ph_sql.push_str(&format!(", ?{}", start_idx + i));
18300 values.push(rusqlite::types::Value::Text(value));
18301 }
18302 Ok((col_sql, ph_sql, values))
18303}
18304
18305/// 0.8.20 Slice 15d (R-20-EAV) — the write-path attribute projector: for a
18306/// just-inserted node, project EVERY declared attribute (reading the live
18307/// registry from `tx`). Same-transaction, so the node is filter/FTS-retrievable
18308/// on commit. A no-op when the registry is empty (the pre-`configure_projections`
18309/// default), so it costs one empty-table scan per node and is behaviour-neutral
18310/// until a projection is declared.
18311fn project_node_attributes(tx: &Connection, cursor: i64, body: &str) -> rusqlite::Result<()> {
18312 let registry = load_projection_registry(tx)?;
18313 for (name, stored) in ®istry {
18314 project_one_attribute(tx, cursor, body, name, stored)?;
18315 }
18316 Ok(())
18317}
18318
18319/// 0.8.20 Slice 15d (R-20-PR) — backfill ONE attribute across every ACTIVE,
18320/// non-superseded canonical node. Called by `configure_projections` when a
18321/// projection is added/changed (after `clear_attribute_projection`), and by boot
18322/// re-derive. Idempotent when paired with the clear.
18323fn backfill_attribute(
18324 tx: &Connection,
18325 name: &str,
18326 stored: &StoredProjection,
18327) -> rusqlite::Result<()> {
18328 if !stored.wants_eav() {
18329 return Ok(());
18330 }
18331 let rows: Vec<(i64, String)> = {
18332 let mut stmt = tx.prepare(
18333 "SELECT write_cursor, body FROM canonical_nodes
18334 WHERE superseded_at IS NULL AND state = 'active'",
18335 )?;
18336 let collected = stmt
18337 .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)))?
18338 .collect::<rusqlite::Result<Vec<_>>>()?;
18339 collected
18340 };
18341 for (cursor, body) in rows {
18342 project_one_attribute(tx, cursor, &body, name, stored)?;
18343 }
18344 Ok(())
18345}
18346
18347/// 0.8.20 Slice 15d (R-20-PR) — is `desired` an INCOMPATIBLE/DESTRUCTIVE change
18348/// to a live `existing` projection? A destructive change discards an
18349/// expensive-to-rebuild resource and so REQUIRES an explicit `drop` (C3): a role
18350/// REMOVAL, dropping the `fts`/`vector` sub-target, or changing the tokenizer /
18351/// embedder. Purely ADDITIVE changes (adding a role, adding an `fts`/`vector`
18352/// sub-object) are non-destructive and applied in place.
18353fn is_destructive_projection_change(
18354 existing: &StoredProjection,
18355 desired: &StoredProjection,
18356) -> bool {
18357 if existing.roles.iter().any(|r| !desired.roles.contains(r)) {
18358 return true;
18359 }
18360 if existing.fts_present
18361 && (!desired.fts_present || existing.fts_tokenizer != desired.fts_tokenizer)
18362 {
18363 return true;
18364 }
18365 if existing.vector_declared
18366 && (!desired.vector_declared || existing.vector_embedder != desired.vector_embedder)
18367 {
18368 return true;
18369 }
18370 if existing.source != desired.source {
18371 return true;
18372 }
18373 false
18374}
18375
18376/// Human-readable summary of the destructive delta, surfaced in
18377/// [`EngineError::ProjectionDestructive`] so the caller sees WHAT it must drop.
18378fn describe_projection_delta(existing: &StoredProjection, desired: &StoredProjection) -> String {
18379 let mut parts: Vec<String> = Vec::new();
18380 for r in &existing.roles {
18381 if !desired.roles.contains(r) {
18382 parts.push(format!("role '{}' removed", r.as_str()));
18383 }
18384 }
18385 if existing.fts_present && !desired.fts_present {
18386 parts.push("fts sub-target removed".to_string());
18387 } else if existing.fts_present && existing.fts_tokenizer != desired.fts_tokenizer {
18388 parts.push("fts tokenizer changed".to_string());
18389 }
18390 if existing.vector_declared && !desired.vector_declared {
18391 parts.push("vector sub-target removed".to_string());
18392 } else if existing.vector_declared && existing.vector_embedder != desired.vector_embedder {
18393 parts.push("vector embedder changed".to_string());
18394 }
18395 if existing.source != desired.source {
18396 parts.push("source path changed".to_string());
18397 }
18398 if parts.is_empty() {
18399 "incompatible change".to_string()
18400 } else {
18401 parts.join("; ")
18402 }
18403}
18404
18405/// 0.8.20 Slice 15d (R-20-PR) — the declarative, idempotent diff+backfill apply
18406/// that backs [`Engine::configure_projections`]. Runs inside the caller's write
18407/// transaction `tx`. Order: apply `drop`s first (so a drop+re-declare in one
18408/// call rebuilds fresh), then diff each spec. Idempotent re-registration diffs to
18409/// an empty delta (`unchanged`). A destructive change without an explicit drop is
18410/// refused with [`EngineError::ProjectionDestructive`].
18411///
18412/// 0.8.20 Slice 20c (R-20-DR remainder) — returns `(delta, enqueued_backfill)`.
18413/// The second member is `true` iff [`enqueue_declared_vector_backfill`] put
18414/// deferred embed work on the queue, in which case the CALLER must
18415/// `notify_new_work()` after committing (the flag cannot ride on
18416/// [`ProjectionDelta`]: that is the caller-facing diff, and this is a runtime
18417/// signal, not part of the declaration's result).
18418///
18419/// 0.8.20 Slice 20c fix-1 (codex §9 [P2]) — and the symmetric inverse: a call
18420/// that removes the LAST `searchable→vector` declaration un-enrols the node kinds
18421/// the forward path enrolled ([`unenrol_registry_vector_node_kinds`]), on this
18422/// same transaction. It deletes no embedding.
18423///
18424/// 0.8.20 Slice 21 fix-1 (codex §9 round 1 [P2]) — and, beside that transition, a
18425/// state-keyed RECONCILIATION ([`registry_governs_an_inert_dense_arm`]) so that a
18426/// database already carrying an inert enrolment from before the Slice-21c role
18427/// gate is healed by any `configure_projections` call, not only by a
18428/// searchable-vector-to-none transition it may never perform. The boot arm is
18429/// [`reconcile_inert_vector_enrolments_on_boot`].
18430fn apply_projection_config(
18431 tx: &Connection,
18432 specs: &[ProjectionSpec],
18433 drop: &[String],
18434 dense_arm_live: bool,
18435) -> Result<(ProjectionDelta, bool), EngineError> {
18436 // Validate up-front so a bad name aborts before any write.
18437 //
18438 // fix-6 finding [P2] — REJECT a duplicate projection `name` within `specs`
18439 // (and a duplicate entry within `drop`) up front. The diff loop below diffs
18440 // every spec against the ONE pre-loop registry snapshot, so a name repeated
18441 // in `specs` diffed the SECOND spec against state that never saw the first
18442 // spec's just-persisted row: on a fresh DB `[status(searchable+fts),
18443 // status(rankable-only)]` reported `built=[status]` in the delta while the
18444 // registry ended rankable-only (which builds nothing) — the returned delta
18445 // DIVERGED from the persisted registry, breaking the fix-4 "accept ⟹ correct"
18446 // contract. A duplicate `drop` entry likewise reported the drop twice though
18447 // the row was removed once. A single request naming the same projection twice
18448 // is ambiguous/malformed, so we refuse it (rejection, not last-wins coalesce)
18449 // — a rejected request is a total no-op, keeping the registry and delta
18450 // consistent with the accepted input. A name that appears in BOTH `specs` and
18451 // `drop` is NOT a duplicate: that is the documented drop-then-rebuild-fresh
18452 // pattern (drops apply first, then the fresh spec builds), so it is allowed.
18453 let mut seen_spec_names: BTreeSet<&str> = BTreeSet::new();
18454 for spec in specs {
18455 if !is_valid_attribute_name(&spec.name) {
18456 return Err(EngineError::InvalidArgument {
18457 msg: format!("invalid projection attribute name: {:?}", spec.name),
18458 });
18459 }
18460 if spec.roles.is_empty() {
18461 return Err(EngineError::InvalidArgument {
18462 msg: format!("projection '{}' declares no roles", spec.name),
18463 });
18464 }
18465 if let Some(source) = &spec.source {
18466 if !is_valid_projection_source(source) {
18467 return Err(EngineError::InvalidArgument {
18468 msg: format!("invalid projection source path for {:?}", spec.name),
18469 });
18470 }
18471 }
18472 if !seen_spec_names.insert(spec.name.as_str()) {
18473 return Err(EngineError::InvalidArgument {
18474 msg: format!("duplicate projection name in one request: '{}'", spec.name),
18475 });
18476 }
18477 }
18478 let mut seen_drop_names: BTreeSet<&str> = BTreeSet::new();
18479 for name in drop {
18480 if !is_valid_attribute_name(name) {
18481 return Err(EngineError::InvalidArgument {
18482 msg: format!("invalid projection drop name: {name:?}"),
18483 });
18484 }
18485 if !seen_drop_names.insert(name.as_str()) {
18486 return Err(EngineError::InvalidArgument {
18487 msg: format!("duplicate projection drop in one request: '{name}'"),
18488 });
18489 }
18490 }
18491
18492 // 0.8.20 Slice 23 (`R-20-SV`) — REJECT an `fts` or `vector` sub-object
18493 // declared WITHOUT the `searchable` role.
18494 //
18495 // HITL ruling 2026-07-24 (`dev/plans/plan-0.8.20.md` §11 item 4, option (b)):
18496 // *"it is a meaningless config; fail-fast matches the hard-reject philosophy,
18497 // and additive strictness is safe pre-1.0"*, to be implemented "at the next
18498 // `configure_projections` slice". This OVERTURNS the shipped 15d fix-4
18499 // position, which accepted the shape because it round-tripped faithfully.
18500 //
18501 // WHY it is meaningless: `searchable→FTS` and `searchable→vector` are TIER
18502 // LABELS, not roles ([`ProjectionRole`] has exactly three members). The
18503 // sub-objects SELECT a sub-target of `searchable`; they do not CONFER one —
18504 // both build predicates ([`StoredProjection::wants_property_fts`] and
18505 // [`StoredProjection::wants_vector`]) are conjunctions with
18506 // `roles.contains(Searchable)`. So without the role the declaration builds no
18507 // property-FTS, enrols no kind and embeds nothing: it names a sub-target of a
18508 // projection that does not exist. The reject is therefore keyed on the
18509 // ABSENCE of `searchable` and on nothing else — `filterable` / `rankable` are
18510 // orthogonal axes that neither supply nor substitute for it.
18511 //
18512 // FAMILY: [`EngineError::WriteValidation`], per decision #18 (0.8.20 Slice 22)
18513 // — the write-SHAPE boundary is ONE family, and this is a shape rejection.
18514 // Deliberately a SEPARATE loop from the name checks above: those are NAME
18515 // rejections that keep `InvalidArgument { msg }` because the message naming
18516 // the offending value is the caller's only handle on it. `dev/design/errors.md`
18517 // ("Validation boundary") states that split; keeping the two loops apart keeps
18518 // the split visible in the code and this change one-line-reversible.
18519 //
18520 // KNOWN COST (TC-95/TC-98, HITL-deferred): `WriteValidation` is a UNIT
18521 // variant, so this refusal cannot name WHICH spec in `specs` was invalid —
18522 // strictly worse than the name rejections above. Recorded, not worked around.
18523 for spec in specs {
18524 if spec.roles.contains(&ProjectionRole::Searchable) {
18525 continue;
18526 }
18527 if spec.fts.is_some() || spec.vector.is_some() {
18528 return Err(EngineError::WriteValidation);
18529 }
18530 }
18531
18532 // A destructive source change retains the normal drop-first error precedence.
18533 // Check the pre-drop registry before inspecting a proposed source's backfill
18534 // rows; otherwise a composite at that source could mask ProjectionDestructive.
18535 let pre_drop = load_projection_registry(tx).map_err(|_| EngineError::Storage)?;
18536 let mut refresh_vector_attributes = false;
18537 for spec in specs {
18538 let desired = StoredProjection::from_spec(spec);
18539 if let Some(existing) = pre_drop.get(&spec.name) {
18540 let replacing = drop.iter().any(|name| name == &spec.name);
18541 if !replacing && is_destructive_projection_change(existing, &desired) {
18542 return Err(EngineError::ProjectionDestructive {
18543 name: spec.name.clone(),
18544 delta: describe_projection_delta(existing, &desired),
18545 });
18546 }
18547 if replacing
18548 && existing.source != desired.source
18549 && desired.roles.contains(&ProjectionRole::Filterable)
18550 {
18551 refresh_vector_attributes = true;
18552 }
18553 }
18554 }
18555
18556 // A declared nested source is scalar-only. Validate the complete backfill
18557 // set before any registry mutation so a composite terminal rolls the whole
18558 // configuration request back with the existing write-validation family.
18559 for spec in specs {
18560 let desired = StoredProjection::from_spec(spec);
18561 validate_projection_source_backfill(tx, &spec.name, &desired)?;
18562 }
18563
18564 let mut delta = ProjectionDelta::default();
18565
18566 // 0.8.20 Slice 20c fix-1 (codex §9 [P2]) — snapshot "is the dense arm
18567 // declared?" BEFORE any registry mutation. Together with the same read taken
18568 // after them it identifies the ONE transition that owns the inverse of this
18569 // slice's enrolment: declared -> not-declared. See
18570 // [`unenrol_registry_vector_node_kinds`] for why the inverse is keyed to that
18571 // TRANSITION rather than to the bare post-state.
18572 let vector_declared_before =
18573 vector_projection_declared(tx).map_err(|_| EngineError::Storage)?;
18574
18575 // (1) Explicit drops. Omission never drops (C3); only this list does.
18576 let before_drop = load_projection_registry(tx).map_err(|_| EngineError::Storage)?;
18577 for name in drop {
18578 if before_drop.contains_key(name) {
18579 clear_attribute_projection(tx, name).map_err(|_| EngineError::Storage)?;
18580 remove_projection_row(tx, name).map_err(|_| EngineError::Storage)?;
18581 delta.dropped.push(name.clone());
18582 }
18583 // dropping an absent projection is an idempotent no-op, not an error.
18584 }
18585
18586 // (2) Diff each spec against the post-drop registry.
18587 let current = load_projection_registry(tx).map_err(|_| EngineError::Storage)?;
18588 for spec in specs {
18589 let desired = StoredProjection::from_spec(spec);
18590 match current.get(&spec.name) {
18591 Some(existing) if existing == &desired => {
18592 // Idempotent re-registration — no-op (the keystone acceptance).
18593 }
18594 Some(existing) => {
18595 if is_destructive_projection_change(existing, &desired) {
18596 return Err(EngineError::ProjectionDestructive {
18597 name: spec.name.clone(),
18598 delta: describe_projection_delta(existing, &desired),
18599 });
18600 }
18601 persist_projection_row(tx, &spec.name, &desired)
18602 .map_err(|_| EngineError::Storage)?;
18603 clear_attribute_projection(tx, &spec.name).map_err(|_| EngineError::Storage)?;
18604 backfill_attribute(tx, &spec.name, &desired).map_err(|_| EngineError::Storage)?;
18605 if desired.wants_eav() {
18606 delta.built.push(spec.name.clone());
18607 }
18608 // 0.8.20 Slice 15e fix-2 finding 2 [P2] — this arm ONLY runs when
18609 // the registry row actually CHANGED (`existing != desired`), so the
18610 // delta MUST reflect that change; otherwise an accepted mutation
18611 // reports `unchanged = true` — a no-op lie to SDK callers. The prior
18612 // `&& !existing.has_deferred()` guard suppressed a deferred-ONLY
18613 // change (e.g. `rankable` → `rankable + vector`, which builds no EAV
18614 // so `built` stays empty): the row persisted but `delta` came back
18615 // empty. Mirror the fresh-registration push (`if
18616 // desired.has_deferred()`). Every valid non-empty spec has
18617 // `wants_eav()` OR `has_deferred()`, so on a real change at least one
18618 // of `built`/`deferred` is now populated ⇒ `unchanged` can never be
18619 // `true` on a persisted change. A genuine no-op (identical spec)
18620 // takes the idempotent arm above and is untouched.
18621 if desired.has_deferred() {
18622 delta.deferred.push(spec.name.clone());
18623 }
18624 }
18625 None => {
18626 persist_projection_row(tx, &spec.name, &desired)
18627 .map_err(|_| EngineError::Storage)?;
18628 clear_attribute_projection(tx, &spec.name).map_err(|_| EngineError::Storage)?;
18629 backfill_attribute(tx, &spec.name, &desired).map_err(|_| EngineError::Storage)?;
18630 if desired.wants_eav() {
18631 delta.built.push(spec.name.clone());
18632 }
18633 if desired.has_deferred() {
18634 delta.deferred.push(spec.name.clone());
18635 }
18636 }
18637 }
18638 }
18639
18640 // 0.8.20 Slice 15e — after the registry mutations, reconcile the live vec0
18641 // shape with the (possibly changed) `filterable` set: a NON-DESTRUCTIVE
18642 // reshape adds/removes the `attr_<hex>` pre-KNN columns preserving every
18643 // row's embedding (TC-46, HITL Option 1). Runs on the caller's write
18644 // transaction, so the reshape commits atomically with the registry row. On an
18645 // idempotent re-registration the desired set equals the live set, so this is a
18646 // no-op (vec0 untouched) and `delta.unchanged` above is unaffected. Skipped
18647 // when there is no embedder profile (⇒ no `vector_default` to reshape).
18648 if let Ok(dimension) = default_profile_dimension(tx) {
18649 if refresh_vector_attributes {
18650 refresh_vector_attr_values(tx, dimension).map_err(|_| EngineError::Storage)?;
18651 } else {
18652 reconcile_vector_attr_columns(tx, dimension).map_err(|_| EngineError::Storage)?;
18653 }
18654 }
18655
18656 delta.unchanged =
18657 delta.built.is_empty() && delta.dropped.is_empty() && delta.deferred.is_empty();
18658
18659 // 0.8.20 Slice 20c (R-20-DR remainder) — THE C4 RIDER. Everything above has
18660 // only *persisted* the `searchable→vector` declaration and pushed its name
18661 // onto `delta.deferred`. Acknowledging deferred work and then dropping it on
18662 // the floor is what made `drain` a FALSE-READY barrier; this call is where the
18663 // deferred work is actually enqueued onto the runtime `drain` waits on.
18664 //
18665 // fix-1 (codex §9 [P2]) — and its SYMMETRIC INVERSE, on the same
18666 // transaction. If this call removed the last `searchable→vector` declaration,
18667 // un-enrol the node kinds the forward path enrols; otherwise enrolment is a
18668 // one-way door and the write path keeps embedding for a projection the
18669 // registry no longer declares.
18670 let vector_declared_after = vector_projection_declared(tx).map_err(|_| EngineError::Storage)?;
18671 let enqueued = if vector_declared_after {
18672 // 0.8.20 Slice 22 (R-20-VC / TC-67) — THE REPORT. Scoped to a live dense
18673 // -arm declaration (with no `searchable→vector` projection there is
18674 // nothing for a kind to be unsupported FOR, so reporting would be noise
18675 // on every `filterable`/FTS-only call), but deliberately OUTSIDE the
18676 // `dense_arm_live` gate below — see [`unsupported_vector_kinds`].
18677 //
18678 // Placed AFTER `delta.unchanged` is computed, and it does not feed it:
18679 // this is a STATE report, not a diff, so an idempotent re-apply still
18680 // carries it (that is also the documented refresh path for the
18681 // declare-time residual).
18682 delta.vector_unsupported_kinds =
18683 unsupported_vector_kinds(tx).map_err(|_| EngineError::Storage)?;
18684 if dense_arm_live {
18685 enqueue_declared_vector_backfill(tx).map_err(|_| EngineError::Storage)?
18686 } else {
18687 false
18688 }
18689 } else {
18690 // fix-1 (codex §9 round 1 [P2], ledger `TC-71`) — the transition arm is
18691 // KEPT as-is and a state-keyed reconciliation is added BESIDE it; neither
18692 // subsumes the other. The transition fires when this very call removed the
18693 // last dense-arm declaration, including the case where it removed the last
18694 // `vector` sub-object with it (which leaves
18695 // `registry_governs_an_inert_dense_arm` false). The reconciliation covers
18696 // the ALREADY-AFFECTED database whose user calls `configure_projections`
18697 // again with anything at all: there `before` is already `false`, so the
18698 // transition arm is inert and the inert enrolment used to survive
18699 // indefinitely. `||` short-circuits, so the transition case pays nothing
18700 // extra; the other case pays two cached `EXISTS` probes on a governed call,
18701 // never on the hot write path.
18702 if vector_declared_before
18703 || registry_governs_an_inert_dense_arm(tx).map_err(|_| EngineError::Storage)?
18704 {
18705 unenrol_registry_vector_node_kinds(tx).map_err(|_| EngineError::Storage)?;
18706 }
18707 false
18708 };
18709 Ok((delta, enqueued))
18710}
18711
18712/// 0.8.20 Slice 20c fix-1 (codex §9 [P2] "Stop embedding after vector projection
18713/// drops") — **the inverse of [`enqueue_declared_vector_backfill`]'s enrolment.**
18714///
18715/// Slice 20c gave `_fathomdb_vector_kinds` its first governed-call-reachable
18716/// writer for a NODE kind (before it, the only one was the `#[doc(hidden)]`
18717/// `configure_vector_kind_for_test` hook). Forward without reverse is the defect:
18718/// after `drop`ping the last `searchable→vector` declaration,
18719/// [`project_canonical_node_row`]'s `kind_is_vector_indexed` gate and
18720/// [`connection_has_pending_projection_work`] both still see the enrolment, so
18721/// subsequent writes keep enqueueing embeds and `drain` keeps waiting on work for
18722/// a projection [`Engine::read_projections`] no longer reports.
18723///
18724/// # It DELETES NO EMBEDDING — that is the point
18725///
18726/// The shipped drop arm ([`clear_attribute_projection`] +
18727/// [`remove_projection_row`]) has never touched vec0, `_fathomdb_vector_rows` or
18728/// `_fathomdb_vector_kinds`, so "vectors already at rest survive a drop" is
18729/// ALREADY the shipped contract. Removing one registry row PRESERVES it; deleting
18730/// embeddings would be the destructive delta, and is not done here.
18731///
18732/// # Why keyed to the TRANSITION, not to the bare post-state
18733///
18734/// The rule is "this call removed the last vector declaration"
18735/// (`declared_before && !declared_after`), not "no vector declaration exists
18736/// now". A workspace can hold enrolments this registry never made — the test hook
18737/// does exactly that, and several shipped suites enrol a kind through it and then
18738/// declare an unrelated `filterable`-only projection (e.g.
18739/// `slice15e_prekn_filterable`). Firing on the bare post-state would un-enrol
18740/// those and silently kill a dense arm the registry never owned. In production
18741/// the two readings coincide: before this slice
18742/// `production_vector_kind_surface=[]`, so a node kind can only be enrolled
18743/// because a `searchable→vector` declaration existed.
18744///
18745/// It is still STATE-keyed, not delta-keyed: both members are reads of the
18746/// registry, never "was this spec new". Re-applying the same drop finds
18747/// `declared_before == false` and is a total no-op, and nothing re-enrols it
18748/// ([`Engine::enrol_vector_kind_if_declared`] is gated on
18749/// [`vector_projection_declared`]).
18750///
18751/// # 0.8.20 Slice 21 fix-1 — a SECOND, narrower authorisation now exists
18752///
18753/// The reasoning above is why the bare post-state cannot authorise this DELETE,
18754/// and it still stands. What it does not cover is a database that ran the
18755/// PRE-Slice-21c code and enrolled node kinds off a `{filterable, vector}`
18756/// declaration: there the registry DID own the enrolment, and no transition will
18757/// ever fire for it. [`registry_governs_an_inert_dense_arm`] adds exactly that
18758/// case — positively conditioned on the registry existing AND declaring a
18759/// `vector` sub-object AND declaring no `searchable→vector` projection, which is
18760/// strictly narrower than the bare post-state and in particular excludes every
18761/// workspace whose enrolment the registry never made. Its callers are
18762/// [`reconcile_inert_vector_enrolments_on_boot`] and the drop arm of
18763/// [`apply_projection_config`].
18764///
18765/// # `'edge_fact'` is excluded, deliberately
18766///
18767/// [`project_canonical_edge_row`] (G11) auto-registers `'edge_fact'` off the
18768/// presence of an edge BODY, unconditionally and independently of the projection
18769/// registry. That lifecycle predates this slice and is not the registry's to end,
18770/// so a node-projection drop must not take the edge dense arm down with it.
18771///
18772/// # What it deliberately does NOT do
18773///
18774/// It touches no `_fathomdb_projection_terminal` row and no readiness watermark.
18775/// A row enqueued-but-not-yet-embedded when the drop lands keeps its absent
18776/// terminal, which pins the watermark below it — harmless, because both the
18777/// scheduler and the pending-work probe join `_fathomdb_vector_kinds` and so no
18778/// longer see it, and it is precisely what lets a later RE-declaration pick the
18779/// row up again instead of stranding it.
18780fn unenrol_registry_vector_node_kinds(tx: &Connection) -> rusqlite::Result<()> {
18781 tx.execute("DELETE FROM _fathomdb_vector_kinds WHERE kind <> 'edge_fact'", [])?;
18782 Ok(())
18783}
18784
18785/// 0.8.20 Slice 21 fix-1 (codex §9 round 1 `[P2]`, ledger `TC-71`) — **does the
18786/// registry GOVERN the dense arm while declaring none?** The narrow,
18787/// positively-conditioned predicate that authorises
18788/// [`unenrol_registry_vector_node_kinds`] on a bare STATE rather than on the
18789/// `declared_before && !declared_after` transition.
18790///
18791/// # Why a state-keyed authorisation exists at all
18792///
18793/// Slice 21c gated the dense arm on the `searchable` ROLE, which closes the three
18794/// FORWARD doors. It cannot reach a database that already ran the old code: those
18795/// node kinds are already in `_fathomdb_vector_kinds`, and
18796///
18797/// - [`Engine::vector_kind_needs_enrolment`] returns early the moment
18798/// [`kind_is_vector_indexed`] is true, so it never consults the new role-aware
18799/// predicate for an EXISTING registration; and
18800/// - [`project_canonical_node_row`] gates the embed enqueue solely on registry
18801/// membership (deliberately — that is the hot write path, and the decision is
18802/// meant to live upstream).
18803///
18804/// So without this, upgrading does not actually stop the billable, unexpected
18805/// embeddings for exactly the population TC-71 was raised for — the finding's
18806/// whole harm survives the fix unless the user happens to perform a
18807/// searchable-vector-to-none transition later.
18808///
18809/// # THE TRAP: why it is not `!vector_projection_declared`
18810///
18811/// [`vector_projection_declared`] answers `false` when the registry table is
18812/// ABSENT (pre-step-24) or merely EMPTY — which is every LEGACY database, many of
18813/// which have a legitimately working dense arm enrolled by other means (the
18814/// `#[doc(hidden)]` `configure_vector_kind_for_test` hook is one; before this
18815/// slice `production_vector_kind_surface=[]`, but a workspace is not obliged to
18816/// have reached its enrolment through the registry). Un-enrolling on that bare
18817/// negative would silently switch vector search OFF for all of them — a far worse
18818/// regression than TC-71 itself. So the rule is POSITIVE on all three counts:
18819///
18820/// 1. `_fathomdb_projection_registry` EXISTS; **and**
18821/// 2. at least one row carries a `vector` sub-object (`vector_declared = 1`) —
18822/// someone actually asked for a dense arm through the registry, which is
18823/// precisely what identifies the affected population; **and**
18824/// 3. NO projection satisfies [`StoredProjection::wants_vector`], i.e. none of
18825/// them is `searchable`.
18826///
18827/// Condition 2 is the load-bearing one. It leaves untouched a registry-governed
18828/// database that declares no `vector` sub-object at all but holds enrolments from
18829/// a pre-registry era (`slice15e_prekn_filterable` is exactly that shape). Being
18830/// conservative here is the correct direction: never destroy a working dense arm.
18831///
18832/// Conditions 1+2 are the SAME two `prepare_cached` `EXISTS` probes
18833/// [`vector_projection_declared`] opens with, so a workspace that never declared
18834/// a `vector` sub-object — the overwhelmingly common shape — pays nothing beyond
18835/// them and never reaches the typed [`load_projection_registry`] read. Condition 3
18836/// is delegated to [`vector_projection_declared`] verbatim rather than re-derived,
18837/// so the authorisation and the gate cannot drift.
18838fn registry_governs_an_inert_dense_arm(conn: &Connection) -> rusqlite::Result<bool> {
18839 // (1) the registry must EXIST. A pre-step-24 database has no registry at all
18840 // and is therefore not registry-governed — hands off.
18841 let table_exists: bool = conn
18842 .prepare_cached(
18843 "SELECT EXISTS(
18844 SELECT 1 FROM sqlite_master
18845 WHERE type = 'table' AND name = '_fathomdb_projection_registry'
18846 )",
18847 )?
18848 .query_row([], |row| row.get(0))?;
18849 if !table_exists {
18850 return Ok(false);
18851 }
18852 // (2) …and it must actually DECLARE a `vector` sub-object somewhere. An empty
18853 // or vector-less registry governs no dense arm, so any enrolment present came
18854 // from outside it and is not ours to remove.
18855 let any_vector_subobject: bool = conn
18856 .prepare_cached(
18857 "SELECT EXISTS(SELECT 1 FROM _fathomdb_projection_registry WHERE vector_declared = 1)",
18858 )?
18859 .query_row([], |row| row.get(0))?;
18860 if !any_vector_subobject {
18861 return Ok(false);
18862 }
18863 // (3) …while declaring no `searchable→vector` projection. THE predicate,
18864 // reused, so this can never disagree with the gate the write path applies.
18865 Ok(!vector_projection_declared(conn)?)
18866}
18867
18868/// 0.8.20 Slice 21 fix-1 (codex §9 round 1 `[P2]`) — the BOOT arm of the
18869/// reconciliation: on every open, bring an already-enrolled inert vector kind
18870/// into agreement with the role-aware decision, so an affected database
18871/// self-heals without the user calling anything. Returns `true` iff it un-enrolled
18872/// something.
18873///
18874/// Authorised by [`registry_governs_an_inert_dense_arm`] (read that for the trap
18875/// this must not fall into), and performed by
18876/// [`unenrol_registry_vector_node_kinds`] — the SAME writer the drop inverse uses,
18877/// so `'edge_fact'` is excluded (G11 auto-registers it off the presence of an edge
18878/// body, independently of the projection registry) and **no embedding is deleted**.
18879///
18880/// # It mirrors the drop inverse exactly, because that inverse does nothing else
18881///
18882/// `apply_projection_config`'s drop arm is a single call to
18883/// [`unenrol_registry_vector_node_kinds`]: no terminal record is touched, no
18884/// readiness watermark is rewound, no row is un-stranded, and nothing is notified
18885/// (it returns `enqueued = false`). So leaving the database in "the state a drop
18886/// transition would have left it in" is exactly that one `DELETE`, and there is
18887/// no second half to mirror.
18888///
18889/// # Cheap when there is nothing to do, and idempotent
18890///
18891/// A workspace with no `vector` sub-object pays only the two cached `EXISTS`
18892/// probes the authorisation opens with. When the authorisation DOES fire, a third
18893/// cached `EXISTS` checks whether any node kind is actually enrolled, so the
18894/// steady state after the first healing open is a pure READ — no write
18895/// transaction, no `DELETE`, nothing to oscillate. `DELETE … WHERE kind <>
18896/// 'edge_fact'` is a single statement, hence atomic on its own; no explicit
18897/// transaction is opened around it.
18898///
18899/// # Placement
18900///
18901/// Runs inside `open_locked` on the writer connection, single-threaded, before
18902/// readers and the projection workers spawn — alongside the other boot
18903/// reconciliations ([`rederive_projections_on_boot`],
18904/// [`reconcile_vector_attr_columns`]) and therefore BEFORE
18905/// [`run_vector_equivalence_probe`], which is deliberate: on a database whose only
18906/// enrolment was the inert one, reconciling first leaves `_fathomdb_vector_kinds`
18907/// empty, so the probe correctly finds no dense arm to guard and the healing open
18908/// spends no embed calls at all.
18909///
18910/// # Not a data migration
18911///
18912/// It removes a registration row inside ONE live database to match that
18913/// database's own declarations. It converts no row across a version step; the
18914/// reconciliation itself introduces no migration.
18915fn reconcile_inert_vector_enrolments_on_boot(conn: &Connection) -> rusqlite::Result<bool> {
18916 if !registry_governs_an_inert_dense_arm(conn)? {
18917 return Ok(false);
18918 }
18919 // Nothing enrolled beyond the G11 edge arm ⇒ nothing to do. Keeps the steady
18920 // state a pure read instead of a no-op write transaction on every open.
18921 let any_node_kind: bool = conn
18922 .prepare_cached(
18923 "SELECT EXISTS(SELECT 1 FROM _fathomdb_vector_kinds WHERE kind <> 'edge_fact')",
18924 )?
18925 .query_row([], |row| row.get(0))?;
18926 if !any_node_kind {
18927 return Ok(false);
18928 }
18929 unenrol_registry_vector_node_kinds(conn)?;
18930 Ok(true)
18931}
18932
18933/// 0.8.20 Slice 20c (R-20-DR remainder) — is ANY `searchable→vector` projection
18934/// declared in the durable registry?
18935///
18936/// This is the corpus-wide "the dense arm is live" predicate. It is corpus-wide
18937/// rather than per-attribute for the same reason [`derive_dense_readiness`] is:
18938/// Slice 15d persists the `searchable→vector` sub-object but defers building any
18939/// per-attribute embedding, so every declared vector projection is served by the
18940/// ONE engine vector pipeline. When per-attribute embedding lands, this is where
18941/// the scoping goes — the same seam as readiness.
18942///
18943/// Safe on a pre-step-24 schema (the registry table is created by step 24): an
18944/// absent table means nothing is declared, not an error. Mirrors the guard in
18945/// [`load_projection_registry`], and uses `prepare_cached` because the write path
18946/// calls this once per un-registered-kind row.
18947///
18948/// # 0.8.20 Slice 21c (ledger `TC-71`) — it requires the `searchable` ROLE
18949///
18950/// This used to answer `EXISTS(… WHERE vector_declared = 1)`, reading the stored
18951/// `vector` sub-object and never the `roles` column. But the sub-object SELECTS
18952/// a sub-target of `searchable`; it does not confer one (exactly as `fts` does
18953/// not — see [`StoredProjection::wants_property_fts`]). So
18954/// `{roles: [filterable], vector: {}}`, which Slice 15d documents as
18955/// inert-but-round-trippable, turned the dense arm ON in any session with a live
18956/// embedder: it enrolled node kinds, backfilled the corpus, and made every later
18957/// write of those kinds enqueue an embedding. Wasted embed work and unexpected
18958/// vectors at rest for a projection meant to do nothing. The answer now comes
18959/// from [`StoredProjection::wants_vector`], the ONE predicate, so the three
18960/// gated paths cannot drift.
18961///
18962/// **This flips the forward AND inverse arms of [`apply_projection_config`] at
18963/// once**, which is a real semantic consequence and not an accident: demoting
18964/// the last `{searchable, vector}` projection to `{filterable, vector}` (or
18965/// dropping it while an inert `{filterable, vector}` sibling survives) now reads
18966/// `declared → not-declared` and therefore UN-ENROLS, where before the surviving
18967/// `vector_declared = 1` row masked the transition and the write path kept
18968/// embedding. Pinned in `tests/slice21c_vector_role_gate.rs`.
18969///
18970/// # Why the cheap `EXISTS` survives as a pre-filter
18971///
18972/// The write path calls this once per un-registered-kind row, and the
18973/// overwhelmingly common shape is a workspace that declared no `vector`
18974/// sub-object at all. `EXISTS(… vector_declared = 1)` is a NECESSARY condition
18975/// for [`StoredProjection::wants_vector`], so keeping it as a fast negative
18976/// leaves that workspace paying exactly the two cached `EXISTS` probes it paid
18977/// before — no typed load, no `BTreeMap`, no uncached `prepare`. Only a
18978/// workspace that HAS a `vector` sub-object somewhere pays the
18979/// [`load_projection_registry`] read, and there the registry is a handful of
18980/// app-declared rows; in the ordinary `searchable→vector` case the kind is
18981/// enrolled after the first probe and `kind_is_vector_indexed` short-circuits
18982/// this call entirely from then on.
18983fn vector_projection_declared(conn: &Connection) -> rusqlite::Result<bool> {
18984 let table_exists: bool = conn
18985 .prepare_cached(
18986 "SELECT EXISTS(
18987 SELECT 1 FROM sqlite_master
18988 WHERE type = 'table' AND name = '_fathomdb_projection_registry'
18989 )",
18990 )?
18991 .query_row([], |row| row.get(0))?;
18992 if !table_exists {
18993 return Ok(false);
18994 }
18995 // Fast negative: no `vector` sub-object anywhere ⇒ certainly no dense arm.
18996 let any_vector_subobject: bool = conn
18997 .prepare_cached(
18998 "SELECT EXISTS(SELECT 1 FROM _fathomdb_projection_registry WHERE vector_declared = 1)",
18999 )?
19000 .query_row([], |row| row.get(0))?;
19001 if !any_vector_subobject {
19002 return Ok(false);
19003 }
19004 // `roles` is persisted as a comma-joined sorted string, so it is not a
19005 // trustworthy SQL predicate (a `LIKE` would match a forward-compat token that
19006 // merely CONTAINS a role spelling). Answer through the typed registry and the
19007 // ONE predicate instead.
19008 Ok(load_projection_registry(conn)?.values().any(StoredProjection::wants_vector))
19009}
19010
19011/// 0.8.20 Slice 20c (R-20-DR remainder) — enrol `kind` in the vector pipeline.
19012///
19013/// `INSERT OR IGNORE`, so it is idempotent and never disturbs an existing
19014/// registration's `profile`/`created_at`. Same statement shape the G11 edge path
19015/// uses for `'edge_fact'` ([`project_canonical_edge_row`]).
19016fn register_vector_kind(tx: &Connection, kind: &str) -> rusqlite::Result<()> {
19017 tx.execute(
19018 "INSERT OR IGNORE INTO _fathomdb_vector_kinds(kind, profile, created_at)
19019 VALUES(?1, ?2, 0)",
19020 params![kind, DEFAULT_VECTOR_PROFILE],
19021 )?;
19022 Ok(())
19023}
19024
19025/// 0.8.20 Slice 20c (R-20-DR remainder) — **the flush barrier's enqueue half**
19026/// (`api-surface.md` **C4** rider: `drain` is a barrier, not a trigger, so
19027/// deferred/backfill rows must be enqueued on the same projection runtime `drain`
19028/// waits on).
19029///
19030/// Runs on the caller's `configure_projections` write transaction, AFTER the
19031/// registry mutations, so the enrolment + re-enqueue commit atomically with the
19032/// declaration that caused them. Returns `true` iff work was enqueued — the
19033/// caller must then `notify_new_work()` (after the commit; the dispatcher opens
19034/// its own connection).
19035///
19036/// # The defect this closes
19037///
19038/// `project_canonical_node_row` writes a PERMANENT `'up_to_date'` terminal for
19039/// any row whose kind was not vector-registered *at write time*, and before this
19040/// slice NOTHING but the `#[doc(hidden)]` test hook ever registered a node kind
19041/// (`slice-G0-design.md`: `production_vector_kind_surface=[]`). So the ordinary
19042/// "turn the dense arm on over an existing corpus" flow — write rows, then
19043/// declare `searchable→vector` — left every row terminally marked done with no
19044/// vector and no way to get one short of an operator `rebuild`. Both
19045/// `drain`/`wait_for_idle` and `derive_dense_readiness` read that terminal
19046/// through [`connection_has_pending_projection_work`], so the corpus reported
19047/// `ready` while nothing would ever embed it.
19048///
19049/// # Shape (deliberately the `run_rebuild` shape, scoped)
19050///
19051/// `run_rebuild` truncates the readiness terminals and rewinds the projection
19052/// cursor so the scheduler re-walks the corpus. This does the same, but scoped to
19053/// the rows the declaration newly covers, and it does NOT truncate anything else:
19054///
19055/// 1. enrol every vector-eligible node kind present in `canonical_nodes`
19056/// (`row_kind IN ('leaf','coverage')` — the `index_targets_for_row_kind`
19057/// vector-eligibility predicate; `graph` rows are lexically searchable but
19058/// never embedded, so enrolling on them would silently start embedding
19059/// structural rows) **that the vector writer can commit**
19060/// ([`kind_is_vector_committable`], fix-2 / codex §9 [P1]);
19061/// 2. (and 3.) un-strand the rows that enrolment now covers, via
19062/// [`reenqueue_stranded_vector_rows`] — shared verbatim with the write path's
19063/// late enrolment.
19064///
19065/// # Why it is IDEMPOTENT (R-20-PR: "re-registration is a no-op")
19066///
19067/// Every step keys off *state*, not off "was this declaration new": step 1 is
19068/// `INSERT OR IGNORE`; steps 2-3 act only on rows that are stranded RIGHT NOW.
19069/// Once the backfill has been drained those rows carry vectors, so a re-apply
19070/// finds an empty stranded set, returns `false`, and touches neither the
19071/// terminals nor the cursor. No rewind, no re-embed, no spurious `embedding`
19072/// window.
19073///
19074/// # Not a data migration
19075///
19076/// This re-enqueues embed work inside ONE live database at the caller's request.
19077/// It converts no rows across a version step and introduces no migration (HITL
19078/// 2026-07-21; cf. TC-46's in-place vec0 reshape).
19079/// 0.8.20 Slice 22 (R-20-VC / **TC-67**) — the ONE scan of "which node kinds in
19080/// this corpus are candidates for the dense arm?".
19081///
19082/// `row_kind IN ('leaf', 'coverage')` is the `index_targets_for_row_kind` vector
19083/// -eligibility predicate: `graph` rows are lexically searchable but NEVER
19084/// embedded, so they are excluded here on a ROW-KIND axis that has nothing to do
19085/// with the `kind` vocabulary — including them would make TC-67 report structural
19086/// rows as "unsupported kinds", which is a different (and false) statement.
19087///
19088/// Extracted so [`enqueue_declared_vector_backfill`] (which enrols the
19089/// commit-able half) and [`unsupported_vector_kinds`] (which reports the other
19090/// half) partition ONE list rather than running two hand-copied queries that
19091/// could drift — the same TC-56 anti-drift discipline that made
19092/// [`kind_is_vector_committable`] delegate to [`resolve_source_type`].
19093///
19094/// `SELECT DISTINCT … ORDER BY kind` gives the caller a sorted, de-duplicated
19095/// list for free, which is the reported ordering.
19096fn vector_eligible_node_kinds(tx: &Connection) -> rusqlite::Result<Vec<String>> {
19097 let mut stmt = tx.prepare(
19098 "SELECT DISTINCT kind FROM canonical_nodes
19099 WHERE row_kind IN ('leaf', 'coverage')
19100 ORDER BY kind",
19101 )?;
19102 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
19103 rows.collect::<rusqlite::Result<Vec<String>>>()
19104}
19105
19106/// 0.8.20 Slice 22 (R-20-VC / **TC-67**) — **the report that replaces the
19107/// silence.** The vector-eligible node kinds present in the corpus that
19108/// [`kind_is_vector_committable`] excludes, i.e. the exact complement of the set
19109/// [`enqueue_declared_vector_backfill`] enrols.
19110///
19111/// Populates [`ProjectionDelta::vector_unsupported_kinds`]. Read that field's
19112/// doc-comment for the naming, the state-not-diff semantics and the residual;
19113/// what belongs HERE is the one thing the call SITE decides:
19114///
19115/// **It is deliberately NOT gated on `dense_arm_live`.** The enrolment it mirrors
19116/// is (`apply_projection_config` only calls `enqueue_declared_vector_backfill`
19117/// with a usable dense runtime, the Q6a graceful-absent path), but this answer does not
19118/// depend on the session: [`resolve_source_type`]'s vocabulary is a compile-time
19119/// constant, so "this kind can never be embedded" is equally true with no
19120/// embedder attached. Gating it would hide the permanent fact behind the
19121/// transient one, which is the very conflation TC-67 exists to end — a
19122/// no-embedder caller is exactly the caller who most needs to know that
19123/// attaching an embedder later will still not embed these kinds.
19124fn unsupported_vector_kinds(tx: &Connection) -> rusqlite::Result<Vec<String>> {
19125 Ok(vector_eligible_node_kinds(tx)?
19126 .into_iter()
19127 .filter(|kind| !kind_is_vector_committable(kind))
19128 .collect())
19129}
19130
19131fn enqueue_declared_vector_backfill(tx: &Connection) -> rusqlite::Result<bool> {
19132 if !vector_projection_declared(tx)? {
19133 return Ok(false);
19134 }
19135
19136 // (1) Enrol the vector-eligible kinds the live corpus actually contains —
19137 // RESTRICTED to the ones the vector writer can actually commit
19138 // ([`kind_is_vector_committable`], fix-2 / codex §9 [P1]). Enrolling a kind
19139 // outside `resolve_source_type`'s locked vocabulary wedges the projection
19140 // worker forever and starves every other kind with it.
19141 //
19142 // 0.8.20 Slice 22 (TC-67) — the kinds this filter DROPS are what
19143 // [`unsupported_vector_kinds`] reports; both read the same scan through
19144 // [`vector_eligible_node_kinds`] so the report can never describe a
19145 // different set from the one actually excluded.
19146 let kinds = vector_eligible_node_kinds(tx)?;
19147 for kind in kinds.iter().filter(|kind| kind_is_vector_committable(kind)) {
19148 register_vector_kind(tx, kind)?;
19149 }
19150
19151 // (2)+(3) Un-strand the rows the new enrolment now covers.
19152 reenqueue_stranded_vector_rows(tx)
19153}
19154
19155/// Enrol and requeue a durable vector declaration during a safe open. The
19156/// prospective-equivalence guard runs before this function; this function owns
19157/// the one durable transaction that makes a crash converge to either the old
19158/// state or a fully queued repair.
19159fn boot_graft_declared_vector_backfill(connection: &Connection) -> rusqlite::Result<bool> {
19160 if !vector_projection_declared(connection)? {
19161 return Ok(false);
19162 }
19163 connection.execute_batch("BEGIN IMMEDIATE")?;
19164 match enqueue_declared_vector_backfill(connection) {
19165 Ok(enqueued) => {
19166 connection.execute_batch("COMMIT")?;
19167 Ok(enqueued)
19168 }
19169 Err(error) => {
19170 let _ = connection.execute_batch("ROLLBACK");
19171 Err(error)
19172 }
19173 }
19174}
19175
19176/// 0.8.20 Slice 20c — steps (2) and (3) of the declared-backfill above, as their
19177/// own function because **both** enrolment doors owe this treatment.
19178///
19179/// fix-2 (codex §9 [P2]): [`enqueue_declared_vector_backfill`] is the DECLARE-time
19180/// door; [`Engine::enrol_batch_vector_kinds`] is the WRITE-time one, and it used to
19181/// enrol a kind while enqueueing only the row in its own batch. A database that
19182/// persisted a `searchable→vector` declaration while opened WITHOUT an embedder
19183/// (Q6a graceful-absent: it defers, enrolling nothing), then reopened WITH one and
19184/// wrote the same kind BEFORE re-applying the projection, therefore drained the new
19185/// row and reported `ready` while every row from the no-embedder session kept its
19186/// permanent `'up_to_date'` terminal and no vector. That is a FALSE READY — the
19187/// exact defect class R-20-DR exists to eliminate — so the two doors share ONE
19188/// implementation rather than one of them carrying a partial copy.
19189///
19190/// Returns `true` iff work was re-enqueued; the caller must then `notify_new_work()`
19191/// (after its commit — the dispatcher opens its own connection).
19192///
19193/// 2. find the STRANDED rows — vector-eligible, now vector-kind-registered,
19194/// carrying an `'up_to_date'` terminal, and carrying NO `_fathomdb_vector_rows`
19195/// row — and delete their terminals so the scheduler's `terminal IS NULL`
19196/// predicate sees them again;
19197/// 3. rewind the readiness watermark to just below the lowest stranded cursor, so
19198/// the scheduler's `write_cursor > cursor` filter reaches them.
19199///
19200/// The `_fathomdb_vector_kinds` join is what scopes this to the dense arm: a kind
19201/// that is not enrolled (including one that is not commit-able, per
19202/// [`kind_is_vector_committable`]) is not stranded — it has no dense arm to be
19203/// behind on.
19204///
19205/// Idempotent by construction: it acts only on rows that are stranded RIGHT NOW, so
19206/// once drained the set is empty, it returns `false`, and neither the terminals nor
19207/// the cursor are touched. A `'failed'` terminal is deliberately NOT re-enqueued
19208/// (the filter is `'up_to_date'`): re-enqueueing it would loop a permanently-failing
19209/// row forever, and the documented failure boundary is that a terminally-failed
19210/// embed stops being outstanding work (see [`derive_dense_readiness`]).
19211fn reenqueue_stranded_vector_rows(tx: &Connection) -> rusqlite::Result<bool> {
19212 // (2) The stranded set: covered by the dense arm, terminally marked done, no
19213 // vector. `MIN` first so a no-op apply costs one indexed probe and stops.
19214 let lowest_stranded: Option<u64> = tx.query_row(
19215 "SELECT MIN(n.write_cursor)
19216 FROM canonical_nodes n
19217 JOIN _fathomdb_vector_kinds k ON k.kind = n.kind
19218 JOIN _fathomdb_projection_terminal t ON t.write_cursor = n.write_cursor
19219 LEFT JOIN _fathomdb_vector_rows v ON v.write_cursor = n.write_cursor
19220 WHERE n.row_kind IN ('leaf', 'coverage')
19221 AND t.state = 'up_to_date'
19222 AND v.write_cursor IS NULL",
19223 [],
19224 |row| row.get::<_, Option<u64>>(0),
19225 )?;
19226 let Some(lowest_stranded) = lowest_stranded else {
19227 return Ok(false);
19228 };
19229
19230 tx.execute(
19231 "DELETE FROM _fathomdb_projection_terminal
19232 WHERE write_cursor IN (
19233 SELECT n.write_cursor
19234 FROM canonical_nodes n
19235 JOIN _fathomdb_vector_kinds k ON k.kind = n.kind
19236 JOIN _fathomdb_projection_terminal t ON t.write_cursor = n.write_cursor
19237 LEFT JOIN _fathomdb_vector_rows v ON v.write_cursor = n.write_cursor
19238 WHERE n.row_kind IN ('leaf', 'coverage')
19239 AND t.state = 'up_to_date'
19240 AND v.write_cursor IS NULL
19241 )",
19242 [],
19243 )?;
19244
19245 // (3) Rewind the readiness watermark just below the lowest stranded row so the
19246 // scheduler's `write_cursor > cursor` filter reaches it. Never move it
19247 // FORWARD: rows above the watermark that still hold their terminals are
19248 // skipped by the scheduler's `terminal IS NULL` predicate, and
19249 // `advance_projection_cursor` walks the watermark back up over them.
19250 let rewind_to = lowest_stranded.saturating_sub(1);
19251 if load_projection_cursor(tx)? > rewind_to {
19252 store_projection_cursor(tx, rewind_to)?;
19253 }
19254 Ok(true)
19255}
19256
19257/// 0.8.20 Slice 15d (R-20-PR, Q5) — BOOT re-derive: the engine `ProjectionSpec`
19258/// is a DERIVED cache, re-driven idempotently on boot. For every persisted
19259/// registry declaration, clear + backfill its EAV / property-FTS rows from the
19260/// canonical nodes — so a DB whose registry row survives but whose projection
19261/// rows are missing/partial (a crash window, a restored registry) CONVERGES on
19262/// the next open. A no-op (single empty-table read) when no projections are
19263/// declared — which is every pre-`configure_projections` DB. Runs on the writer
19264/// connection, single-threaded, before readers spawn.
19265fn rederive_projections_on_boot(conn: &Connection) -> rusqlite::Result<()> {
19266 let registry = load_projection_registry(conn)?;
19267 if registry.is_empty() {
19268 return Ok(());
19269 }
19270 conn.execute_batch("BEGIN IMMEDIATE")?;
19271 let result = (|| {
19272 for (name, stored) in ®istry {
19273 clear_attribute_projection(conn, name)?;
19274 backfill_attribute(conn, name, stored)?;
19275 }
19276 Ok(())
19277 })();
19278 match result {
19279 Ok(()) => conn.execute_batch("COMMIT"),
19280 Err(err) => {
19281 let _ = conn.execute_batch("ROLLBACK");
19282 Err(err)
19283 }
19284 }
19285}
19286
19287/// EXP-S (0.8.14 Slice 5) — the `row_kind -> index-target set` dispatch
19288/// (ADR-0.8.14 §D2), and the OPP-12 forward-compat seam (ADR-0.8.14 §D5(a) /
19289/// ledger `TC-1`).
19290///
19291/// This is deliberately a per-kind LOOKUP rather than branching inlined at each
19292/// write call-site: it is the single seam a later declarative OPP-12 projection
19293/// registry (`dev/design/projection-registry-and-async-embed.md`) would wrap to
19294/// populate `row_kind -> {filterable, searchable->FTS (same-txn), searchable->
19295/// vector (async)}` without reshaping the substrate. Per D5, EXP-S implements
19296/// NO OPP-12 surface here (OPP-12 lands >=0.9.x; re-check at its scheduling) —
19297/// this function only records the index-target intent so the async-vs-sync split
19298/// (D5(b)) and the per-kind-extensible terminal-cursor readiness (D5(c)) stay
19299/// wrappable.
19300///
19301/// `Leaf` MUST preserve today's behavior exactly: FTS (sync) + vector (async,
19302/// gated by `kind_is_vector_indexed`).
19303fn index_targets_for_row_kind(row_kind: RowKind) -> IndexTargetSet {
19304 match row_kind {
19305 // Normal record — identical to pre-EXP-S behavior.
19306 RowKind::Leaf => IndexTargetSet { fts: true, vector: true },
19307 // Coverage/summary rows — searchable and embeddable.
19308 RowKind::Coverage => IndexTargetSet { fts: true, vector: true },
19309 // Graph structural rows — lexically searchable, not embedded.
19310 RowKind::Graph => IndexTargetSet { fts: true, vector: false },
19311 }
19312}
19313
19314/// EXP-S (0.8.14 Slice 5, D2/D5) — apply the per-`row_kind` index-target
19315/// dispatch for one just-inserted canonical node row (write_cursor `cursor`).
19316///
19317/// Preserves the OPP-12-shaped split (D5(b)): FTS is written in THIS
19318/// transaction (same-txn `searchable->FTS`); vector work is only *enqueued*
19319/// here into `_fathomdb_projection_state` and embedded later, asynchronously,
19320/// by the projection worker pool (`searchable->vector`). When the row projects
19321/// into no async vector index, its readiness is terminated up-front (D5(c),
19322/// per-kind-extensible) so `advance_projection_cursor` can walk past it.
19323///
19324/// Returns `true` iff async vector work was enqueued (the caller must then
19325/// `notify_new_work`). For `RowKind::Leaf` this is behavior-identical to the
19326/// pre-EXP-S inline node path.
19327fn project_canonical_node_row(
19328 tx: &Connection,
19329 cursor: u64,
19330 kind: &str,
19331 body: &str,
19332 row_kind: RowKind,
19333 pass: ProjectionPass,
19334 node_active: bool,
19335) -> rusqlite::Result<bool> {
19336 let targets = index_targets_for_row_kind(row_kind);
19337 if targets.fts && pass.writes_fts() {
19338 tx.execute(
19339 "INSERT INTO search_index(body, kind, write_cursor) VALUES(?1, ?2, ?3)",
19340 params![body, kind, cursor],
19341 )?;
19342 // F5 (0.8.14 Slice 10) — same coexisting `searchable->FTS` target also
19343 // populates the multi-column `search_index_v2` (kind/body/status) so a
19344 // BM25F query can field-weight the lexical arm. Written SYNCHRONOUSLY in
19345 // THIS transaction, exactly like `search_index` (rowid==write_cursor
19346 // identity preserved). The `status` field mirrors the migration-17
19347 // O(N) re-index: `$.status` from a JSON body, guarded by `json_valid` so
19348 // non-JSON bodies index an empty status. NOTE (codex fix-1 finding 2):
19349 // this is F5's OWN `$.status`-derived field for the BM25F `status`
19350 // column — it is NOT (yet) the value the shipped G10 SearchFilter reads.
19351 // G10 filtering reads the vec0 `status` column, which is still hardwired
19352 // to the empty-string sentinel; wiring G10 onto this field is out of
19353 // scope for F5. Determinism (R-SUB-2) is preserved: the derivation is
19354 // a pure function of `body`, evaluated in-SQL identically on every run.
19355 tx.execute(
19356 "INSERT INTO search_index_v2(kind, body, status, write_cursor)
19357 VALUES(
19358 ?1,
19359 ?2,
19360 CASE WHEN json_valid(?2)
19361 THEN COALESCE(json_extract(?2, '$.status'), '')
19362 ELSE '' END,
19363 ?3
19364 )",
19365 params![kind, body, cursor],
19366 )?;
19367 }
19368 // 0.8.20 Slice 15d (R-20-EAV) — same-transaction attribute projection. Only
19369 // the full `Write` pass re-derives attributes (see `writes_attributes`): the
19370 // FtsOnly tokenizer reproject predates step 24 and must not touch the
19371 // registry/attribute tables; VectorOnly rebuilds only vector shadows. A full
19372 // operator FTS rebuild uses `Write`, so it re-derives attributes after the
19373 // truncate.
19374 //
19375 // fix-2 [P2]: gated on `node_active`. The at-rest attribute projection tracks
19376 // EXACTLY the backfill's row set — `state = 'active' AND superseded_at IS NULL`
19377 // (see `backfill_attribute`). Unlike node-FTS / vector shadows (whose stale
19378 // versions are excluded by the canonical read path's `superseded_at IS NULL`
19379 // / `state = 'active'` join), the property tables carry NO read-side lifecycle
19380 // filter (`property_search_index` is an FTS5 table that cannot), so a pending
19381 // or superseded node's attribute values would otherwise LEAK into a
19382 // same-session property filter / property-FTS. The write path passes
19383 // `state == Active`; a projector-replay rebuild passes `active ∧ non-superseded`
19384 // per row. Lifecycle transitions maintain the store directly (see
19385 // `Engine::transition`). Passes where `writes_attributes()` is false ignore the
19386 // flag entirely.
19387 if pass.writes_attributes() && node_active {
19388 project_node_attributes(tx, cursor as i64, body)?;
19389 }
19390 // 0.8.20 Slice 20c (R-20-DR remainder) — UNCHANGED, deliberately. Late
19391 // enrolment of a kind first written AFTER a `searchable→vector` declaration
19392 // happens in [`Engine::enrol_vector_kind_if_declared`], upstream of this
19393 // transaction, NOT here: the decision needs the engine's usable-runtime
19394 // predicate, which a free function holding only a `Connection` cannot see.
19395 // Enrolling without one would queue embeds that cannot safely run.
19396 let enqueue_vector = targets.vector && kind_is_vector_indexed(tx, kind).unwrap_or(false);
19397 if pass.writes_vector_state() {
19398 if enqueue_vector {
19399 tx.execute(
19400 "INSERT INTO _fathomdb_projection_state(kind, last_enqueued_cursor, updated_at)
19401 VALUES(?1, ?2, 0)
19402 ON CONFLICT(kind) DO UPDATE SET last_enqueued_cursor = excluded.last_enqueued_cursor",
19403 params![kind, cursor],
19404 )?;
19405 } else {
19406 // Never-vector-projected rows terminate the cursor up-front so
19407 // `advance_projection_cursor` can advance the readiness watermark.
19408 record_projection_terminal(tx, cursor, "up_to_date")?;
19409 }
19410 }
19411 Ok(enqueue_vector)
19412}
19413
19414/// 0.8.20 Slice 5a (R-20-E1, work item 1) — the EDGE half of the total
19415/// projector, extracted verbatim from the inlined `commit_batch` edge arm.
19416///
19417/// Before this extraction there was NO edge projector function: `commit_batch`
19418/// inlined the edge FTS insert + the edge vector enqueue, and
19419/// `rebuild_shadow_state` re-implemented a SUBSET of it (edge FTS only, and only
19420/// for body-carrying edges), so a projector-replay rebuild silently dropped the
19421/// rest — notably the `up_to_date` readiness terminal that the write path
19422/// records for a body-less structural edge. With both sites now calling this one
19423/// function, the write path and the rebuild path produce identical edge
19424/// projections by construction.
19425///
19426/// Mirrors [`project_canonical_node_row`]'s split (ADR-0.8.14 §D5(b)): FTS in
19427/// THIS transaction; vector work only ENQUEUED, embedded later by the worker
19428/// pool. Edge bodies enqueue under the fixed kind `"edge_fact"` so
19429/// `resolve_source_type` maps them to `source_type = "edge_fact"` in
19430/// `vector_default` (partition correctness); that kind is auto-registered in
19431/// `_fathomdb_vector_kinds` (idempotent).
19432///
19433/// Returns `true` iff async vector work was enqueued.
19434fn project_canonical_edge_row(
19435 tx: &Connection,
19436 cursor: u64,
19437 kind: &str,
19438 body: Option<&str>,
19439 pass: ProjectionPass,
19440) -> rusqlite::Result<bool> {
19441 // G11 — edge FTS projection into `search_index_edges` (separate table from
19442 // node-body `search_index` — Option B partition). Body-less structural
19443 // edges carry no lexical content and project no FTS row.
19444 if pass.writes_fts() {
19445 if let Some(edge_body) = body {
19446 tx.execute(
19447 "INSERT INTO search_index_edges(body, kind, write_cursor)
19448 VALUES(?1, ?2, ?3)",
19449 params![edge_body, kind, cursor],
19450 )?;
19451 }
19452 }
19453 let enqueue_vector = body.is_some();
19454 if pass.writes_vector_state() {
19455 if enqueue_vector {
19456 let now_unix =
19457 SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
19458 tx.execute(
19459 "INSERT OR IGNORE INTO _fathomdb_vector_kinds(kind, profile, created_at)
19460 VALUES('edge_fact', 'default', ?1)",
19461 params![now_unix],
19462 )?;
19463 tx.execute(
19464 "INSERT INTO _fathomdb_projection_state(
19465 kind, last_enqueued_cursor, updated_at
19466 ) VALUES('edge_fact', ?1, 0)
19467 ON CONFLICT(kind) DO UPDATE
19468 SET last_enqueued_cursor = excluded.last_enqueued_cursor",
19469 params![cursor],
19470 )?;
19471 // Do NOT call record_projection_terminal — let the scheduler embed
19472 // the body and mark it terminal after projection.
19473 } else {
19474 record_projection_terminal(tx, cursor, "up_to_date")?;
19475 }
19476 }
19477 Ok(enqueue_vector)
19478}
19479
19480/// F5 (0.8.14 Slice 10, fix-1) — tokenizer for the in-engine BM25F scorer.
19481///
19482/// Tokenizes `text` through the SAME FTS5 tokenizer that `search_index_v2` uses
19483/// for candidate recall (`porter unicode61 remove_diacritics 2`), so the scorer
19484/// measures term-frequency, document-frequency, field length, and average field
19485/// length under the index's own tokenization — porter stemming + unicode61
19486/// case-fold + diacritic folding. The previous implementation hand-rolled a
19487/// second lowercase-alnum splitter; a stemmed/diacritic variant recalled by
19488/// `MATCH` (e.g. query `run` vs indexed `running`, or `cafe` vs `café`) was then
19489/// scored as if the term were absent, so ranking was wrong for exactly those
19490/// variants (codex §9 fix-1 finding 1). Reusing FTS5 itself makes scoring
19491/// tokenization-faithful without re-implementing porter/unicode61 in Rust.
19492///
19493/// Mechanism: round-trip `text` through a temp single-column FTS5 table with the
19494/// identical tokenizer, then read the emitted token instances back via the
19495/// `fts5vocab(..., 'instance')` companion. The token multiset is returned in
19496/// index order (duplicates kept) so callers count tf and field length directly.
19497/// Query terms and every candidate field go through this one path, so all four
19498/// statistics are consistent with each other and with the FTS5 index the scorer
19499/// ranks.
19500fn fts5_tokenize(connection: &Connection, text: &str) -> rusqlite::Result<Vec<String>> {
19501 connection.execute_batch(
19502 "CREATE VIRTUAL TABLE IF NOT EXISTS temp.bm25f_tok
19503 USING fts5(t, tokenize = 'porter unicode61 remove_diacritics 2');
19504 CREATE VIRTUAL TABLE IF NOT EXISTS temp.bm25f_tok_vocab
19505 USING fts5vocab('bm25f_tok', 'instance');
19506 DELETE FROM temp.bm25f_tok;",
19507 )?;
19508 connection.execute("INSERT INTO temp.bm25f_tok(t) VALUES(?1)", params![text])?;
19509 let mut stmt =
19510 connection.prepare("SELECT term FROM temp.bm25f_tok_vocab ORDER BY \"offset\"")?;
19511 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
19512 rows.collect()
19513}
19514
19515/// F5 (0.8.14 Slice 10) — build the FTS5 `MATCH` expression for candidate
19516/// recall from the query's tokens: each token is a double-quoted FTS5 string
19517/// (tokens are FTS5-emitted stems — unicode61 alnum, no embedded quotes),
19518/// OR-joined.
19519fn bm25f_match_expression(terms: &[String]) -> String {
19520 terms.iter().map(|t| format!("\"{t}\"")).collect::<Vec<_>>().join(" OR ")
19521}
19522
19523/// F5 (0.8.14 Slice 10) — the BM25F score for one candidate document.
19524///
19525/// Standard BM25F: per query term, accumulate a length-normalized,
19526/// field-weighted pseudo term-frequency across the fields, then apply the BM25
19527/// saturation once. `norm_f = 1 - b + b*(len_f/avglen_f)` is the per-field
19528/// length normalization (this is where tunable `b` bites); `weight_f` is the
19529/// field boost (this is where the R-F5-1 field weighting bites).
19530fn bm25f_score_doc(
19531 plan: &Bm25fQueryPlan,
19532 query_terms: &[String],
19533 // (weight, doc field length, corpus avg field length, per-term tf in field)
19534 fields: &[(f64, f64, f64, &HashMap<String, u32>)],
19535 doc_count: usize,
19536 df: &HashMap<String, usize>,
19537) -> f64 {
19538 let mut score = 0.0_f64;
19539 for term in query_terms {
19540 let mut weighted_tf = 0.0_f64;
19541 for (weight, len_f, avglen_f, tf_map) in fields {
19542 if *weight == 0.0 || *avglen_f <= 0.0 {
19543 continue;
19544 }
19545 let tf = *tf_map.get(term).unwrap_or(&0) as f64;
19546 if tf == 0.0 {
19547 continue;
19548 }
19549 let norm = 1.0 - plan.b + plan.b * (len_f / avglen_f);
19550 if norm <= 0.0 {
19551 continue;
19552 }
19553 weighted_tf += weight * tf / norm;
19554 }
19555 if weighted_tf <= 0.0 {
19556 continue;
19557 }
19558 let dfq = *df.get(term).unwrap_or(&0);
19559 if dfq == 0 {
19560 continue;
19561 }
19562 let n = doc_count as f64;
19563 let idf = ((n - dfq as f64 + 0.5) / (dfq as f64 + 0.5) + 1.0).ln();
19564 score += idf * (weighted_tf * (plan.k1 + 1.0)) / (plan.k1 + weighted_tf);
19565 }
19566 score
19567}
19568
19569/// F5 (0.8.14 Slice 10) — connection-level implementation of the BM25F lexical
19570/// arm. See [`Engine::bm25f_search`].
19571fn bm25f_search_inner(
19572 connection: &Connection,
19573 query: &str,
19574 plan: &Bm25fQueryPlan,
19575) -> rusqlite::Result<Vec<(u64, f64)>> {
19576 let query_terms: Vec<String> = {
19577 let mut seen = BTreeSet::new();
19578 fts5_tokenize(connection, query)?.into_iter().filter(|t| seen.insert(t.clone())).collect()
19579 };
19580 if query_terms.is_empty() {
19581 return Ok(Vec::new());
19582 }
19583
19584 // Corpus pass over ACTIVE rows (superseded versions excluded): accumulate
19585 // N, total field length per field (for avg field length), and per-term
19586 // document frequency — all under the SAME FTS5 tokenization the index uses.
19587 let mut doc_count: usize = 0;
19588 let mut total_len = [0.0_f64; 3]; // kind, body, status
19589 let mut df: HashMap<String, usize> = HashMap::new();
19590 {
19591 let mut stmt = connection.prepare(
19592 "SELECT v.kind, v.body, v.status
19593 FROM search_index_v2 v
19594 JOIN canonical_nodes cn ON cn.write_cursor = v.write_cursor
19595 WHERE cn.superseded_at IS NULL AND cn.state = 'active'",
19596 )?;
19597 let mut rows = stmt.query([])?;
19598 while let Some(row) = rows.next()? {
19599 let fields =
19600 [row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?];
19601 doc_count += 1;
19602 let mut present: BTreeSet<String> = BTreeSet::new();
19603 for (i, field) in fields.iter().enumerate() {
19604 let toks = fts5_tokenize(connection, field)?;
19605 total_len[i] += toks.len() as f64;
19606 for tok in toks {
19607 if query_terms.contains(&tok) {
19608 present.insert(tok);
19609 }
19610 }
19611 }
19612 for term in present {
19613 *df.entry(term).or_insert(0) += 1;
19614 }
19615 }
19616 }
19617 if doc_count == 0 {
19618 return Ok(Vec::new());
19619 }
19620 let avglen = [
19621 total_len[0] / doc_count as f64,
19622 total_len[1] / doc_count as f64,
19623 total_len[2] / doc_count as f64,
19624 ];
19625
19626 // Active write_cursor set, to filter FTS5 MATCH candidates (search_index_v2
19627 // retains superseded versions, exactly like search_index).
19628 let active: BTreeSet<i64> = {
19629 let mut stmt = connection
19630 .prepare("SELECT write_cursor FROM canonical_nodes WHERE superseded_at IS NULL AND state = 'active'")?;
19631 let rows = stmt.query_map([], |r| r.get::<_, i64>(0))?;
19632 rows.collect::<rusqlite::Result<BTreeSet<i64>>>()?
19633 };
19634
19635 // Candidate recall through the FTS5 index (this is what makes the v2 index
19636 // load-bearing), then score each candidate with the in-engine BM25F.
19637 let match_expr = bm25f_match_expression(&query_terms);
19638 let mut scored: Vec<(u64, f64)> = Vec::new();
19639 {
19640 let mut stmt = connection.prepare(
19641 "SELECT write_cursor, kind, body, status
19642 FROM search_index_v2
19643 WHERE search_index_v2 MATCH ?1",
19644 )?;
19645 let mut rows = stmt.query([match_expr.as_str()])?;
19646 while let Some(row) = rows.next()? {
19647 let wc = row.get::<_, i64>(0)?;
19648 if !active.contains(&wc) {
19649 continue;
19650 }
19651 let kind = row.get::<_, String>(1)?;
19652 let body = row.get::<_, String>(2)?;
19653 let status = row.get::<_, String>(3)?;
19654
19655 let mut tf_kind: HashMap<String, u32> = HashMap::new();
19656 let mut len_kind = 0.0_f64;
19657 for tok in fts5_tokenize(connection, &kind)? {
19658 len_kind += 1.0;
19659 *tf_kind.entry(tok).or_insert(0) += 1;
19660 }
19661 let mut tf_body: HashMap<String, u32> = HashMap::new();
19662 let mut len_body = 0.0_f64;
19663 for tok in fts5_tokenize(connection, &body)? {
19664 len_body += 1.0;
19665 *tf_body.entry(tok).or_insert(0) += 1;
19666 }
19667 let mut tf_status: HashMap<String, u32> = HashMap::new();
19668 let mut len_status = 0.0_f64;
19669 for tok in fts5_tokenize(connection, &status)? {
19670 len_status += 1.0;
19671 *tf_status.entry(tok).or_insert(0) += 1;
19672 }
19673
19674 let fields = [
19675 (plan.weights.kind, len_kind, avglen[0], &tf_kind),
19676 (plan.weights.body, len_body, avglen[1], &tf_body),
19677 (plan.weights.status, len_status, avglen[2], &tf_status),
19678 ];
19679 let score = bm25f_score_doc(plan, &query_terms, &fields, doc_count, &df);
19680 scored.push((wc as u64, score));
19681 }
19682 }
19683
19684 // Descending score; write_cursor ascending as the deterministic tiebreak.
19685 scored.sort_by(|a, b| {
19686 b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal).then(a.0.cmp(&b.0))
19687 });
19688 Ok(scored)
19689}
19690
19691fn commit_batch(
19692 connection: &mut Connection,
19693 batch: &[PreparedWrite],
19694 plans: &[WritePlan],
19695 base_cursor: u64,
19696 provenance_row_cap: u64,
19697) -> rusqlite::Result<u64> {
19698 // 0.8.20 Slice 21a-2 (TC-57) — `BEGIN IMMEDIATE`, not rusqlite's `BEGIN
19699 // DEFERRED` default. Take the WAL write lock AT `BEGIN`, before the
19700 // supersession SELECT below, so this transaction never has to PROMOTE a read
19701 // lock to a write lock.
19702 //
19703 // The defect this closes (characterized in
19704 // `dev/design/0.8.20-tc57-write-race-characterization.md`, repro 10/10 at
19705 // baseline `41a81c17`): for a GOVERNED write (`logical_id: Some`) the first
19706 // statement in this transaction is a read —
19707 // `prior_node_cursors_by_logical_id` — and the second is the supersession
19708 // UPDATE. When the async projection worker holds the write lock on its own
19709 // connection at that instant, SQLite refuses the promotion with plain
19710 // `SQLITE_BUSY` (5) and SKIPS the busy handler entirely, for deadlock
19711 // avoidance (`sqlite3_busy_handler`: "if SQLite determines that invoking the
19712 // busy handler could result in a deadlock, it will go ahead and return
19713 // SQLITE_BUSY"). MEASURED: handler invoked ZERO times, error returned in 0 ms
19714 // against rusqlite's 5 000 ms default timeout. So NO `busy_timeout` value
19715 // could ever have absorbed it, and the caller saw an opaque, un-retryable
19716 // `EngineError::Storage` mid-ingest. The same shape also has a second,
19717 // narrower exit — `SQLITE_BUSY_SNAPSHOT` (517) when the WAL advances past the
19718 // read snapshot — which this closes too, by construction.
19719 //
19720 // UNCONDITIONAL rather than gated on `logical_id`, deliberately: an anonymous
19721 // batch's first statement is already the INSERT below, so it takes the write
19722 // lock essentially immediately anyway and the delta is microseconds, whereas a
19723 // content-dependent transaction behaviour would be a NEW correctness surface
19724 // (mixed batches, edge arms, future write kinds) with a place to be wrong in
19725 // each. MEASURED cost on the anonymous arm: none detectable
19726 // (`tc57_worker_commit_pressure.rs`).
19727 //
19728 // `BEGIN IMMEDIATE` can itself return `SQLITE_BUSY` — but WITH the busy
19729 // handler consulted, i.e. absorbed by the existing 5 s default instead of
19730 // surfaced (pinned by `tc57_mechanism_control_write_first_is_retryable`).
19731 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
19732
19733 for (i, (write, plan)) in batch.iter().zip(plans).enumerate() {
19734 // Per-row cursor: row i gets `base_cursor + i + 1`. See the
19735 // comment in `Engine::write_inner`.
19736 let cursor = base_cursor.saturating_add((i as u64).saturating_add(1));
19737 match (write, plan) {
19738 (
19739 PreparedWrite::Node {
19740 kind,
19741 body,
19742 source_id,
19743 logical_id,
19744 state,
19745 reason,
19746 valid_from,
19747 valid_until,
19748 },
19749 WritePlan::Node,
19750 ) => {
19751 // G0 — supersession is tombstone-then-insert in this same txn:
19752 // mark the prior active version superseded BEFORE inserting the
19753 // new active row, so the partial-unique-active index never sees
19754 // two active rows for one logical_id. Scoped to logical_id ALONE
19755 // (Decision 5, HITL-SIGNED 2026-06-05): a kind-change re-ingest of
19756 // the same logical_id SUPERSEDES, never forks. No-op when logical_id
19757 // is None (legacy/own-identity insert, behavior-identical to 0.7.x).
19758 if let Some(logical_id) = logical_id {
19759 // fix-1 finding 2 [P2]: collect the prior active cursor(s)
19760 // BEFORE tombstoning so we can purge the superseded row's
19761 // row-owned attribute projections and keep the at-rest EAV /
19762 // property-FTS store ACTIVE-ONLY. Without this, a same-session
19763 // property filter / property-FTS saw BOTH the stale and the
19764 // current value until a boot re-derive/reconfigure cleared the
19765 // table — a stale read that violates the active-only invariant.
19766 let prior_g0 = prior_node_cursors_by_logical_id(&tx, logical_id)?;
19767 tx.execute(
19768 "UPDATE canonical_nodes SET superseded_at = ?1
19769 WHERE logical_id = ?2 AND superseded_at IS NULL",
19770 params![cursor, logical_id],
19771 )?;
19772 // Purge only the Attribute + PropertyFts classes: those tables
19773 // have NO `superseded_at IS NULL` read-side filter (the FTS5
19774 // `property_search_index` cannot carry one), so their stale rows
19775 // MUST be deleted at rest. The NodeFts (`search_index` /
19776 // `search_index_v2`) + Vector shadows are left intact — the node
19777 // read path already excludes their superseded rows via the
19778 // `canonical_nodes WHERE superseded_at IS NULL` join, so purging
19779 // them here would be a behaviour change outside this fix's scope.
19780 for sc in &prior_g0 {
19781 purge_row_projections_for_cursor_in(
19782 &tx,
19783 *sc,
19784 &[ProjectionClass::Attribute, ProjectionClass::PropertyFts],
19785 )?;
19786 }
19787 }
19788 // EXP-S (0.8.14 Slice 5, D1) — a `PreparedWrite::Node` is the
19789 // `leaf` structural row_kind (a normal record). coverage/graph
19790 // rows are written via internal paths (row_kind is a SEPARATE
19791 // axis from the doc-type `kind`, and there is no public SDK
19792 // surface for it this release). Writing `leaf` explicitly is
19793 // value-identical to the column DEFAULT.
19794 // OPP-12 Phase-1 (0.8.19 Slice 5) — persist the create-time
19795 // existence state + advisory reason. `InitialState::Active`
19796 // (the default) writes `state = 'active'`, value-identical to the
19797 // migration step-20 column DEFAULT; `Pending` quarantines the node
19798 // out of default retrieval (the `state = 'active'` read exclusion).
19799 // 0.8.20 Slice 15b (TC-34) — persist the world-time validity
19800 // window. A `None` binds SQL NULL, which is what the migration
19801 // step-22 columns already hold for every pre-existing row and what
19802 // `ReadView::validity_sql` reads as UNBOUNDED on that side. So a
19803 // write that omits the window is byte-identical on disk to a
19804 // pre-slice write, and default-view visibility cannot drift.
19805 tx.execute(
19806 "INSERT INTO canonical_nodes(write_cursor, kind, body, source_id, logical_id, row_kind, state, reason, valid_from, valid_until)
19807 VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
19808 params![cursor, kind, body, source_id.as_str(), logical_id, RowKind::Leaf.as_str(), state.as_str(), reason, valid_from, valid_until],
19809 )?;
19810 // EXP-S (D2/D5) — per-row_kind index-target dispatch. For `leaf`
19811 // this is behavior-identical to the pre-EXP-S inline path: FTS
19812 // (sync, in-tx) + vector (async, gated by kind_is_vector_indexed);
19813 // else the cursor is terminated up-front.
19814 // fix-2 [P2]: gate the attribute projection on the create-time
19815 // state. A fresh insert is always non-superseded, so the backfill
19816 // predicate (`state = 'active' AND superseded_at IS NULL`) reduces
19817 // to `state == Active` here. A `Pending` node is quarantined out of
19818 // the canonical read model — its declared attributes must NOT reach
19819 // the property store until a `transition(pending → active)` promotes
19820 // it (which projects them then). Node-FTS / vector shadows are left
19821 // to their read-side lifecycle filter, exactly as for supersession.
19822 project_canonical_node_row(
19823 &tx,
19824 cursor,
19825 kind,
19826 body,
19827 RowKind::Leaf,
19828 ProjectionPass::Write,
19829 matches!(state, InitialState::Active),
19830 )?;
19831 }
19832 (
19833 PreparedWrite::Edge {
19834 kind,
19835 from,
19836 to,
19837 source_id,
19838 logical_id,
19839 body,
19840 t_valid,
19841 t_invalid,
19842 confidence,
19843 extractor_model_id,
19844 temporal_fallback,
19845 },
19846 WritePlan::Edge,
19847 ) => {
19848 // G0 — identical tombstone-then-insert supersession on edges,
19849 // keyed by logical_id ALONE (Decision 5, HITL-SIGNED 2026-06-05;
19850 // edge `kind` is relationship-type, not identity — a kind-change
19851 // re-ingest of the same edge logical_id SUPERSEDES, never forks).
19852 // No-op when logical_id is None.
19853 if let Some(logical_id) = logical_id {
19854 // fix-30 [P2]: collect prior active cursors BEFORE tombstoning
19855 // so stale vector_default rows can be pruned.
19856 let prior_g0 = prior_edge_cursors_by_logical_id(&tx, logical_id)?;
19857 tx.execute(
19858 "UPDATE canonical_edges SET superseded_at = ?1
19859 WHERE logical_id = ?2 AND superseded_at IS NULL",
19860 params![cursor, logical_id],
19861 )?;
19862 for sc in &prior_g0 {
19863 delete_vector_partition_row(&tx, *sc)?;
19864 tx.execute(
19865 "DELETE FROM _fathomdb_vector_rows WHERE write_cursor = ?1",
19866 [sc],
19867 )?;
19868 // fix-32 [P2]: record terminal so advance_projection_cursor
19869 // can walk past this now-superseded cursor.
19870 // TC-45: the token MUST be 'up_to_date', NOT 'superseded'.
19871 // The terminal table (schema step 7) carries
19872 // CHECK(state IN ('failed','up_to_date')) and the writer is
19873 // INSERT OR IGNORE, which SILENTLY SKIPS a CHECK-violating
19874 // row — so 'superseded' was dropped without error and this
19875 // cursor stalled forever (nothing backfills it: the job
19876 // query and the pending-work probe both exclude superseded
19877 // edges). 'up_to_date' is the CHECK-valid, non-'failed'
19878 // terminal and is semantically exact here: the row is
19879 // tombstoned and its vector shadow just deleted, so there is
19880 // no further projection work for this cursor. Same reasoning
19881 // and same token as the step-23 backfill (fix-4, TC-33).
19882 record_projection_terminal(&tx, *sc as u64, "up_to_date")?;
19883 }
19884 }
19885 // G11 — invalidate-not-accumulate: for fact-edges (body IS NOT NULL),
19886 // tombstone any prior active edge on the same (from_id, to_id, kind)
19887 // BEFORE inserting the new row. This is DIFFERENT from the G0
19888 // logical_id tombstone: it is keyed on the triple, not the identity.
19889 // Regular edges (body=None) skip this path — they retain G0 semantics.
19890 if body.is_some() {
19891 // fix-30 [P2]: collect and prune vector shadow for the superseded edge.
19892 let prior_g11 = prior_edge_cursors_by_triple(&tx, from, to, kind)?;
19893 tx.execute(
19894 "UPDATE canonical_edges SET superseded_at = ?1
19895 WHERE from_id = ?2 AND to_id = ?3 AND kind = ?4 AND superseded_at IS NULL",
19896 params![cursor, from, to, kind],
19897 )?;
19898 for sc in &prior_g11 {
19899 delete_vector_partition_row(&tx, *sc)?;
19900 tx.execute(
19901 "DELETE FROM _fathomdb_vector_rows WHERE write_cursor = ?1",
19902 [sc],
19903 )?;
19904 // fix-32 [P2]: mark terminal so projection cursor can advance.
19905 // TC-45: 'up_to_date', NOT 'superseded' — see the identical
19906 // note on the G0 prune loop above. The step-7 CHECK admits
19907 // only ('failed','up_to_date') and INSERT OR IGNORE swallows
19908 // a violating row, so 'superseded' never landed and wedged
19909 // the shared readiness watermark.
19910 record_projection_terminal(&tx, *sc as u64, "up_to_date")?;
19911 }
19912 }
19913 let temporal_fallback_i: Option<i64> =
19914 temporal_fallback.and_then(|f| if f { Some(1) } else { None });
19915 tx.execute(
19916 "INSERT INTO canonical_edges(
19917 write_cursor, kind, from_id, to_id, source_id, logical_id,
19918 body, t_valid, t_invalid, confidence, extractor_model_id,
19919 temporal_fallback
19920 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
19921 params![
19922 cursor,
19923 kind,
19924 from,
19925 to,
19926 source_id.as_str(),
19927 logical_id,
19928 body,
19929 t_valid,
19930 t_invalid,
19931 confidence,
19932 extractor_model_id,
19933 temporal_fallback_i
19934 ],
19935 )?;
19936 // 0.8.20 Slice 5a (R-20-E1, work item 1) — edge projection is no
19937 // longer inlined here: the write path and the rebuild replay
19938 // share ONE projector, so they cannot drift.
19939 project_canonical_edge_row(
19940 &tx,
19941 cursor,
19942 kind,
19943 body.as_deref(),
19944 ProjectionPass::Write,
19945 )?;
19946 }
19947 (
19948 PreparedWrite::AdminSchema { name, kind, schema_json, retention_json },
19949 WritePlan::AdminSchema,
19950 ) => {
19951 tx.execute(
19952 "INSERT INTO operational_collections(
19953 name, kind, schema_json, retention_json, format_version, created_at
19954 ) VALUES(?1, ?2, ?3, ?4, 1, 0)
19955 ON CONFLICT(name) DO UPDATE SET
19956 schema_json = excluded.schema_json,
19957 retention_json = excluded.retention_json",
19958 params![name, kind, schema_json, retention_json],
19959 )?;
19960 record_projection_terminal(&tx, cursor, "up_to_date")?;
19961 }
19962 (
19963 PreparedWrite::OpStore { collection, record_key, schema_id, body },
19964 WritePlan::AppendOnlyLog,
19965 ) => {
19966 tx.execute(
19967 "INSERT INTO operational_mutations(
19968 collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
19969 ) VALUES(?1, ?2, 'append', ?3, ?4, ?5)",
19970 params![collection, record_key, body, schema_id, cursor],
19971 )?;
19972 record_projection_terminal(&tx, cursor, "up_to_date")?;
19973 }
19974 (
19975 PreparedWrite::OpStore { collection, record_key, schema_id, body },
19976 WritePlan::LatestState,
19977 ) => {
19978 tx.execute(
19979 "INSERT INTO operational_state(
19980 collection_name, record_key, payload_json, schema_id, write_cursor
19981 ) VALUES(?1, ?2, ?3, ?4, ?5)
19982 ON CONFLICT(collection_name, record_key) DO UPDATE SET
19983 payload_json = excluded.payload_json,
19984 schema_id = excluded.schema_id,
19985 write_cursor = excluded.write_cursor",
19986 params![collection, record_key, body, schema_id, cursor],
19987 )?;
19988 record_projection_terminal(&tx, cursor, "up_to_date")?;
19989 }
19990 _ => return Err(rusqlite::Error::InvalidQuery),
19991 }
19992 }
19993
19994 // G8 (Slice 20 / F10) — cross-row dangling-edge flag-and-count. This runs
19995 // AFTER the batch loop (so every same-batch node is already on disk in `tx`
19996 // and a same-batch later-inserted endpoint is visible) and BEFORE retention /
19997 // projection-cursor / commit. It is the cross-row reason this lives here and
19998 // not in single-row pre-insert `validate_write`. Default is FLAG-AND-COUNT:
19999 // we only COUNT, never roll back (strict-mode rollback is deferred to
20000 // reserved-gap band 22 — adding a write-options surface is out of scope).
20001 //
20002 // Probe is `logical_id`-alone against the step-12 partial index
20003 // `canonical_nodes_logical_active_idx ON canonical_nodes(logical_id)
20004 // WHERE superseded_at IS NULL` (its leading column + partial predicate), so
20005 // it SEARCHes the index with no SCAN (see `tests/pr_g8_dangling_edges.rs`
20006 // case (f)). There is no node-kind to match: `canonical_edges` stores only
20007 // the edge's own kind, not the endpoint node's kind.
20008 let dangling_edge_endpoints = {
20009 // O(N) pre-pass: record, per `logical_id`, the LAST (highest) index at
20010 // which an `Edge { logical_id: Some(_), .. }` with that id appears. Keyed
20011 // by `logical_id` ALONE (Decision 5, HITL-SIGNED 2026-06-05) to match the
20012 // supersession UPDATE, which keys by logical_id alone: a kind-change
20013 // re-ingest of the same edge logical_id SUPERSEDES the earlier one.
20014 // Iterating front-to-back and overwriting means the stored value ends up
20015 // as the final index for each id. An edge at index `i` with that id is
20016 // then in-batch-superseded iff `last_index[lid] > i`. This is
20017 // behavior-identical to the prior per-edge `batch[i+1..]` `.any(..)` scan
20018 // (which was O(N²) under the single-writer txn) — same skip-set, same count.
20019 let mut last_index: HashMap<&str, usize> = HashMap::new();
20020 for (i, write) in batch.iter().enumerate() {
20021 if let PreparedWrite::Edge { logical_id: Some(lid), .. } = write {
20022 last_index.insert(lid.as_str(), i);
20023 }
20024 }
20025
20026 let mut probe = tx.prepare(
20027 "SELECT 1 FROM canonical_nodes WHERE logical_id = ?1 AND superseded_at IS NULL LIMIT 1",
20028 )?;
20029 let mut count: u64 = 0;
20030 for (i, write) in batch.iter().enumerate() {
20031 if let PreparedWrite::Edge { from, to, logical_id, .. } = write {
20032 // Honor `edge.superseded_at IS NULL`: an edge inserted in this
20033 // batch is active unless a LATER same-batch edge with the same
20034 // `Some(logical_id)` tombstoned it (the loop's supersession
20035 // UPDATE). Skip such an in-batch-superseded edge. Edges with
20036 // `logical_id: None` are never superseded-in-batch.
20037 if let Some(lid) = logical_id {
20038 let superseded_in_batch =
20039 last_index.get(lid.as_str()).is_some_and(|&last| last > i);
20040 if superseded_in_batch {
20041 continue;
20042 }
20043 }
20044 // Probe `from_id` and `to_id` independently (0, 1, or 2 per edge).
20045 for endpoint in [from, to] {
20046 if !probe.exists(params![endpoint])? {
20047 count = count.saturating_add(1);
20048 }
20049 }
20050 }
20051 }
20052 count
20053 };
20054
20055 enforce_provenance_retention(&tx, provenance_row_cap)?;
20056 advance_projection_cursor(&tx)?;
20057
20058 tx.commit()?;
20059 Ok(dangling_edge_endpoints)
20060}
20061
20062fn load_next_cursor(connection: &Connection) -> u64 {
20063 let nodes = max_cursor(connection, "canonical_nodes").unwrap_or(0);
20064 let edges = max_cursor(connection, "canonical_edges").unwrap_or(0);
20065 let mutations = max_cursor(connection, "operational_mutations").unwrap_or(0);
20066 let state = max_cursor(connection, "operational_state").unwrap_or(0);
20067 // TC-33: schema step 23 RECREATES `canonical_edges` (no data migration), so
20068 // the edge rows that used to hold the high-water mark are gone. Without this
20069 // term the allocator can hand out a cursor a PREVIOUS edge already used —
20070 // and stale `_fathomdb_projection_terminal` / `_fathomdb_vector_rows` / vec0
20071 // rows still key on it, so a brand-new row would be treated as
20072 // already-projected and never get indexed. Step 23 stashes the pre-drop
20073 // maximum here; folding it in keeps cursors monotonic across the migration.
20074 let reserved = reserved_write_cursor(connection);
20075 nodes.max(edges).max(mutations).max(state).max(reserved)
20076}
20077
20078/// The write-cursor high-water mark reserved by schema step 23, or 0 when the
20079/// key is absent (fresh DB, or a DB that never had edges). Never fails the
20080/// caller: a missing/unparseable value degrades to 0, which is the pre-TC-33
20081/// behaviour.
20082fn reserved_write_cursor(connection: &Connection) -> u64 {
20083 connection
20084 .query_row(
20085 "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
20086 params![fathomdb_schema::RESERVED_WRITE_CURSOR_KEY],
20087 |row| row.get::<_, String>(0),
20088 )
20089 .ok()
20090 .and_then(|raw| raw.parse::<u64>().ok())
20091 .unwrap_or(0)
20092}
20093
20094fn max_cursor(connection: &Connection, table: &str) -> rusqlite::Result<u64> {
20095 let sql = format!("SELECT COALESCE(MAX(write_cursor), 0) FROM {table}");
20096 connection.query_row(&sql, [], |row| row.get::<_, u64>(0))
20097}
20098
20099/// Map a rusqlite error to its stable SQLite extended-code name.
20100///
20101/// Returns `None` for non-`SqliteFailure` variants (e.g. JSON conversion
20102/// failures, type mismatches at the rusqlite layer) — those are not
20103/// SQLite-internal events and should not be surfaced under
20104/// `EventSource::SqliteInternal`. The names returned here are the
20105/// canonical `SQLITE_*` symbol names from `sqlite3.h` and are stable
20106/// dispatch keys for AC-021 / AC-006 binding adapters.
20107///
20108/// Only the subset of codes the engine can reach in 0.6.0 is enumerated
20109/// — bare-extended-code matching covers the rest with a stable
20110/// `"SQLITE_UNKNOWN"` fallback so subscribers always see a typed code.
20111///
20112/// Diagnostic completeness for unmapped codes — **corrected 0.8.20 Slice 21a-2
20113/// (TC-57)**. This comment used to claim that when the helper returns
20114/// `"SQLITE_UNKNOWN"` the numeric extended code "is not lost — it remains on the
20115/// underlying `rusqlite::Error::SqliteFailure` carried in the engine error chain
20116/// that subscribers can inspect via `EngineError`'s `source()`". **That is
20117/// false.** There is no such chain: `EngineError::Storage` is a UNIT variant with
20118/// no payload and no `source()`, and `write_inner` drops the `rusqlite::Error`
20119/// immediately after emitting the lifecycle event. So for an unmapped code the
20120/// numeric value IS lost, and the only signal a host receives is the string
20121/// `"SQLITE_UNKNOWN"`.
20122///
20123/// Concretely: `SQLITE_BUSY_SNAPSHOT` (517) matches none of the PRIMARY constants
20124/// below — the match is on the EXTENDED value — so it reaches subscribers as
20125/// `"SQLITE_UNKNOWN"` and is unrecoverable from the public API. Restructuring the
20126/// error path so busy codes are distinguishable (and surfacing the numeric code as
20127/// a typed payload field) is candidate R2 of
20128/// `dev/design/0.8.20-tc57-write-race-characterization.md` §7, explicitly OUT of
20129/// scope for the 21a-2 fix and recorded here rather than silently carried.
20130fn sqlite_extended_code_name(err: &rusqlite::Error) -> Option<&'static str> {
20131 let sqlite_error = err.sqlite_error()?;
20132 let extended = sqlite_error.extended_code;
20133 Some(match extended {
20134 rusqlite::ffi::SQLITE_SCHEMA => "SQLITE_SCHEMA",
20135 rusqlite::ffi::SQLITE_BUSY => "SQLITE_BUSY",
20136 rusqlite::ffi::SQLITE_LOCKED => "SQLITE_LOCKED",
20137 rusqlite::ffi::SQLITE_CORRUPT => "SQLITE_CORRUPT",
20138 rusqlite::ffi::SQLITE_NOTADB => "SQLITE_NOTADB",
20139 rusqlite::ffi::SQLITE_IOERR => "SQLITE_IOERR",
20140 rusqlite::ffi::SQLITE_FULL => "SQLITE_FULL",
20141 rusqlite::ffi::SQLITE_READONLY => "SQLITE_READONLY",
20142 rusqlite::ffi::SQLITE_CONSTRAINT => "SQLITE_CONSTRAINT",
20143 rusqlite::ffi::SQLITE_MISUSE => "SQLITE_MISUSE",
20144 rusqlite::ffi::SQLITE_INTERRUPT => "SQLITE_INTERRUPT",
20145 rusqlite::ffi::SQLITE_NOMEM => "SQLITE_NOMEM",
20146 rusqlite::ffi::SQLITE_PERM => "SQLITE_PERM",
20147 rusqlite::ffi::SQLITE_ABORT => "SQLITE_ABORT",
20148 rusqlite::ffi::SQLITE_PROTOCOL => "SQLITE_PROTOCOL",
20149 rusqlite::ffi::SQLITE_RANGE => "SQLITE_RANGE",
20150 rusqlite::ffi::SQLITE_TOOBIG => "SQLITE_TOOBIG",
20151 rusqlite::ffi::SQLITE_MISMATCH => "SQLITE_MISMATCH",
20152 rusqlite::ffi::SQLITE_AUTH => "SQLITE_AUTH",
20153 rusqlite::ffi::SQLITE_NOTFOUND => "SQLITE_NOTFOUND",
20154 rusqlite::ffi::SQLITE_CANTOPEN => "SQLITE_CANTOPEN",
20155 _ => "SQLITE_UNKNOWN",
20156 })
20157}
20158
20159fn sqlite_extended_code_name_from_int(extended: i32) -> &'static str {
20160 match extended {
20161 rusqlite::ffi::SQLITE_SCHEMA => "SQLITE_SCHEMA",
20162 rusqlite::ffi::SQLITE_BUSY => "SQLITE_BUSY",
20163 rusqlite::ffi::SQLITE_LOCKED => "SQLITE_LOCKED",
20164 rusqlite::ffi::SQLITE_CORRUPT => "SQLITE_CORRUPT",
20165 rusqlite::ffi::SQLITE_NOTADB => "SQLITE_NOTADB",
20166 rusqlite::ffi::SQLITE_IOERR => "SQLITE_IOERR",
20167 rusqlite::ffi::SQLITE_FULL => "SQLITE_FULL",
20168 rusqlite::ffi::SQLITE_READONLY => "SQLITE_READONLY",
20169 rusqlite::ffi::SQLITE_CONSTRAINT => "SQLITE_CONSTRAINT",
20170 rusqlite::ffi::SQLITE_MISUSE => "SQLITE_MISUSE",
20171 rusqlite::ffi::SQLITE_INTERRUPT => "SQLITE_INTERRUPT",
20172 rusqlite::ffi::SQLITE_NOMEM => "SQLITE_NOMEM",
20173 rusqlite::ffi::SQLITE_PERM => "SQLITE_PERM",
20174 rusqlite::ffi::SQLITE_ABORT => "SQLITE_ABORT",
20175 rusqlite::ffi::SQLITE_PROTOCOL => "SQLITE_PROTOCOL",
20176 rusqlite::ffi::SQLITE_RANGE => "SQLITE_RANGE",
20177 rusqlite::ffi::SQLITE_TOOBIG => "SQLITE_TOOBIG",
20178 rusqlite::ffi::SQLITE_MISMATCH => "SQLITE_MISMATCH",
20179 rusqlite::ffi::SQLITE_AUTH => "SQLITE_AUTH",
20180 rusqlite::ffi::SQLITE_NOTFOUND => "SQLITE_NOTFOUND",
20181 rusqlite::ffi::SQLITE_CANTOPEN => "SQLITE_CANTOPEN",
20182 _ => "SQLITE_UNKNOWN",
20183 }
20184}
20185
20186fn map_open_sqlite_error(err: rusqlite::Error, stage: OpenStage) -> EngineOpenError {
20187 let Some(sqlite_error) = err.sqlite_error() else {
20188 return EngineOpenError::Io { message: "could not open database".to_string() };
20189 };
20190 match sqlite_error.extended_code {
20191 rusqlite::ffi::SQLITE_CORRUPT | rusqlite::ffi::SQLITE_NOTADB => {
20192 EngineOpenError::Corruption(CorruptionDetail {
20193 kind: match stage {
20194 OpenStage::WalReplay => CorruptionKind::WalReplayFailure,
20195 OpenStage::HeaderProbe => CorruptionKind::HeaderMalformed,
20196 OpenStage::SchemaProbe => CorruptionKind::SchemaInconsistent,
20197 OpenStage::EmbedderIdentity => CorruptionKind::EmbedderIdentityDrift,
20198 },
20199 stage,
20200 locator: CorruptionLocator::OpaqueSqliteError {
20201 sqlite_extended_code: sqlite_error.extended_code,
20202 },
20203 recovery_hint: RecoveryHint {
20204 code: match stage {
20205 OpenStage::WalReplay => "E_CORRUPT_WAL_REPLAY",
20206 OpenStage::HeaderProbe => "E_CORRUPT_HEADER",
20207 OpenStage::SchemaProbe => "E_CORRUPT_SCHEMA",
20208 OpenStage::EmbedderIdentity => "E_CORRUPT_EMBEDDER_IDENTITY",
20209 },
20210 doc_anchor: match stage {
20211 OpenStage::WalReplay => "design/recovery.md#wal-replay-failures",
20212 OpenStage::HeaderProbe => "design/recovery.md#header-malformed",
20213 OpenStage::SchemaProbe => "design/recovery.md#schema-inconsistent",
20214 OpenStage::EmbedderIdentity => "design/recovery.md#embedder-identity-drift",
20215 },
20216 },
20217 })
20218 }
20219 _ => EngineOpenError::Io { message: "could not open database".to_string() },
20220 }
20221}
20222
20223fn emit_open_error_event(subscriber: &Arc<dyn lifecycle::Subscriber>, err: &EngineOpenError) {
20224 if let EngineOpenError::Corruption(detail) = err {
20225 let code = match detail.locator {
20226 CorruptionLocator::OpaqueSqliteError { sqlite_extended_code } => {
20227 Some(sqlite_extended_code_name_from_int(sqlite_extended_code))
20228 }
20229 _ => None,
20230 };
20231 let event = lifecycle::Event {
20232 phase: lifecycle::Phase::Failed,
20233 source: lifecycle::EventSource::SqliteInternal,
20234 category: lifecycle::EventCategory::Corruption,
20235 code,
20236 };
20237 subscriber.on_event(&event);
20238 }
20239}
20240
20241/// Install a `sqlite3_profile` callback on `connection` that dispatches
20242/// per-statement profile records and slow-statement signals to the
20243/// engine's subscriber registry.
20244///
20245/// Why FFI rather than `rusqlite::Connection::profile`: the safe API
20246/// (rusqlite 0.31) accepts only a `fn(&str, Duration)` with no
20247/// environment, so it cannot carry a per-engine subscriber-registry
20248/// pointer. We use `sqlite3_profile` directly with a leaked-into-`Box`
20249/// context whose pointer is tied to the engine's lifetime via
20250/// `Engine::profile_contexts`.
20251///
20252/// `sqlite3_profile` is documented as deprecated in favor of
20253/// `sqlite3_trace_v2`, but it remains supported and is sufficient for
20254/// the wall-clock + SQL-text payload required by AC-005a/b.
20255#[allow(clippy::vec_box)]
20256fn install_profile_callback(
20257 connection: &Connection,
20258 subscribers: &Arc<lifecycle::SubscriberRegistry>,
20259 profiling_enabled: &Arc<AtomicBool>,
20260 slow_threshold_ms: &Arc<AtomicU64>,
20261 contexts: &mut Vec<Box<ProfileContext>>,
20262) {
20263 let mut ctx = Box::new(ProfileContext {
20264 subscribers: Arc::clone(subscribers),
20265 profiling_enabled: Arc::clone(profiling_enabled),
20266 slow_threshold_ms: Arc::clone(slow_threshold_ms),
20267 });
20268 let ctx_ptr: *mut ProfileContext = &mut *ctx;
20269
20270 // SAFETY: the Box outlives the connection. Rust drops struct fields
20271 // in declaration order. `connection` and `reader_pool` are declared
20272 // before `profile_contexts`. `ReaderWorkerPool::Drop` joins every
20273 // reader worker, and each worker uninstalls and drops its owned
20274 // connection inside `reader_worker_loop` before the worker thread
20275 // returns. Therefore all connections — and SQLite's internal
20276 // profile-callback state with them — are torn down before the
20277 // `Box<ProfileContext>` allocations are freed. `Engine::close`
20278 // additionally clears the callback via
20279 // `sqlite3_profile(handle, None, NULL)` before connection close to
20280 // drain any in-flight callback dispatch.
20281 unsafe {
20282 rusqlite::ffi::sqlite3_profile(
20283 connection.handle(),
20284 Some(profile_callback_trampoline),
20285 ctx_ptr.cast::<std::ffi::c_void>(),
20286 );
20287 }
20288 contexts.push(ctx);
20289}
20290
20291/// Uninstall the profile callback so SQLite stops calling into our
20292/// freed `Box<ProfileContext>` pointer once a connection is being torn
20293/// down. Call before dropping `profile_contexts`.
20294fn uninstall_profile_callback(connection: &Connection) {
20295 // SAFETY: passing `None` as the callback unregisters the previous
20296 // callback; SQLite documents this as legal and idempotent.
20297 unsafe {
20298 rusqlite::ffi::sqlite3_profile(connection.handle(), None, std::ptr::null_mut());
20299 }
20300}
20301
20302/// Pack 6.G G.1 — configure SQLite per-connection lookaside on a reader
20303/// worker connection. Must be called BEFORE any statement is prepared
20304/// or any PRAGMA is run on `connection`; per the SQLite docs
20305/// (https://www.sqlite.org/malloc.html §3) lookaside is silently
20306/// ignored if reconfigured after the first allocation on the
20307/// connection. Passing `NULL` for the buffer pointer lets SQLite
20308/// allocate the lookaside backing memory itself.
20309///
20310/// rusqlite 0.31's `set_db_config` only handles the boolean
20311/// `DbConfig::*` variants; `SQLITE_DBCONFIG_LOOKASIDE` is not surfaced
20312/// (it is commented out in `rusqlite/src/config.rs`), so we call the
20313/// raw FFI directly.
20314///
20315/// Returns the rc of `sqlite3_db_config` so callers can debug-assert
20316/// `SQLITE_OK` and surface configuration failure under
20317/// `debug_assertions` test builds without expanding the public surface.
20318/// 0.7.0 perf-experiments hook: apply caller-supplied reader PRAGMAs
20319/// from the `FATHOMDB_PERF_READER_PRAGMAS` env var. Format:
20320/// comma-separated `name=value` pairs (e.g.
20321/// `cache_size=-262144,mmap_size=268435456,temp_store=MEMORY`).
20322///
20323/// **Gated on `FATHOMDB_PERF_EXPERIMENTS=1`.** No-op if the gate env
20324/// var is unset, so production paths are never affected. Failures to
20325/// apply individual PRAGMAs are logged to stderr (via `eprintln!`) but
20326/// do not error the connection open — experiments are best-effort,
20327/// not contract.
20328///
20329/// Scope: 0.7.0 perf-experiment campaign per
20330/// `dev/plans/0.7.0-perf-experiments.md`. Once Wave 5 picks the
20331/// landing combination, the chosen PRAGMAs are hardcoded as the new
20332/// reader-open default and this hook is removed.
20333/// 0.7.0 perf-experiments hook: apply writer-side PRAGMAs from
20334/// `FATHOMDB_PERF_WRITER_PRAGMAS` (same format as reader hook).
20335/// **Runs BEFORE migrations** so PRAGMAs like `page_size` that must
20336/// precede any table creation take effect on a fresh DB.
20337///
20338/// Gated on `FATHOMDB_PERF_EXPERIMENTS=1`. No-op otherwise.
20339fn apply_perf_experiment_writer_pragmas(connection: &Connection) {
20340 if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
20341 return;
20342 }
20343 let raw = match std::env::var("FATHOMDB_PERF_WRITER_PRAGMAS") {
20344 Ok(s) if !s.is_empty() => s,
20345 _ => return,
20346 };
20347 for entry in raw.split(',') {
20348 let entry = entry.trim();
20349 if entry.is_empty() {
20350 continue;
20351 }
20352 let (name, value) = match entry.split_once('=') {
20353 Some((n, v)) => (n.trim(), v.trim()),
20354 None => {
20355 eprintln!("perf-experiment: bad writer pragma entry (expect name=value): {entry}");
20356 continue;
20357 }
20358 };
20359 if name.is_empty() {
20360 eprintln!("perf-experiment: empty pragma name in writer entry: {entry}");
20361 continue;
20362 }
20363 match connection.pragma_update(None, name, value) {
20364 Ok(()) => {
20365 eprintln!(
20366 "perf-experiment: applied PRAGMA {name}={value} on writer (pre-migration)"
20367 );
20368 }
20369 Err(err) => {
20370 eprintln!("perf-experiment: writer PRAGMA {name}={value} failed: {err}");
20371 }
20372 }
20373 }
20374}
20375
20376fn apply_perf_experiment_reader_pragmas(connection: &Connection) {
20377 if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
20378 return;
20379 }
20380 let raw = match std::env::var("FATHOMDB_PERF_READER_PRAGMAS") {
20381 Ok(s) if !s.is_empty() => s,
20382 _ => return,
20383 };
20384 for entry in raw.split(',') {
20385 let entry = entry.trim();
20386 if entry.is_empty() {
20387 continue;
20388 }
20389 let (name, value) = match entry.split_once('=') {
20390 Some((n, v)) => (n.trim(), v.trim()),
20391 None => {
20392 eprintln!("perf-experiment: bad pragma entry (expect name=value): {entry}");
20393 continue;
20394 }
20395 };
20396 if name.is_empty() {
20397 eprintln!("perf-experiment: empty pragma name in entry: {entry}");
20398 continue;
20399 }
20400 match connection.pragma_update(None, name, value) {
20401 Ok(()) => {
20402 eprintln!("perf-experiment: applied PRAGMA {name}={value} on reader");
20403 }
20404 Err(err) => {
20405 eprintln!("perf-experiment: PRAGMA {name}={value} failed: {err}");
20406 }
20407 }
20408 }
20409}
20410
20411fn configure_reader_lookaside(connection: &Connection) -> std::os::raw::c_int {
20412 // SAFETY: `connection.handle()` returns a valid `*mut sqlite3` for
20413 // the lifetime of `connection`. The variadic
20414 // `sqlite3_db_config(LOOKASIDE)` call expects three trailing
20415 // arguments of types `void*`, `int`, `int` — the prototype shape
20416 // documented in `sqlite3.h`. We pass a null buffer so SQLite owns
20417 // the lookaside backing allocation, and the slot size / count from
20418 // the G.1 constants. No allocations happen on the connection
20419 // before this call (reader open path is `Connection::open` ->
20420 // `configure_reader_lookaside` -> first PRAGMA).
20421 unsafe {
20422 rusqlite::ffi::sqlite3_db_config(
20423 connection.handle(),
20424 rusqlite::ffi::SQLITE_DBCONFIG_LOOKASIDE,
20425 std::ptr::null_mut::<std::ffi::c_void>(),
20426 READER_LOOKASIDE_SLOT_SIZE,
20427 READER_LOOKASIDE_SLOT_COUNT,
20428 )
20429 }
20430}
20431
20432/// Read the high-water-mark for `SQLITE_DBSTATUS_LOOKASIDE_USED` on
20433/// `connection`. The `current` out-param is the live checked-out slot
20434/// count and decays as transactions finalize, so it is unreliable as
20435/// post-warmup evidence. The `hiwtr` out-param latches the largest
20436/// observed `current` value since the last reset and is the right
20437/// signal that lookaside was honored at any point on this connection.
20438/// Reset flag is `0` so reading does not clear the high-water mark.
20439#[cfg(debug_assertions)]
20440fn read_lookaside_used_hiwtr(connection: &Connection) -> std::os::raw::c_int {
20441 let mut current: std::os::raw::c_int = 0;
20442 let mut hiwtr: std::os::raw::c_int = 0;
20443 // SAFETY: handle is valid; both out pointers are to local stack
20444 // ints; reset flag 0 is documented as legal.
20445 unsafe {
20446 rusqlite::ffi::sqlite3_db_status(
20447 connection.handle(),
20448 rusqlite::ffi::SQLITE_DBSTATUS_LOOKASIDE_USED,
20449 &mut current,
20450 &mut hiwtr,
20451 0,
20452 );
20453 }
20454 hiwtr
20455}
20456
20457/// Pack 6.G G.3.5 — read the three page-cache pressure counters on
20458/// `connection`: `SQLITE_DBSTATUS_CACHE_HIT`, `_CACHE_MISS`, and
20459/// `_CACHE_USED`. Returns `(hit, miss, used_bytes)`. Hit/miss are
20460/// monotonic counters (reset flag = 0 here); used_bytes is the live
20461/// page-cache memory footprint at call time. The caller is expected to
20462/// take pre/post snapshots and do delta arithmetic explicitly.
20463#[cfg(debug_assertions)]
20464fn read_cache_status(
20465 connection: &Connection,
20466) -> (std::os::raw::c_int, std::os::raw::c_int, std::os::raw::c_int) {
20467 let mut hit_current: std::os::raw::c_int = 0;
20468 let mut hit_hiwtr: std::os::raw::c_int = 0;
20469 let mut miss_current: std::os::raw::c_int = 0;
20470 let mut miss_hiwtr: std::os::raw::c_int = 0;
20471 let mut used_current: std::os::raw::c_int = 0;
20472 let mut used_hiwtr: std::os::raw::c_int = 0;
20473 // SAFETY: `connection.handle()` returns a valid `*mut sqlite3` for
20474 // the lifetime of `connection`. All out-pointers are to local stack
20475 // ints. Reset flag 0 is documented as legal (no counter is reset).
20476 unsafe {
20477 rusqlite::ffi::sqlite3_db_status(
20478 connection.handle(),
20479 rusqlite::ffi::SQLITE_DBSTATUS_CACHE_HIT,
20480 &mut hit_current,
20481 &mut hit_hiwtr,
20482 0,
20483 );
20484 rusqlite::ffi::sqlite3_db_status(
20485 connection.handle(),
20486 rusqlite::ffi::SQLITE_DBSTATUS_CACHE_MISS,
20487 &mut miss_current,
20488 &mut miss_hiwtr,
20489 0,
20490 );
20491 rusqlite::ffi::sqlite3_db_status(
20492 connection.handle(),
20493 rusqlite::ffi::SQLITE_DBSTATUS_CACHE_USED,
20494 &mut used_current,
20495 &mut used_hiwtr,
20496 0,
20497 );
20498 }
20499 // CACHE_HIT / CACHE_MISS are monotonic counters reported in the
20500 // `current` out-param; CACHE_USED is the live byte count, also in
20501 // `current`. The hiwtr values are unused for this telemetry.
20502 (hit_current, miss_current, used_current)
20503}
20504
20505/// FFI trampoline for `sqlite3_profile`.
20506///
20507/// Invoked by SQLite at statement-finish with the SQL text and the
20508/// statement's wall-clock cost in nanoseconds. We dispatch a
20509/// `ProfileRecord` (when profiling is enabled) and a `SlowStatement`
20510/// signal (when `wall_clock_ms` exceeds the configured slow threshold).
20511///
20512/// Per `dev/design/lifecycle.md` § Public record shape, the public
20513/// payload exposes `wall_clock_ms`, `step_count`, and `cache_delta`.
20514/// `sqlite3_profile` does not surface per-statement step counts or
20515/// cache-hit deltas in its callback; we emit `0` for those fields and
20516/// document the hazard. AC-005b requires the fields be typed numeric,
20517/// not that they carry non-zero values for every backend.
20518unsafe extern "C" fn profile_callback_trampoline(
20519 user_data: *mut std::ffi::c_void,
20520 sql: *const std::os::raw::c_char,
20521 nanoseconds: u64,
20522) {
20523 if user_data.is_null() || sql.is_null() {
20524 return;
20525 }
20526 let ctx = unsafe { &*(user_data.cast::<ProfileContext>()) };
20527 let sql_text = match unsafe { std::ffi::CStr::from_ptr(sql) }.to_str() {
20528 Ok(s) => s,
20529 Err(_) => return,
20530 };
20531
20532 let wall_clock_ms = nanoseconds / 1_000_000;
20533
20534 if ctx.profiling_enabled.load(Ordering::Relaxed) {
20535 let record = lifecycle::ProfileRecord {
20536 wall_clock_ms,
20537 // step_count / cache_delta are not surfaced by
20538 // sqlite3_profile; placeholder 0 satisfies AC-005b's
20539 // "typed numeric" contract. A future profiling refactor
20540 // around sqlite3_stmt_status + sqlite3_db_status would
20541 // populate them with non-zero deltas.
20542 step_count: 0,
20543 cache_delta: 0,
20544 };
20545 ctx.subscribers.dispatch_profile(&record);
20546 }
20547
20548 let threshold = ctx.slow_threshold_ms.load(Ordering::Relaxed);
20549 if wall_clock_ms > threshold {
20550 let signal = lifecycle::SlowStatement { statement: sql_text.to_string(), wall_clock_ms };
20551 ctx.subscribers.dispatch_slow_statement(&signal);
20552 }
20553}
20554
20555#[cfg(test)]
20556mod tests {
20557 use super::{
20558 derive_stable_id, resolve_source_type, Engine, IdSpace, IdSpaceKind, PreparedWrite,
20559 KIND_TO_SOURCE_TYPE_CASE_SQL, ROW_OWNED_PROJECTIONS,
20560 };
20561 use rusqlite::Connection;
20562 use tempfile::TempDir;
20563
20564 /// 0.8.20 Slice 5a (R-20-E1, work item 2) — the registry GUARD.
20565 ///
20566 /// Introspects `sqlite_master` on a freshly migrated database and asserts
20567 /// that EVERY `write_cursor`-keyed table is accounted for: either it is a
20568 /// registered row-owned projection, or it is one of the explicitly named
20569 /// canonical / operational tables that are sources of truth, not shadows.
20570 /// A future projection table therefore cannot be added without either
20571 /// registering it in [`ROW_OWNED_PROJECTIONS`] (making it erasable at every
20572 /// maintenance site at once) or consciously failing this test.
20573 ///
20574 /// **`_fathomdb_projection_state` is allowlisted as KIND-owned** (design v5
20575 /// §1.1): it is keyed by `kind`, not by `write_cursor`, and holds a per-kind
20576 /// enqueue watermark. Erasing one row must not rewind a whole kind's
20577 /// watermark, so it must NEVER be deleted per-cursor. The test asserts both
20578 /// halves of that claim — that it carries no `write_cursor` column, and that
20579 /// it is absent from the row-owned registry.
20580 #[test]
20581 fn guard_row_owned_registry() {
20582 /// Canonical + operational tables: `write_cursor`-carrying SOURCES OF
20583 /// TRUTH, never row-owned projections of another row.
20584 const NON_PROJECTION_CURSOR_TABLES: &[&str] =
20585 &["canonical_nodes", "canonical_edges", "operational_mutations", "operational_state"];
20586
20587 let dir = TempDir::new().unwrap();
20588 let path = dir.path().join("registry_guard.fathomdb");
20589 Engine::open(&path).expect("open").engine.close().expect("close");
20590 let conn = Connection::open(&path).expect("open sqlite");
20591
20592 let table_names: Vec<String> = conn
20593 .prepare(
20594 "SELECT name FROM sqlite_master
20595 WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
20596 )
20597 .expect("prepare")
20598 .query_map([], |row| row.get::<_, String>(0))
20599 .expect("query")
20600 .collect::<rusqlite::Result<Vec<_>>>()
20601 .expect("collect");
20602 assert!(table_names.len() > 5, "sqlite_master introspection returned nothing useful");
20603
20604 let has_write_cursor = |table: &str| -> bool {
20605 conn.prepare(&format!("PRAGMA table_info({table})"))
20606 .and_then(|mut stmt| {
20607 let names = stmt
20608 .query_map([], |row| row.get::<_, String>(1))?
20609 .collect::<rusqlite::Result<Vec<_>>>()?;
20610 Ok(names.iter().any(|n| n == "write_cursor"))
20611 })
20612 .unwrap_or(false)
20613 };
20614
20615 let registered: Vec<&str> = ROW_OWNED_PROJECTIONS.iter().map(|p| p.table).collect();
20616
20617 // (1) Every write_cursor-keyed table is registered or explicitly excused.
20618 for table in &table_names {
20619 if !has_write_cursor(table) {
20620 continue;
20621 }
20622 assert!(
20623 registered.contains(&table.as_str())
20624 || NON_PROJECTION_CURSOR_TABLES.contains(&table.as_str()),
20625 "table `{table}` is keyed by write_cursor but is neither registered in \
20626 ROW_OWNED_PROJECTIONS nor listed as a non-projection source of truth. \
20627 If it is a projection, register it — otherwise erasure will leave its \
20628 rows on disk (the `search_index_v2` defect)."
20629 );
20630 }
20631
20632 // (2) Every registered projection actually exists and is erasable by its
20633 // declared cursor column (vec0's `rowid` included).
20634 for projection in ROW_OWNED_PROJECTIONS {
20635 assert!(
20636 table_names.iter().any(|t| t == projection.table),
20637 "registered projection `{}` does not exist in the schema",
20638 projection.table
20639 );
20640 conn.query_row(
20641 &format!(
20642 "SELECT COUNT(*) FROM {} WHERE {} = 0",
20643 projection.table, projection.cursor_column
20644 ),
20645 [],
20646 |row| row.get::<_, u64>(0),
20647 )
20648 .unwrap_or_else(|err| {
20649 panic!(
20650 "registered projection `{}` is not erasable by `{}`: {err}",
20651 projection.table, projection.cursor_column
20652 )
20653 });
20654 }
20655
20656 // (3) `_fathomdb_projection_state` is KIND-owned, not row-owned.
20657 assert!(
20658 !has_write_cursor("_fathomdb_projection_state"),
20659 "_fathomdb_projection_state gained a write_cursor column — re-decide its ownership \
20660 class before treating it as kind-owned"
20661 );
20662 assert!(
20663 !registered.contains(&"_fathomdb_projection_state"),
20664 "_fathomdb_projection_state is KIND-owned (per-kind enqueue watermark) and must \
20665 never be deleted per-cursor: erasing one row would rewind a whole kind's watermark"
20666 );
20667 }
20668
20669 /// 0.8.20 Slice 15d (R-20-EAV) — PROVE THE GUARD BITES. The two net-new
20670 /// content-storing projection tables (`canonical_attributes`,
20671 /// `property_search_index`) are `write_cursor`-keyed and hold attribute
20672 /// values at rest. This test asserts (1) they ARE registered in
20673 /// `ROW_OWNED_PROJECTIONS` (so `erase_row_projections` reaches them), and (2)
20674 /// the guard's core predicate — "registered OR a named source of truth" —
20675 /// FAILS for either table if it is (hypothetically) removed from the
20676 /// registry. This is what makes forgetting to register a future
20677 /// content-storing projection a red test, not a silent erasure leak.
20678 #[test]
20679 fn slice15d_attribute_projections_registered_and_guard_bites() {
20680 const NON_PROJECTION_CURSOR_TABLES: &[&str] =
20681 &["canonical_nodes", "canonical_edges", "operational_mutations", "operational_state"];
20682
20683 let registered: Vec<&str> = ROW_OWNED_PROJECTIONS.iter().map(|p| p.table).collect();
20684
20685 // (1) Both new content-storing projections are registered as row-owned.
20686 for table in ["canonical_attributes", "property_search_index"] {
20687 assert!(
20688 registered.contains(&table),
20689 "{table} holds attribute values at rest and MUST be in ROW_OWNED_PROJECTIONS \
20690 so purge/excise_source reach it"
20691 );
20692 }
20693
20694 // (2) The guard predicate BITES: pretend one of them was never
20695 // registered — the guard's "registered OR source-of-truth" check must
20696 // reject it (the exact assertion `guard_row_owned_registry` runs).
20697 for hidden in ["canonical_attributes", "property_search_index"] {
20698 let as_if_unregistered: Vec<&str> =
20699 registered.iter().copied().filter(|t| *t != hidden).collect();
20700 let accepted = as_if_unregistered.contains(&hidden)
20701 || NON_PROJECTION_CURSOR_TABLES.contains(&hidden);
20702 assert!(
20703 !accepted,
20704 "if {hidden} were unregistered the guard would still (incorrectly) accept it — \
20705 the guard does not actually bite"
20706 );
20707 }
20708 }
20709
20710 /// Cause-A (0.8.11.2) / C-2 (0.8.19) — `derive_stable_id` id-space contract:
20711 /// a present `logical_id` yields a `Logical` (`"l:"`) [`IdSpace`]; a NULL or
20712 /// empty `logical_id` falls back to a deterministic `Content` (`"h:"`) sha256
20713 /// content-hash of the body. The typed spaces are prefix-distinguishable and
20714 /// the value is behaviour-neutral (never used in ranking). Post-C-2 the helper
20715 /// returns a typed [`IdSpace`] whose `to_prefixed()` reproduces the pre-swap
20716 /// string byte-for-byte (eu7 no-op basis).
20717 #[test]
20718 fn derive_stable_id_id_space_contract() {
20719 // logical_id present → Logical space, body-independent.
20720 assert_eq!(derive_stable_id(Some("alice-1"), "any body"), IdSpace::logical("alice-1"));
20721 assert_eq!(
20722 derive_stable_id(Some("alice-1"), "a different body"),
20723 IdSpace::logical("alice-1")
20724 );
20725 // Byte-identical prefixed form to the pre-C-2 `stable_id` string.
20726 assert_eq!(derive_stable_id(Some("alice-1"), "any body").to_prefixed(), "l:alice-1");
20727
20728 // NULL logical_id → Content space, deterministic on body.
20729 let h1 = derive_stable_id(None, "stable body text");
20730 let h2 = derive_stable_id(None, "stable body text");
20731 assert_eq!(h1, h2, "content-hash is deterministic");
20732 assert_eq!(h1.space, IdSpaceKind::Content);
20733 let h1s = h1.to_prefixed();
20734 assert!(h1s.starts_with("h:"));
20735 assert_eq!(h1s.len(), 2 + 64, "h: + sha256 hex");
20736 assert!(h1s["h:".len()..].chars().all(|c| c.is_ascii_hexdigit()));
20737
20738 // Empty logical_id is treated as absent (falls back to content-hash).
20739 assert_eq!(derive_stable_id(Some(""), "stable body text"), h1);
20740
20741 // Distinct bodies → distinct content-hashes (no collision).
20742 assert_ne!(derive_stable_id(None, "body A"), derive_stable_id(None, "body B"));
20743 }
20744
20745 /// C-2 (0.8.19 / TC-8) — [`IdSpace`] parse/format round-trip is stable across
20746 /// all three spaces, including a value that itself contains `":"`.
20747 #[test]
20748 fn id_space_parse_format_round_trip() {
20749 let cases = [
20750 IdSpace::logical("alice-1"),
20751 IdSpace::content("a".repeat(64)),
20752 IdSpace::passage("7"),
20753 IdSpace::logical("l:weird:value"), // value contains the delimiter
20754 ];
20755 for id in cases {
20756 assert_eq!(IdSpace::parse(&id.to_prefixed()), Some(id.clone()), "round-trip {id:?}");
20757 }
20758 assert_eq!(IdSpace::logical("x").to_prefixed(), "l:x");
20759 assert_eq!(IdSpace::content("y").to_prefixed(), "h:y");
20760 assert_eq!(IdSpace::passage("3").to_prefixed(), "p:3");
20761 assert_eq!(IdSpace::parse("untagged"), None);
20762 }
20763
20764 // Pack 1 drift-detection: the Rust helper used by the two writer
20765 // sites must agree with the CASE WHEN used by the Pack 1 reshape
20766 // migration in `migrate_vector_partition_to_pack1`. The CASE SQL
20767 // is exported as `KIND_TO_SOURCE_TYPE_CASE_SQL`; this test
20768 // exercises it against an in-memory SQLite (no sqlite-vec extension
20769 // required — only the CASE) and asserts byte-equal output with the
20770 // Rust helper for every kind in the locked Pack 1 vocabulary
20771 // (incl. the synthetic `doc` -> `article` coercion). See
20772 // `dev/design/0.7.0-vector-quant-pack1.md` D3 / D4.
20773 #[test]
20774 fn resolve_source_type_drift_check() {
20775 let kinds = ["email", "article", "paper", "meeting", "note", "todo", "doc"];
20776
20777 // 1. Rust helper return values (table is the contract: changes
20778 // here must be reflected in the SQL CASE or this test fails).
20779 let want: &[(&str, &str)] = &[
20780 ("email", "email"),
20781 ("article", "article"),
20782 ("paper", "paper"),
20783 ("meeting", "meeting"),
20784 ("note", "note"),
20785 ("todo", "todo"),
20786 ("doc", "article"),
20787 ];
20788 for (kind, expected) in want {
20789 let got = resolve_source_type(kind).unwrap_or_else(|_| {
20790 panic!("resolve_source_type({kind}) returned Err; want Ok({expected})")
20791 });
20792 assert_eq!(got, *expected, "Rust helper drift for kind={kind}");
20793 }
20794 assert!(
20795 resolve_source_type("banana").is_err(),
20796 "unknown kind must surface as writer error"
20797 );
20798
20799 // 2. SQL CASE evaluated against the same kinds. Build a
20800 // one-row staging row per kind and SELECT through
20801 // KIND_TO_SOURCE_TYPE_CASE_SQL; assert each row equals the
20802 // Rust helper's output. Drift in either direction fails.
20803 let conn = Connection::open_in_memory().expect("in-memory sqlite");
20804 conn.execute_batch("CREATE TABLE s(kind TEXT NOT NULL)").expect("create s");
20805 for kind in &kinds {
20806 conn.execute("INSERT INTO s(kind) VALUES (?1)", [kind]).expect("insert kind");
20807 }
20808 let sql = format!("SELECT s.kind, {KIND_TO_SOURCE_TYPE_CASE_SQL} FROM s");
20809 let mut stmt = conn.prepare(&sql).expect("prepare CASE");
20810 let rows: Vec<(String, String)> = stmt
20811 .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
20812 .expect("query")
20813 .map(|r| r.expect("row"))
20814 .collect();
20815 assert_eq!(rows.len(), kinds.len(), "row count drift");
20816 for (kind, sql_result) in &rows {
20817 let rust_result = resolve_source_type(kind).expect("known kind");
20818 assert_eq!(
20819 sql_result, rust_result,
20820 "SQL CASE vs Rust helper drift for kind={kind}: SQL={sql_result}, Rust={rust_result}"
20821 );
20822 }
20823 }
20824
20825 #[test]
20826 fn write_advances_cursor() {
20827 let dir = TempDir::new().unwrap();
20828 let opened = Engine::open(dir.path().join("rewrite.sqlite")).expect("engine should open");
20829 let receipt = opened
20830 .engine
20831 .write(&[PreparedWrite::Node {
20832 kind: "doc".to_string(),
20833 body: "hello".to_string(),
20834 source_id: crate::SourceId::new("test:fixture").expect("test source id"),
20835 logical_id: None,
20836 state: crate::InitialState::Active,
20837 reason: None,
20838 valid_from: None,
20839 valid_until: None,
20840 }])
20841 .expect("write should succeed");
20842
20843 assert_eq!(receipt.cursor, 1);
20844 }
20845}