fathomdb_engine/lib.rs
1//! **FathomDB engine** — the runtime core: storage, projections, ingest and
2//! query.
3//!
4//! ⚠ **Most consumers should depend on the [`fathomdb`] facade crate instead.**
5//! `fathomdb` re-exports exactly the governed application surface and gates the
6//! operator/recovery seam behind a cargo feature; this crate is the
7//! implementation and exposes internals the facade deliberately withholds.
8//! Depend on it directly only if you are building FathomDB tooling.
9//!
10//! [`fathomdb`]: https://docs.rs/fathomdb
11//!
12//! # What it does
13//!
14//! One `Engine` owns an embedded SQLite database (FTS5 + `sqlite-vec`), the
15//! single writer thread, a thread-affine reader pool serving DEFERRED-tx
16//! snapshots, the background projection scheduler, and — optionally — an
17//! in-process embedder. There is no server and no sidecar.
18//!
19//! - **Hybrid retrieval.** A vector branch and an FTS5 branch, fused by
20//! Reciprocal Rank Fusion on ordinal *rank* (never on raw, non-comparable
21//! scores), with an optional CPU cross-encoder rerank and an optional
22//! graph-BFS third arm over temporal fact edges.
23//! - **Canonical rows + projections.** Writes land as durable canonical rows;
24//! FTS, vector and attribute indexes are engine-maintained projections
25//! rebuildable from them.
26//! - **Record lifecycle.** Transaction-time supersession keyed on `logical_id`,
27//! an existence axis (`transition` / `purge`), and world-time validity
28//! windows on nodes plus `t_valid` / `t_invalid` on edges.
29//! - **Deletion on request.** `Engine::erase_source` erases every row carrying a
30//! provenance id — including anonymous rows `Engine::purge` cannot reach —
31//! and finishes the erasure at rest.
32//!
33//! # Provenance is mandatory
34//!
35//! `PreparedWrite::Node` and `PreparedWrite::Edge` carry `source_id: SourceId`,
36//! a newtype rather than an `Option<String>`. `Engine::erase_source` addresses
37//! rows **by** `source_id`, so a row written without one could never be erased;
38//! `SourceId::new` is the only public constructor and makes that state
39//! inexpressible.
40//!
41//! # Stability
42//!
43//! Pre-1.0, so **beta**. `SCHEMA_VERSION` is the on-disk contract; migrations
44//! run at open and only there. `PreparedWrite`, `SearchFilter` and
45//! `EngineError` are `#[non_exhaustive]` or documented as additive.
46
47pub mod lifecycle;
48mod pcache2;
49
50use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
51use std::error::Error;
52use std::fmt::{Display, Formatter};
53use std::fs::{File, OpenOptions};
54use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
55use std::path::{Path, PathBuf};
56use std::process::{Command, Stdio};
57use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
58use std::sync::mpsc::{self, Receiver, SyncSender};
59#[cfg(debug_assertions)]
60use std::sync::Barrier;
61use std::sync::Once;
62use std::sync::{Arc, Condvar, Mutex};
63use std::thread::{self, JoinHandle};
64use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
65
66use fathomdb_embedder::EmbedderEvent;
67// `MeanRecomputeTrigger` is used only by the operator-gated `recompute_mean`.
68#[cfg(feature = "operator")]
69use fathomdb_embedder::MeanRecomputeTrigger;
70use fathomdb_embedder_api::{Embedder, EmbedderError as RuntimeEmbedderError, EmbedderIdentity};
71use fathomdb_query::compile_text_query;
72use fathomdb_schema::{
73 migrate_with_event_sink, MigrationError as SchemaMigrationError, MigrationStepReport,
74 LOCK_SUFFIX, MIGRATIONS, SCHEMA_VERSION,
75};
76// `CANONICAL_TABLES` is used only by the operator-gated `dump_row_counts`.
77#[cfg(feature = "operator")]
78use fathomdb_schema::CANONICAL_TABLES;
79use jsonschema::JSONSchema;
80use rusqlite::{params, Connection, OptionalExtension};
81use serde_json::Value;
82// `sha2::Digest` + `sha2::Sha256` — used by `safe_export` (operator-gated)
83// and unconditionally by `ingest_with_extractor` (G11 logical_id derivation).
84#[cfg(feature = "operator")]
85use sha2::Digest;
86#[cfg(not(feature = "operator"))]
87use sha2::Digest as _;
88use sha2::Sha256;
89use sqlite_vec::sqlite3_vec_init;
90
91#[cfg(unix)]
92use std::os::unix::fs::OpenOptionsExt;
93
94// EU-5b lock-flip: the engine's default embedder identity is now the
95// pinned bge-small variant. Pre-existing 0.7.0 workspaces opened with
96// `EmbedderChoice::Default` will fail-closed on identity mismatch per
97// ADR-0.6.0-vector-identity-embedder-owned; callers can still hold an
98// older noop profile by supplying `EmbedderChoice::Caller(NoopEmbedder)`.
99const DEFAULT_EMBEDDER_NAME: &str = "fathomdb-bge-small-en-v1.5";
100const DEFAULT_EMBEDDER_REVISION: &str = "5c38ec7c405ec4b44b94cc5a9bb96e735b38267a";
101const DEFAULT_EMBEDDER_DIMENSION: u32 = 384;
102
103/// Identity name of the bge-small embedder. `OpenReport.embedder_mean_centering_required`
104/// is `true` iff the live embedder identity reports this name. NoopEmbedder
105/// is `false`. Lifted out as a constant so the EU-5b lock-flip (when the
106/// engine's default identity becomes bge-small) is a single-line change.
107///
108/// TODO(EU-5b): when `DEFAULT_EMBEDDER_NAME` flips to this constant, the
109/// Default path will populate `embedder_mean_centering_required = true`
110/// without further engine work. Caller-supplied bge-small (rare today)
111/// already does the right thing.
112const BGE_SMALL_EMBEDDER_NAME: &str = "fathomdb-bge-small-en-v1.5";
113
114/// REQ-006a / AC-007a default slow-statement threshold. Mutated at runtime
115/// via [`Engine::set_slow_threshold_ms`].
116const DEFAULT_SLOW_THRESHOLD_MS: u64 = 100;
117const DEFAULT_VECTOR_PROFILE: &str = "default";
118const DEFAULT_VECTOR_PARTITION: &str = "vector_default";
119
120/// 0.8.18 Slice 5 (#5 vector-equivalence probe) — the committed 45-probe fixture
121/// (byte-identical to `fathomdb-embedder/tests/fixtures/candle_onnx_equivalence_probes.txt`;
122/// a drift-guard test pins the two copies equal). One probe per non-empty line;
123/// lines whose first non-whitespace char is `#` are comments.
124const VECTOR_EQUIVALENCE_PROBE_FIXTURE: &str = include_str!("vector_equivalence_probes.txt");
125
126/// 0.8.18 Slice 5 (#5 vector-equivalence probe) — the FROZEN D4 tolerance floor,
127/// **P2 component**: the un-centered Phase-2 L2 epsilon. `‖reembed − reference‖₂`
128/// (un-centered, `vec_distance_l2` semantics) strictly greater than this ⇒
129/// divergence ⇒ dense refused. Named constant so the final ε (HITL look at
130/// landing) is trivially tunable. The **P1 component** (Phase-1 mean-centered
131/// `embedding_bin` sign-flip count) has an *exact-zero* floor: ANY single flip on
132/// the 45 probes ⇒ divergence (see [`VECTOR_EQUIVALENCE_P1_FLIP_FLOOR`]).
133const VECTOR_EQUIVALENCE_L2_EPSILON: f32 = 1e-5;
134
135/// 0.8.18 Slice 5 — the FROZEN D4 tolerance floor, **P1 component**: the maximum
136/// tolerated Phase-1 mean-centered `embedding_bin` sign-flip count across all 45
137/// probes. `0` = exact: any single flip ⇒ divergence ⇒ dense refused.
138const VECTOR_EQUIVALENCE_P1_FLIP_FLOOR: u64 = 0;
139
140/// 0.8.20 Slice 22 (TC-68) — `_fathomdb_open_state` key holding the
141/// [`probe_verification_fingerprint`] of the last open at which the
142/// vector-equivalence probe actually RAN and PASSED on this workspace. An open
143/// whose freshly computed fingerprint equals this value reuses that verdict and
144/// performs ZERO probe embeds; anything else re-runs the full probe.
145///
146/// It lives in `_fathomdb_open_state` — the engine's existing open-time
147/// durable-marker KV table (migration step 1) — alongside
148/// [`SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY`] and
149/// [`EDGE_VECTOR_PRUNE_MARKER_KEY`], which is exactly this shape of state. So
150/// TC-68 adds **no** table, **no** migration step and **no** `SCHEMA_VERSION`
151/// bump: an old DB simply has no row here and re-runs the probe once.
152const VECTOR_EQUIVALENCE_VERDICT_CACHE_KEY: &str = "vector_equivalence_verified_fingerprint";
153
154/// 0.8.20 Slice 22 (TC-68) — recipe tag mixed into every verdict fingerprint.
155/// **Bump it whenever the SET of fingerprint inputs changes.** Every cached
156/// verdict in the field then stops matching and the probe re-runs once per
157/// workspace — the fail-SAFE direction, and the reason a stale recipe can never
158/// silently keep vouching for a narrower check than the current build performs.
159const VECTOR_EQUIVALENCE_FINGERPRINT_RECIPE: &str = "fathomdb-veq-verdict-v1";
160/// Default drain budget for `rebuild_projections` / `rebuild_vec0`. The
161/// rebuild path freezes the scheduler before truncating shadow rows, so
162/// the only outstanding work is whatever workers were mid-flight when
163/// the call landed; 30 s is generous for normal job sizes and bounded
164/// for tests.
165#[cfg(feature = "operator")]
166const REBUILD_DRAIN_TIMEOUT_MS: u64 = 30_000;
167/// OPP-12 Phase-1 (0.8.19 Slice 10) — drain budget the `transition`/`purge`
168/// lifecycle verbs use to settle in-flight projection work before mutating.
169/// Same 30 s budget as `REBUILD_DRAIN_TIMEOUT_MS`, but not `operator`-gated
170/// (the lifecycle verbs are always-on governed surface).
171const LIFECYCLE_DRAIN_TIMEOUT_MS: u64 = 30_000;
172/// 0.8.0 Slice 5 (G1) — schema version that introduces the global FTS5
173/// tokenizer-default upgrade (`SCHEMA_VERSION` 11, migration step 11). A DB
174/// migrated to (or past) this version re-tokenizes `search_index` from
175/// canonical source rows on open (the drop+recreate leaves the FTS index
176/// empty). Repair is keyed off the completion marker below — NOT off crossing
177/// the step boundary — so it is crash-retryable (see
178/// `SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY`).
179const SEARCH_INDEX_TOKENIZER_SCHEMA_VERSION: u32 = 11;
180/// 0.8.0 Slice 5 (G1) fix-1 — `_fathomdb_open_state` key set, in the SAME
181/// transaction as the reproject DELETE+INSERT, once the post-tokenizer-upgrade
182/// re-tokenization commits durably. Step 11 commits `user_version = 11` with an
183/// EMPTY `search_index` in its own transaction; the reproject runs in a later
184/// transaction on open. A crash in that window leaves a durable `user_version =
185/// 11` + empty index. Gating repair on a boundary crossing (`before < 11`)
186/// would skip it on the next open (it sees `before == 11`), stranding the index
187/// empty forever. Gating on this marker's ABSENCE instead makes repair
188/// idempotent and crash-retryable: written atomically with the reindex, so a
189/// crash before commit leaves no marker and the next open re-runs.
190const SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY: &str =
191 "search_index_tokenizer_reproject_complete";
192/// 0.8.20 Slice 15c (TC-33) fix-6 — schema version at which the
193/// `canonical_edges` INTEGER-epoch recreate (migration step 23) runs. A DB
194/// migrated to (or past) this version has had every edge row DROPPED with NO
195/// DATA MIGRATION, and the migration removed the dropped edges'
196/// `_fathomdb_vector_rows` sidecar rows. The vec0 `vector_default` shadow it
197/// mirrors is engine-created and dim-parameterized, so the migration cannot
198/// touch it; the engine prunes the now-orphaned vec0 rows on open.
199const EDGE_TEMPORAL_EPOCH_SCHEMA_VERSION: u32 = 23;
200/// 0.8.20 Slice 15c (TC-33) fix-6 — `_fathomdb_open_state` key set once the
201/// one-time edge-vector prune commits durably (written in the SAME transaction
202/// as the vec0 DELETEs). Gating repair on this marker's ABSENCE — not on
203/// crossing the step-23 boundary — makes it crash-retryable: the step-23
204/// migration commits `user_version = 23` (edges dropped, sidecar cleared) in its
205/// own transaction, and the prune runs in a later transaction on open. A crash
206/// in that window leaves a durable `user_version = 23` with orphaned vec0 rows;
207/// a boundary-crossing gate (`before < 23`) would skip the prune forever on the
208/// next open (it sees `before == 23`). The marker is absent on any DB upgraded
209/// before this fix shipped, so the prune runs once and cleans the lingering
210/// orphans; thereafter the paired vec0/sidecar insert+delete keeps the invariant
211/// so no new orphans arise.
212const EDGE_VECTOR_PRUNE_MARKER_KEY: &str = "tc33_edge_vector_prune_complete";
213const DEFAULT_PROVENANCE_ROW_CAP: u64 = 1_000_000;
214/// 0.8.20 Slice 5b (R-20-E5) — how many times an erasure verb re-tries
215/// `PRAGMA wal_checkpoint(TRUNCATE)` before refusing with
216/// [`EngineError::ErasureIncomplete`]. Deliberately small: a concurrent reader
217/// pinning a WAL snapshot can hold it for an unbounded time, and an erasure verb
218/// must fail loudly rather than block a caller indefinitely.
219const ERASURE_WAL_TRUNCATE_ATTEMPTS: u32 = 5;
220/// 0.8.20 Slice 5b (R-20-E5) — pause between WAL-truncation attempts
221/// (~100 ms total budget across [`ERASURE_WAL_TRUNCATE_ATTEMPTS`]).
222const ERASURE_WAL_TRUNCATE_BACKOFF_MS: u64 = 25;
223/// 0.8.20 Slice 5b (R-20-E6) — the sentinel that replaces an erased
224/// `result_stable_ids` element in the telemetry sink. Positional alignment with
225/// the parallel `result_ids` array is preserved, so a redacted sink stays
226/// parseable by the gold pipeline.
227const REDACTED_STABLE_ID: &str = "[erased]";
228/// 0.8.20 Slice 5b (design `0.8.20-slice0-erasure-design.md` §2 defect D-A,
229/// HITL-ruled 2026-07-19: *"there must be an auditable record of deletion
230/// event."*) — op-store collections holding ERASURE-AUDIT records.
231///
232/// These rows are **exempt from [`enforce_provenance_retention`]**. Before this
233/// slice they were swept like any other op-store row: cap-first, oldest-`id`
234/// first, with no collection filter — and because the audit row is written
235/// *before* the workload that follows it, it was among the FIRST evicted. The
236/// proof of erasure was therefore destructible, and shared a retention pool with
237/// the very payloads it must prove erased. Accountability (demonstrating *that*
238/// an erasure occurred) is a distinct obligation from erasure itself, and a
239/// retention sweep must not silently discharge it.
240///
241/// **Guarantee:** a row in one of these collections is never removed by the
242/// retention sweep, and (0.8.20 Slice 5 fix-3) never by
243/// [`Engine::excise_collection_record`] either — see
244/// [`is_erasure_bookkeeping_collection`].
245const ERASURE_AUDIT_COLLECTIONS: &[&str] = &["excise_source_audit", "excise_record_audit"];
246/// 0.8.20 Slice 5 fix-1 (codex §9 P2) — the `operational_mutations` collection
247/// holding the DURABLE record of a telemetry redaction that is owed but not yet
248/// performed. See [`Engine::discharge_pending_redactions`].
249///
250/// Like the audit collections it is exempt from the retention sweep: an
251/// outstanding erasure obligation must not be discharged by cap pressure.
252const ERASURE_PENDING_REDACTION_COLLECTION: &str = "erasure_pending_redaction";
253/// 0.8.20 Slice 5 fix-3 (codex §9 round-3 P1) — true for the op-store
254/// collections that hold the engine's ERASURE BOOKKEEPING: the durable
255/// pending-redaction queue and the erasure-audit trail.
256///
257/// These are engine-owned invariants that happen to be *stored* as op-store
258/// records. They are not caller data, and the generic record-erasure verb
259/// [`Engine::excise_collection_record`] must refuse to target them:
260///
261/// * **The pending queue** ([`ERASURE_PENDING_REDACTION_COLLECTION`]) records a
262/// telemetry redaction the engine still OWES. Deleting the entry makes
263/// [`Engine::complete_erasure_at_rest`] see no outstanding work, so the next
264/// erasure verb reports SUCCESS while the erased `l:`/`h:` ids are still in
265/// the telemetry sink. That is the exact R-20-E5 violation — *an erasure verb
266/// must never report success on an incomplete erasure* — that the queue was
267/// introduced to close, and the verb re-opened it through an
268/// operator-reachable path (`--excise-collection erasure_pending_redaction
269/// --excise-record-key <verb>`).
270/// * **The audit trail** ([`ERASURE_AUDIT_COLLECTIONS`]) is protected by the
271/// HITL ruling of 2026-07-19: *"there must be an auditable record of deletion
272/// event."* Deleting audit rows one-by-one defeats that ruled-on guarantee as
273/// surely as a retention sweep would. Accountability is a distinct obligation
274/// from erasure, and no verb may silently discharge it.
275///
276/// Neither carries erasable payload, so refusing them costs a caller nothing: a
277/// pending-queue row holds only stable ids the engine is about to remove from
278/// the sink (and deletes itself on discharge), and an audit row holds a
279/// `source_id` bound by the non-PII rule or a SHA-256 record digest.
280///
281/// The refusal is TYPED ([`EngineError::InvalidArgument`]), never a silent
282/// no-op — an operator who aimed at the wrong collection must be told. The shape
283/// mirrors the slice's existing precedent: [`Engine::erase_source`] refuses the
284/// reserved `_`-prefixed provenance namespace while [`Engine::excise_source`]
285/// stays permissive.
286///
287/// Gated on `feature = "operator"` to match its only call site,
288/// [`Engine::excise_collection_record`], which is itself operator-only: without
289/// the matching `cfg` a non-`operator` build emits a `dead_code` warning for a
290/// helper that has nothing to guard, because the verb it guards does not exist
291/// in that build.
292#[cfg(feature = "operator")]
293fn is_erasure_bookkeeping_collection(collection: &str) -> bool {
294 collection == ERASURE_PENDING_REDACTION_COLLECTION
295 || ERASURE_AUDIT_COLLECTIONS.contains(&collection)
296}
297const PROJECTION_CURSOR_KEY: &str = "projection_cursor";
298const PROJECTION_WORKERS: usize = 2;
299/// PR-9 — ADR-0.6.0-embedder-protocol **Invariant 5** default per-`embed()`
300/// watchdog deadline. Every projection-path embed runs under this timeout;
301/// a hung embed surfaces `RuntimeEmbedderError::Timeout` (engaging the
302/// existing retry/failure path) rather than parking a worker forever. The
303/// EU-5f `catch_unwind` only catches *panics*; this catches *hangs*.
304const DEFAULT_EMBED_TIMEOUT_MS: u64 = 30_000;
305/// PR-9 — embed circuit-breaker threshold: the maximum number of watchdog
306/// embed threads allowed alive at once before the breaker latches and
307/// projection jobs fail fast (see `embed_circuit_open` / `live_embed_threads`).
308/// Healthy serialized operation keeps the live count at 0–1, so reaching this
309/// many concurrently-alive embed threads means timed-out embeds are piling up
310/// (a hung/wedged embedder); the breaker then caps the abandoned-thread leak
311/// at roughly this count.
312const DEFAULT_EMBED_CIRCUIT_THRESHOLD: u64 = 8;
313const PROJECTION_COMMIT_BATCH: usize = 16;
314// Each worker should be able to grab a full commit batch while another
315// worker has the same waiting in the queue. Below this, the dispatcher
316// throttles below the workers' commit-batch capacity.
317const PROJECTION_INFLIGHT_LIMIT: usize = PROJECTION_WORKERS * PROJECTION_COMMIT_BATCH;
318// SQL fetch cap inside the dispatcher: enough to fill the in-flight
319// budget in a single scan so we don't pay one SQL roundtrip per job.
320const PROJECTION_SCAN_FETCH: usize = PROJECTION_INFLIGHT_LIMIT;
321const DEFAULT_PROJECTION_RETRY_DELAYS_MS: [u64; 3] = [1_000, 4_000, 16_000];
322
323/// G11 — the fixed projection kind every EDGE body is scheduled under
324/// (`resolve_source_type` maps it to `source_type = 'edge_fact'` in
325/// `vector_default`). Named so the fix-4 deferral can say WHICH rows it
326/// deliberately leaves on the shipped terminal path (see
327/// `projection_dispatcher_loop`).
328const EDGE_FACT_KIND: &str = "edge_fact";
329
330/// Reader pool size. Per `dev/design/engine.md` § Writer / reader split,
331/// reader connections are pooled and never serialize behind one
332/// connection. AC-021 exercises 8 concurrent readers.
333const READER_POOL_SIZE: usize = 8;
334
335/// Per-reader-connection lookaside slot size, in bytes. Pack 6.G G.1.
336/// Picked from G.0 telemetry (`allocator_lookaside` 26.67% conc cycles
337/// with 3.89× ratio) + the SQLite docs' typical-workload sizing
338/// guidance (https://www.sqlite.org/malloc.html §3): 1200-byte slots
339/// cover the small allocations from `sqlite3DbMallocRaw`,
340/// `sqlite3Fts5ExprNew`, and `vec0Filter_knn` visible at the top of the
341/// concurrent profile.
342const READER_LOOKASIDE_SLOT_SIZE: std::os::raw::c_int = 1200;
343
344/// Per-reader-connection lookaside slot count. SQLite default is 128;
345/// we use 500 to absorb the per-statement allocation footprint of the
346/// hybrid search workload across a sticky worker connection without
347/// falling back to the glibc malloc-arena mutex.
348const READER_LOOKASIDE_SLOT_COUNT: std::os::raw::c_int = 500;
349
350pub struct Engine {
351 path: PathBuf,
352 next_cursor: AtomicU64,
353 closed: AtomicBool,
354 lock: Mutex<Option<File>>,
355 connection: Mutex<Option<Connection>>,
356 reader_pool: ReaderWorkerPool,
357 counters: lifecycle::Counters,
358 subscribers: Arc<lifecycle::SubscriberRegistry>,
359 profiling_enabled: Arc<AtomicBool>,
360 slow_threshold_ms: Arc<AtomicU64>,
361 runtime_embedder: Option<Arc<dyn Embedder>>,
362 runtime_embedder_identity: EmbedderIdentity,
363 projection_runtime: ProjectionRuntime,
364 provenance_row_cap: AtomicU64,
365 /// Per-connection profile-callback contexts. Each box's pointer is
366 /// installed into the connection's `sqlite3_profile` userdata; the
367 /// box must outlive the connection so the callback never reads
368 /// freed memory. Connections are dropped before this vec on
369 /// `close`/`Drop`, so the lifetime ordering holds.
370 ///
371 /// Why `Box<ProfileContext>` and not `ProfileContext` directly: the
372 /// FFI pointer captured during `install_profile_callback` MUST
373 /// remain stable for the connection's lifetime; pushing onto a
374 /// `Vec<ProfileContext>` could reallocate and invalidate that
375 /// pointer.
376 #[allow(clippy::vec_box)]
377 profile_contexts: Mutex<Vec<Box<ProfileContext>>>,
378 /// Pack 6.G G.1 — `sqlite3_db_config(LOOKASIDE)` rc per reader
379 /// worker, captured at open time before any PRAGMA / prepare ran
380 /// on the connection. Read only by the debug-only test accessor
381 /// `reader_lookaside_config_rcs_for_test`; held in release builds
382 /// too because the field is set unconditionally at open and a cfg
383 /// gate would force two open-locked return shapes.
384 #[allow(dead_code)]
385 reader_lookaside_rcs: Vec<i32>,
386 /// 0.8.8 Slice 15 (OPP-9) — opt-in telemetry sink. `None` (default) = OFF.
387 /// Local JSONL append; no network/egress. The OFF path never takes this lock —
388 /// it is gated by `telemetry_enabled` (below).
389 telemetry: Mutex<Option<TelemetrySink>>,
390 /// 0.8.8 Slice 15 — fast OFF-path guard. `false` (default) → search does ZERO
391 /// telemetry work: a single `Relaxed` atomic load, NO mutex acquisition (the
392 /// §B.1 footprint / zero-cost gate, codex §9 P2). Set `true` by
393 /// `enable_telemetry` after the sink is installed; the `telemetry` mutex is only
394 /// ever taken when this flag is set.
395 telemetry_enabled: AtomicBool,
396 /// 0.8.18 Slice 5 (#5 vector-equivalence probe, R-VEQ-4/6) — degraded-open
397 /// latch, re-derived at every open by the #5 self-check. `true` ⇒ every
398 /// vector-dependent arm refuses at the `search_inner_with_stats` choke point
399 /// with `EngineError::VectorEquivalenceMismatch`. Read lock-free on the query
400 /// hot path (a single `Relaxed`/`Acquire` load); the text-only/FTS-only path
401 /// never reads it.
402 dense_disabled: AtomicBool,
403 /// R-VEQ-6 — the human-readable reason attached to the query-time refusal (and
404 /// surfaced on `OpenReport.dense_disabled_reason`). Set once at open; read only
405 /// when `dense_disabled` is `true`.
406 dense_disabled_reason: Mutex<Option<String>>,
407 /// R-VEQ-6 — telemetry counter: number of query-time vector-dependent-arm
408 /// refusals raised because the engine opened in the `dense_disabled` state.
409 /// Observable pre/post-query via `vector_equivalence_refusal_count`.
410 vector_equivalence_refusals: AtomicU64,
411 #[cfg(debug_assertions)]
412 force_next_commit_failure: AtomicBool,
413}
414
415/// 0.8.8 Slice 15 (OPP-9) — opt-in telemetry capture state (per `enable_telemetry`).
416/// Records query→result→feedback events to a local JSONL sink. Ids are
417/// `SearchHit.id` — the interim identity carrier per
418/// `ADR-0.8.0-canonical-identity-substrate` (write_cursor today; swaps to
419/// `logical_id` at the G0 keystone with no carrier reshape), consistent with
420/// `PerHitExplain.id`. Query text and `source_id` are NEVER captured (privacy, ADR
421/// §C). `query_id = "q{nonce}-{seq}"` is fully deterministic; `ts_monotonic_ms` is
422/// monotonic since enable (NOT wall-clock).
423struct TelemetrySink {
424 path: PathBuf,
425 base: Instant,
426 nonce: u64,
427 seq: u64,
428 last_query_id: Option<String>,
429}
430
431#[derive(Clone, Debug)]
432struct ProjectionJob {
433 cursor: u64,
434 kind: String,
435 body: String,
436}
437
438#[derive(Debug, Default)]
439struct ProjectionRuntimeState {
440 active_jobs: usize,
441 queued_jobs: usize,
442 frozen: bool,
443 pending_scan: bool,
444 stopping: bool,
445 in_flight: BTreeSet<u64>,
446}
447
448struct ProjectionRuntimeShared {
449 path: PathBuf,
450 embedder: Option<Arc<dyn Embedder>>,
451 embedder_identity: EmbedderIdentity,
452 /// Host-owned lifecycle diagnostics for worker failures, which occur on
453 /// background connections rather than through an `Engine` method call.
454 subscribers: Arc<lifecycle::SubscriberRegistry>,
455 state: Mutex<ProjectionRuntimeState>,
456 state_cvar: Condvar,
457 queue: Mutex<VecDeque<ProjectionJob>>,
458 queue_cvar: Condvar,
459 retry_delays_ms: Mutex<Vec<u64>>,
460 /// PR-9 — ADR-0.6.0-embedder-protocol Invariant 5 per-`embed()` watchdog
461 /// deadline (ms). Read lock-free on the projection hot path. Default
462 /// `DEFAULT_EMBED_TIMEOUT_MS` (30s); the test seam
463 /// `set_embed_timeout_ms_for_test` lowers it so the hanging-embedder
464 /// test need not wait 30s. A hung embed surfaces
465 /// `RuntimeEmbedderError::Timeout`, engaging the existing retry/failure
466 /// path in `run_projection_job`.
467 embed_timeout_ms: AtomicU64,
468 /// PR-9 — engine-side embed serialization guard. The pool runs
469 /// `PROJECTION_WORKERS` workers; this guard ensures the shared
470 /// `Arc<dyn Embedder>` is invoked by at most one worker at a time.
471 ///
472 /// Rationale is SAFETY, not throughput. The engine accepts arbitrary
473 /// caller-supplied embedders (the pyo3 / napi bridges, per ADR-0.6.0)
474 /// whose `embed` is `Sync` only by trait contract; many real impls (a
475 /// GIL-bound Python model, a non-reentrant native lib, an internal cache)
476 /// are not actually safe under concurrent calls. Serializing engine-side
477 /// makes the projection robust to embedders that are not truly
478 /// concurrency-safe, without the engine having to trust each impl. The
479 /// default `CandleBgeEmbedder` was shown safe under concurrent forwards
480 /// in the PR-9 pre-flight, so for it the guard is belt-and-suspenders.
481 ///
482 /// Throughput is ~neutral: `candle` fans every `BertModel::forward` onto a
483 /// single process-wide rayon pool, so two concurrent forwards merely
484 /// share that pool (trading per-embed latency, not aggregate work) rather
485 /// than getting 2x — serializing avoids some scheduler/cache thrash but is
486 /// not a large win. (An earlier "~13x" figure compared a debug-build
487 /// unserialized run against a release-build number and was withdrawn; a
488 /// PR-9 micro-benchmark put release embeds at ~14 ms short / ~960 ms for a
489 /// 512-token doc, watchdog overhead ~0.)
490 ///
491 /// Commit/IO stays parallel across workers (see `commit_gate`); this guard
492 /// wraps only the embed call. It is held by the worker across the watchdog
493 /// call and released here, so a timed-out (abandoned) embed frees it and
494 /// cannot stall the pool — the guard owns no data, so a panic-resumed
495 /// embed that poisons it is recovered via `into_inner`.
496 ///
497 /// Deliberate trade-off (codex PR-9 CONCERN-1, accepted): on the *timeout*
498 /// path the worker drops this guard while the abandoned detached embed
499 /// thread is still running lock-free, so serialization is briefly relaxed
500 /// until that thread finishes. This is the prescribed choice over holding
501 /// the guard inside the embed thread — which would let a genuinely-hung
502 /// embed hold it forever and deadlock the whole pool, exactly the wedge
503 /// ADR-0.6.0 Invariant 5 and this slice's spec forbid. Timeouts are the
504 /// fault path only; the embed circuit breaker (`embed_circuit_open`) caps
505 /// how many such abandoned threads can be alive at once. A future slice may
506 /// replace this hard serialize with an operator-configurable embed
507 /// concurrency limit (ADR-0.6.0 Invariant 4 pool-size override) for I/O-
508 /// or GPU-bound embedders; that knob is out of PR-9 scope.
509 embed_serialize: Mutex<()>,
510 /// PR-9 — embed circuit breaker. `live_embed_threads` counts watchdog embed
511 /// threads currently alive (incremented when one is spawned, decremented
512 /// when it finishes — see `embed_with_watchdog`). Under healthy serialized
513 /// operation this is 0 or 1; it only grows when timed-out embeds are
514 /// abandoned and keep running (ADR-0.6.0 Invariant 5 forbids aborting a
515 /// running embed). When a new embed would push the live count to
516 /// `embed_circuit_threshold`, the breaker latches `embed_circuit_open` and
517 /// projection jobs fail fast WITHOUT spawning further embeds — bounding the
518 /// abandoned-thread leak to ~threshold REGARDLESS of whether the embedder
519 /// hangs on every input or only intermittently (a returning embed
520 /// decrements the count rather than resetting a streak, so an
521 /// intermittently-hanging embedder still latches as its hung threads pile
522 /// up, and a merely-slow-but-returning embedder self-clears and never
523 /// false-trips). Latches for the engine session (a reopen resets it); a
524 /// half-open/cool-down retry is future work. `threshold == 0` disables it.
525 live_embed_threads: Arc<AtomicU64>,
526 embed_circuit_open: AtomicBool,
527 embed_circuit_threshold: AtomicU64,
528 /// EU-5b — streaming mean accumulator for the per-workspace mean
529 /// pinning lifecycle (`dev/design/embedder.md` §0.3). `Some(_)` iff
530 /// the identity is MC-required AND no mean has been pinned yet on
531 /// disk. The accumulator graduates to `None` after the at-pin
532 /// commit; subsequent docs feed nothing.
533 mean_accumulator: Mutex<Option<MeanAccumulator>>,
534 /// EU-5b — `MeanVecPinned` events queued by the projection-commit
535 /// transaction for the next test-seam drain. Production callers
536 /// consume these via the `OpenReport.embedder_events` channel; the
537 /// drain seam is `Engine::drain_mean_centering_events_for_test`.
538 pending_events: Mutex<Vec<EmbedderEvent>>,
539 /// EU-5f — serializes the body of `commit_projection_outcomes` across
540 /// the `PROJECTION_WORKERS` worker connections. Each worker commits on
541 /// its own connection; holding this gate for the whole commit makes the
542 /// commit transactions totally ordered, which is what makes the at-pin
543 /// re-quantize pass provably complete (every row is wholly before or
544 /// after the unique pin tx, so none can survive un-centered). Embedding
545 /// (`run_projection_job`) runs OUTSIDE the gate and stays parallel.
546 commit_gate: Mutex<()>,
547 /// 0.7.2 PR-2bc S1 fix-1 — overridable phase-2 rerank `LIMIT` for the
548 /// search hot path. Equals `SEARCH_RERANK_LIMIT` (10) in production; a
549 /// test seam (`set_search_limit_for_test`) can RAISE it (clamped to >=10,
550 /// so it can never shrink below production semantics) so the recall
551 /// harness can pull top-(10+slack) and exclude the self-retrieving
552 /// query-source doc before truncating to 10. Production reads this atomic
553 /// (default 10) — there is NO env var read on the hot path.
554 search_limit_override: AtomicUsize,
555 /// Slice 10 / G12-recency — dedicated recency-reweight flag, **off by
556 /// default** (NOT `fusion_mode`). When set, fused hits are reweighted toward
557 /// the more recent `write_cursor` AFTER bit-KNN. Flipped by the
558 /// `set_recency_reweight_enabled_for_test` seam; no production toggle yet.
559 recency_reweight_enabled: AtomicBool,
560 /// 0.8.16 Slice 5 / F9 — dedicated importance/confidence reweight flag,
561 /// **off by default** (mirrors `recency_reweight_enabled`; NOT `fusion_mode`).
562 /// When set, fused hits are multiplicatively reweighted by node `importance`
563 /// (`canonical_nodes.importance`) and edge `confidence`
564 /// (`canonical_edges.confidence`) AFTER bit-KNN + RRF fusion — `NULL ⇒ neutral
565 /// (1.0)`. Flipped by `set_importance_reweight_enabled_for_test`; no production
566 /// toggle yet (F9 ships OFF-by-default as a MECHANISM, no eval-quality claim).
567 importance_reweight_enabled: AtomicBool,
568 /// GA-2 / Slice-40 (◆ B-1) measurement seam, **off by default**. When set,
569 /// `read_search_in_tx` returns the pre-fusion VECTOR-branch ranking
570 /// (bit-KNN K=192 + f32 rerank) verbatim — the ANN-quantization fidelity
571 /// signal — INSTEAD of the unconditional RRF-fused result. This changes
572 /// nothing for any production caller (the flag is never set outside the
573 /// `eu7` recall harness via `set_vector_stage_only_for_test`); it does NOT
574 /// reintroduce a `fusion_mode` knob (RRF stays unconditional) and does NOT
575 /// alter `fuse_rrf` / `rerank_fused` / recency. It only lets the AC-075
576 /// recall gate measure ANN+ vector top-10 vs the exact-f32 VECTOR top-10
577 /// ground truth in isolation (the quantization-FIDELITY axis the 0.90 floor
578 /// is defined to measure), not the hybrid `search()` output.
579 vector_stage_only_for_test: AtomicBool,
580 /// 0.7.2 PR-2b — debug-only fault injection: when set, `recompute_mean_in_tx`
581 /// errors AFTER writing `mean_vec` but BEFORE finishing the re-quantize
582 /// pass, so the crash-atomicity test can prove the whole recompute rolls
583 /// back (no half-recentered corpus). One-shot (cleared on consume).
584 #[cfg(debug_assertions)]
585 force_recompute_failure: AtomicBool,
586 /// TC-91 — one-shot worker-commit fault seam. `0` is disabled, `1`
587 /// requests a synthetic SQLite busy error, and `2` a rusqlite-layer
588 /// storage error immediately before commit.
589 /// Kept entirely in the runtime and compiled only for tests.
590 #[cfg(debug_assertions)]
591 force_projection_commit_failure: AtomicUsize,
592 /// TC-91 test-only rendezvous after error reporting and before worker
593 /// cleanup. It proves a stop in that window leaves canonical pending work
594 /// for the next open rather than relying on an in-memory retry queue.
595 #[cfg(debug_assertions)]
596 projection_commit_failure_pause: Mutex<Option<(Arc<Barrier>, Arc<Barrier>)>>,
597 /// TC-91 test-only acknowledgement after `stopping` is set and before a
598 /// close joins workers, used with `projection_commit_failure_pause`.
599 #[cfg(debug_assertions)]
600 projection_stop_ack: Mutex<Option<Arc<Barrier>>>,
601}
602
603impl std::fmt::Debug for ProjectionRuntimeShared {
604 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
605 f.debug_struct("ProjectionRuntimeShared")
606 .field("path", &self.path)
607 .field("embedder_identity", &self.embedder_identity)
608 .finish_non_exhaustive()
609 }
610}
611
612#[derive(Debug)]
613struct ProjectionRuntime {
614 shared: Arc<ProjectionRuntimeShared>,
615 dispatcher: Mutex<Option<JoinHandle<()>>>,
616 workers: Mutex<Vec<JoinHandle<()>>>,
617}
618
619impl std::fmt::Debug for Engine {
620 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
621 f.debug_struct("Engine")
622 .field("path", &self.path)
623 .field("closed", &self.closed.load(Ordering::SeqCst))
624 .field("runtime_embedder_identity", &self.runtime_embedder_identity)
625 .finish_non_exhaustive()
626 }
627}
628
629/// Per-connection profile-callback context.
630///
631/// Holds the registry handle the callback dispatches to, plus shared
632/// references to the engine's profiling toggle and slow-statement
633/// threshold. The `Arc` clones here mirror the same atomics held by
634/// `Engine`, so `set_profiling` / `set_slow_threshold_ms` mutations are
635/// visible inside the callback without restart (REQ-006a / AC-005a /
636/// AC-007b runtime-toggle contract).
637#[derive(Debug)]
638struct ProfileContext {
639 subscribers: Arc<lifecycle::SubscriberRegistry>,
640 profiling_enabled: Arc<AtomicBool>,
641 slow_threshold_ms: Arc<AtomicU64>,
642}
643
644/// Thread-affine reader worker pool (Pack 6 F.0).
645///
646/// Per `dev/design/engine.md` § Writer / reader split, reader connections
647/// must not serialize behind a single mutex. Each worker thread owns
648/// exactly one read-only `Connection` for its lifetime; `Connection`
649/// objects never cross thread boundaries after startup. `Engine::search`
650/// dispatches a request via a per-worker bounded channel using a
651/// lock-free round-robin counter on the hot path.
652struct ReaderWorkerPool {
653 senders: Vec<SyncSender<ReaderRequest>>,
654 handles: Mutex<Option<Vec<JoinHandle<()>>>>,
655 next: AtomicUsize,
656 shutdown: AtomicBool,
657 live_workers: Arc<AtomicUsize>,
658}
659
660impl std::fmt::Debug for ReaderWorkerPool {
661 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
662 f.debug_struct("ReaderWorkerPool")
663 .field("worker_count", &self.senders.len())
664 .field("live_workers", &self.live_workers.load(Ordering::Relaxed))
665 .field("shutdown", &self.shutdown.load(Ordering::Relaxed))
666 .finish()
667 }
668}
669
670/// One request handled by exactly one reader worker. The response is
671/// returned through a fresh oneshot channel so requests cannot be
672/// routed to or duplicated across workers.
673enum ReaderRequest {
674 /// Slice 60 — property-FTS search stays on a reader-owned connection, never
675 /// the writer connection. It shares the snapshot-local filter validation of
676 /// the hybrid search path.
677 SearchProjectedText {
678 query: String,
679 name: String,
680 filter: Option<Box<SearchFilter>>,
681 limit: usize,
682 view: ReadView,
683 respond: SyncSender<ProjectedTextReaderResponse>,
684 },
685 Search {
686 compiled: fathomdb_query::CompiledQuery,
687 /// Un-centered f32 query vector serialized for `vec_f32`. Phase 2
688 /// f32 rerank uses this verbatim.
689 query_vector: Option<String>,
690 /// EU-5a2 — (possibly centered) f32 query vector for the phase 1
691 /// `vec_quantize_binary` sign-quant. Equal to `query_vector` for
692 /// non-MC-required identities (the EU-5a2 default).
693 query_vector_bin: Option<String>,
694 /// 0.7.2 PR-2bc S1 fix-1 — phase-2 rerank `LIMIT`. Read from
695 /// `ProjectionRuntimeShared::search_limit_override` (default
696 /// `SEARCH_RERANK_LIMIT` = 10, clamped >=10) by `search_inner`
697 /// before dispatch, so the worker never reads any env var.
698 search_limit: usize,
699 /// G10 — optional closed metadata filter (`None` = unfiltered, the
700 /// byte-identical-to-0.7.2 path). Applied in the phase-1 candidates
701 /// statement (vector branch) and as a Rust post-filter (text branch).
702 /// Boxed so the `ReaderRequest::Search` variant stays small (the request
703 /// rides a `Result<(), ReaderRequest>` retry channel).
704 filter: Option<Box<SearchFilter>>,
705 /// G12-recency — whether the dedicated recency reweight is enabled for
706 /// this request (read from `recency_reweight_enabled`, off by default).
707 recency_enabled: bool,
708 /// F9 (0.8.16 Slice 5) — whether the dedicated importance/confidence
709 /// reweight is enabled for this request (read from
710 /// `importance_reweight_enabled`, off by default).
711 importance_enabled: bool,
712 /// GA-2 / Slice-40 (◆ B-1) measurement seam — when true the worker
713 /// returns the pre-fusion vector-branch ranking instead of the fused
714 /// result (read from `vector_stage_only_for_test`, off by default).
715 vector_stage_only: bool,
716 /// 0.8.1 Slice 10 (R1) — raw query text for the CE reranker. Passed
717 /// from `search_inner` to `read_search_in_tx` → `rerank_fused`.
718 /// FIX-4: `Box<str>` (16 bytes) instead of `String` (24 bytes) to keep
719 /// the Search variant smaller (mirroring the boxed `filter` field).
720 raw_query: Box<str>,
721 /// 0.8.1 Slice 10 (R1) — per-request rerank depth (snapshot of
722 /// `ProjectionRuntimeShared::rerank_depth`). `0` = identity path.
723 rerank_depth: usize,
724 /// 0.8.1 Slice 30 (R3) — when `true`, run the graph-BFS arm (seeded
725 /// from top-10 fused hits, depth ≤ 3, cap 50, temporal filter) and
726 /// fuse its candidates into the final ranking via `fuse_three_arms`.
727 /// When `false` (the default), the graph arm pool is `vec![]` and
728 /// results are byte-identical to the pre-Slice-30 two-arm pipeline.
729 use_graph_arm: bool,
730 /// 0.8.5 (EXP-0) — CE-blend weight (clamped to `[0,1]` in `ce_rerank`).
731 /// `0.3` is the byte-identical default; `1.0` is the measured-parity config.
732 alpha: f64,
733 /// 0.8.5 (EXP-0) — reranked-pool size (clamped to the hit count). The
734 /// binding resolves `pool_n.unwrap_or(rerank_depth)` before dispatch.
735 pool_n: usize,
736 /// 0.8.8 EXP-OBS (Slice 5) — when `true`, capture per-arm ranks + the
737 /// fused/CE score breakdown + query trace into a `SearchResult`
738 /// `Explanation` sidecar. `false` (the default for `search`/`search_filtered`/
739 /// `search_reranked`) does ZERO extra work and returns `explanation = None`
740 /// (R-OBS-2 zero-cost; byte-identical `results`).
741 explain: bool,
742 /// 0.8.20 Slice 15b fix-2 (R-20-NV / R-20-RV) — the VALIDITY view the
743 /// node-hydration SELECTs filter by. `ReadView::default()` reproduces
744 /// the pre-fix predicate on any corpus that never authored a window
745 /// (step 22 back-filled NULL/NULL with no DEFAULT, and `validity_sql`
746 /// treats NULL as unbounded ⇒ the conjunct is a provable no-op there).
747 /// The existence axis is refused upstream, never carried here.
748 view: ReadView,
749 respond: SyncSender<ReaderResponse>,
750 },
751 /// Slice 30 (G2) — active-only point lookup by `logical_id`. Returns one
752 /// slot per requested id, in request order, `None` where no active row
753 /// carries that id. Its own typed `respond` channel keeps the `Search`
754 /// `ReaderResponse` byte-identical (no Search regression).
755 GetById {
756 logical_ids: Vec<String>,
757 /// R-20-RV — the read view this lookup runs under. `ReadView::default()`
758 /// is the strict (pre-slice) view.
759 view: ReadView,
760 respond: SyncSender<rusqlite::Result<Vec<Option<NodeRecord>>>>,
761 },
762 /// Slice 30 (G3) — paginated op-store read-back over `operational_mutations`
763 /// for a `collection`, `ORDER BY id`, with a MANDATORY (already-clamped)
764 /// limit + optional after-id cursor.
765 ReadCollection {
766 collection: String,
767 after_id: Option<i64>,
768 limit: usize,
769 respond: SyncSender<rusqlite::Result<Vec<OpStoreRow>>>,
770 },
771 /// Slice 35 (G4) — list active canonical nodes of a `kind`, filtered by
772 /// zero or more `Predicate`s (AND-combined), up to `limit` rows.
773 /// Path validation already happened at `Predicate` construction time;
774 /// the worker only compiles + executes parameterized SQL.
775 ReadList {
776 kind: String,
777 predicates: Vec<Predicate>,
778 limit: usize,
779 /// R-20-RV — the read view this listing runs under.
780 view: ReadView,
781 respond: SyncSender<rusqlite::Result<Vec<NodeRecord>>>,
782 },
783 /// Slice 20 (G5) — bounded BFS from a single root node over
784 /// `canonical_edges`. Returns the set of reachable nodes (excluding the
785 /// root) within `depth` hops, limited to the hard cap 50.
786 GraphNeighbors {
787 root_logical_id: String,
788 depth: u32,
789 direction: TraversalDirection,
790 /// R-20-RV — the read view applied at EVERY node position of the BFS
791 /// CTE (anchor, recursive join, final projection), for every direction.
792 view: ReadView,
793 respond: SyncSender<rusqlite::Result<Vec<NodeRecord>>>,
794 },
795 /// 0.8.20 Slice 10b (R-20-NV) — nodes that crossed a validity boundary in
796 /// `(since, view-instant]`.
797 CrossedBoundarySince {
798 since: i64,
799 view: ReadView,
800 respond: SyncSender<rusqlite::Result<Vec<BoundaryCrossing>>>,
801 },
802 /// Slice 20 (G6) — compose the previous search result with BFS expansion.
803 /// Resolves search hit `write_cursor`s to `logical_id`s, runs G5 traversal
804 /// for each root, deduplicates, and returns a `SearchExpandResult`.
805 SearchExpand {
806 search_hits: Vec<SearchHit>,
807 depth: u32,
808 respond: SyncSender<rusqlite::Result<SearchExpandResult>>,
809 },
810 /// Slice 20 test seam — run `EXPLAIN QUERY PLAN` on the BFS CTE SQL for
811 /// the given root/depth/direction and return the plan detail lines.
812 #[doc(hidden)]
813 ExplainGraphNeighbors {
814 root_logical_id: String,
815 depth: u32,
816 direction: TraversalDirection,
817 respond: SyncSender<rusqlite::Result<Vec<String>>>,
818 },
819 Shutdown,
820 /// Pack 6.G G.1 — debug-only request that asks a worker to read its
821 /// own connection's `SQLITE_DBSTATUS_LOOKASIDE_USED` and return the
822 /// high-water mark (`hiwtr` out-param). Used solely by the integration
823 /// test that asserts post-warmup lookaside slots were consumed; not
824 /// on any production path.
825 #[cfg(debug_assertions)]
826 LookasideStatus {
827 respond: SyncSender<i32>,
828 },
829 /// Pack 6.G G.3.5 — debug-only request that asks a worker to read
830 /// `SQLITE_DBSTATUS_CACHE_HIT`, `_CACHE_MISS`, and `_CACHE_USED`
831 /// off its own connection and return them as `(hit, miss, used_bytes)`.
832 /// `snapshot_label` is opaque to the worker; the caller uses it to
833 /// distinguish pre/post snapshots in its own bookkeeping.
834 #[cfg(debug_assertions)]
835 CacheStatus {
836 snapshot_label: String,
837 respond: SyncSender<(String, i32, i32, i32)>,
838 },
839 /// OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — debug-only request
840 /// that asks a worker to read its own connection's `PRAGMA secure_delete`
841 /// and return it (`0`/`1`). Used solely by the gap-4 test that asserts the
842 /// standing secure_delete flag is ON at EVERY open, not just the writer.
843 #[cfg(debug_assertions)]
844 SecureDeleteStatus {
845 respond: SyncSender<i64>,
846 },
847}
848
849// G0 Phase-2: the Search response carries a 4th element — the graph-arm frontier
850// meter (`GraphFrontierStats`). It rides the internal channel but is dropped before
851// `SearchResult` is built (kept OFF the governed surface); the
852// `_graph_frontier_stats_for_test` seam captures it. Default (all-zero) on non-graph paths.
853// 0.8.8 EXP-OBS (Slice 5): the Search response carries a 5th element — the opt-in
854// retrieval `Explanation` (`None` on every default `explain=false` path; `Some`
855// only on the `search_explained` path). Like the `GraphFrontierStats` 4th element
856// it rides the internal channel as a side-channel; unlike it, the explanation IS
857// surfaced (onto `SearchResult.explanation`) when requested.
858type ReaderResponse = Result<
859 (u64, Option<SoftFallback>, Vec<SearchHit>, GraphFrontierStats, Option<Explanation>),
860 SearchReaderError,
861>;
862
863type ProjectedTextReaderResponse = Result<SearchResult, SearchReaderError>;
864
865/// 0.8.20 keystone closeout fix-3 (codex §9 [P2], TOCTOU) — the error a Search
866/// reader worker can return. Two arms:
867/// * `Sqlite` — a backend/storage failure (the pre-fix-3 `rusqlite::Result`
868/// behaviour verbatim; the caller emits the internal-error event and maps to
869/// `EngineError::Storage`);
870/// * `InvalidFilter` — a filter naming an UNDECLARED `filterable` attribute,
871/// detected on the reader's OWN transaction snapshot (see
872/// [`validate_filter_attributes_on_snapshot`]). The caller re-raises it as the
873/// EXISTING `EngineError::InvalidFilter { reason }` typed variant.
874///
875/// Why a channel-carried variant and not a pre-dispatch check on the writer
876/// connection: fix-2 validated on `self.connection` BEFORE dispatch, then the
877/// reader prepared the vec0 query on a DIFFERENT connection/snapshot. A
878/// `configure_projections` DROP landing in that window let the vec0 `attr_<hex>`
879/// column vanish AFTER validation passed → an opaque `no such column` `Storage`
880/// error (the exact untyped failure fix-2 meant to prevent). Validating INSIDE
881/// the reader transaction that also compiles+executes the search binds the check
882/// and the query to ONE snapshot, closing the race; carrying the typed reason
883/// back through this variant keeps the outcome `InvalidFilter`, never `Storage`.
884enum SearchReaderError {
885 Sqlite(rusqlite::Error),
886 InvalidFilter(String),
887}
888
889impl From<rusqlite::Error> for SearchReaderError {
890 fn from(err: rusqlite::Error) -> Self {
891 SearchReaderError::Sqlite(err)
892 }
893}
894
895/// Pack 6.G G.3.5 — per-worker cache-pressure snapshot. Carried only on
896/// the debug-only `CacheStatus` broadcast path and the test accessor;
897/// not part of the public 0.6.0 surface.
898#[cfg(debug_assertions)]
899#[doc(hidden)]
900#[derive(Clone, Debug)]
901pub struct CacheStatusReply {
902 pub worker_idx: usize,
903 pub snapshot_label: String,
904 pub cache_hit: i32,
905 pub cache_miss: i32,
906 pub cache_used_bytes: i32,
907}
908
909/// Per-worker outbound channel capacity. Round-robin dispatch keeps
910/// queue depth at ~0 on hot paths; the small slack absorbs jitter
911/// without a runtime mutex.
912const READER_WORKER_CHANNEL_CAPACITY: usize = 4;
913
914impl ReaderWorkerPool {
915 fn new(connections: Vec<Connection>) -> Self {
916 let live_workers = Arc::new(AtomicUsize::new(0));
917 let mut senders = Vec::with_capacity(connections.len());
918 let mut handles = Vec::with_capacity(connections.len());
919 for (idx, connection) in connections.into_iter().enumerate() {
920 let (tx, rx) = mpsc::sync_channel::<ReaderRequest>(READER_WORKER_CHANNEL_CAPACITY);
921 let live = Arc::clone(&live_workers);
922 let handle = thread::Builder::new()
923 .name(format!("fathomdb-reader-{idx}"))
924 .spawn(move || reader_worker_loop(connection, rx, live))
925 .expect("spawn reader worker");
926 senders.push(tx);
927 handles.push(handle);
928 }
929 Self {
930 senders,
931 handles: Mutex::new(Some(handles)),
932 next: AtomicUsize::new(0),
933 shutdown: AtomicBool::new(false),
934 live_workers,
935 }
936 }
937
938 fn worker_count(&self) -> usize {
939 self.senders.len()
940 }
941
942 fn live_count(&self) -> usize {
943 self.live_workers.load(Ordering::SeqCst)
944 }
945
946 /// Pack 6.G G.1 — broadcast a `LookasideStatus` request to every
947 /// worker (not round-robin) and collect each worker's
948 /// `SQLITE_DBSTATUS_LOOKASIDE_USED`. Used only by the debug
949 /// integration test for post-warmup lookaside-slot consumption.
950 #[cfg(debug_assertions)]
951 fn lookaside_used_per_worker(&self) -> Vec<i32> {
952 let mut results = Vec::with_capacity(self.senders.len());
953 for sender in &self.senders {
954 let (tx, rx) = mpsc::sync_channel::<i32>(1);
955 if sender.send(ReaderRequest::LookasideStatus { respond: tx }).is_ok() {
956 results.push(rx.recv().unwrap_or(-1));
957 } else {
958 results.push(-1);
959 }
960 }
961 results
962 }
963
964 /// Pack 6.G G.3.5 — broadcast a `CacheStatus` request to every
965 /// worker and collect each worker's `(cache_hit, cache_miss,
966 /// cache_used_bytes)` triple. Same broadcast pattern as G.1's
967 /// `lookaside_used_per_worker`. Returns one `CacheStatusReply` per
968 /// worker in worker-index order.
969 #[cfg(debug_assertions)]
970 fn cache_status_per_worker(&self, snapshot_label: &str) -> Vec<CacheStatusReply> {
971 let mut results = Vec::with_capacity(self.senders.len());
972 for (idx, sender) in self.senders.iter().enumerate() {
973 let (tx, rx) = mpsc::sync_channel::<(String, i32, i32, i32)>(1);
974 let request = ReaderRequest::CacheStatus {
975 snapshot_label: snapshot_label.to_string(),
976 respond: tx,
977 };
978 if sender.send(request).is_ok() {
979 if let Ok((label, hit, miss, used)) = rx.recv() {
980 results.push(CacheStatusReply {
981 worker_idx: idx,
982 snapshot_label: label,
983 cache_hit: hit,
984 cache_miss: miss,
985 cache_used_bytes: used,
986 });
987 continue;
988 }
989 }
990 results.push(CacheStatusReply {
991 worker_idx: idx,
992 snapshot_label: snapshot_label.to_string(),
993 cache_hit: -1,
994 cache_miss: -1,
995 cache_used_bytes: -1,
996 });
997 }
998 results
999 }
1000
1001 /// OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — broadcast a
1002 /// `SecureDeleteStatus` request to every worker and collect each worker's
1003 /// `PRAGMA secure_delete` value. Same broadcast pattern as G.1's
1004 /// `lookaside_used_per_worker`. Proves the standing secure_delete flag is ON
1005 /// on the reader-pool connections, not just the writer.
1006 #[cfg(debug_assertions)]
1007 fn secure_delete_per_worker(&self) -> Vec<i64> {
1008 let mut results = Vec::with_capacity(self.senders.len());
1009 for sender in &self.senders {
1010 let (tx, rx) = mpsc::sync_channel::<i64>(1);
1011 if sender.send(ReaderRequest::SecureDeleteStatus { respond: tx }).is_ok() {
1012 results.push(rx.recv().unwrap_or(-1));
1013 } else {
1014 results.push(-1);
1015 }
1016 }
1017 results
1018 }
1019
1020 /// Hot path. Lock-free dispatch: `AtomicUsize::fetch_add` selects
1021 /// the worker, then a single `SyncSender::send` enqueues the
1022 /// request. No global mutex is taken on the request path.
1023 // The `Search` variant contains a SyncSender and boxed fields (filter, raw_query);
1024 // even after FIX-4 (raw_query: Box<str>), the variant remains large due to the
1025 // SyncSender channel ownership. The Err return is only ever a no-worker/shutdown
1026 // signal, never heap-allocated repeatedly, so the allow is justified by the
1027 // channel ownership model.
1028 #[allow(clippy::result_large_err)]
1029 fn dispatch(&self, request: ReaderRequest) -> Result<(), ReaderRequest> {
1030 if self.shutdown.load(Ordering::Relaxed) {
1031 return Err(request);
1032 }
1033 let n = self.senders.len();
1034 if n == 0 {
1035 return Err(request);
1036 }
1037 let idx = self.next.fetch_add(1, Ordering::Relaxed) % n;
1038 self.senders[idx].send(request).map_err(|err| err.0)
1039 }
1040
1041 /// Signal every worker to exit and join its thread. Idempotent —
1042 /// safe to call from `Engine::close` and again from
1043 /// `ReaderWorkerPool::Drop`.
1044 fn shutdown(&self) {
1045 if self.shutdown.swap(true, Ordering::SeqCst) {
1046 return;
1047 }
1048 for sender in &self.senders {
1049 let _ = sender.send(ReaderRequest::Shutdown);
1050 }
1051 if let Ok(mut slot) = self.handles.lock() {
1052 if let Some(handles) = slot.take() {
1053 for handle in handles {
1054 let _ = handle.join();
1055 }
1056 }
1057 }
1058 }
1059}
1060
1061impl Drop for ReaderWorkerPool {
1062 fn drop(&mut self) {
1063 self.shutdown();
1064 }
1065}
1066
1067fn reader_worker_loop(
1068 mut connection: Connection,
1069 rx: Receiver<ReaderRequest>,
1070 live_workers: Arc<AtomicUsize>,
1071) {
1072 live_workers.fetch_add(1, Ordering::SeqCst);
1073 // Drop guard so the live counter decrements even on panic.
1074 struct LiveGuard(Arc<AtomicUsize>);
1075 impl Drop for LiveGuard {
1076 fn drop(&mut self) {
1077 self.0.fetch_sub(1, Ordering::SeqCst);
1078 }
1079 }
1080 let _guard = LiveGuard(live_workers);
1081
1082 while let Ok(request) = rx.recv() {
1083 match request {
1084 ReaderRequest::Shutdown => break,
1085 ReaderRequest::SearchProjectedText { query, name, filter, limit, view, respond } => {
1086 let result = read_projected_text_in_tx(
1087 &mut connection,
1088 &query,
1089 &name,
1090 filter.as_deref(),
1091 limit,
1092 view,
1093 );
1094 let _ = respond.send(result);
1095 }
1096 ReaderRequest::Search {
1097 compiled,
1098 query_vector,
1099 query_vector_bin,
1100 search_limit,
1101 filter,
1102 recency_enabled,
1103 importance_enabled,
1104 vector_stage_only,
1105 raw_query,
1106 rerank_depth,
1107 use_graph_arm,
1108 alpha,
1109 pool_n,
1110 explain,
1111 view,
1112 respond,
1113 } => {
1114 let result = read_search_in_tx(
1115 &mut connection,
1116 &compiled,
1117 query_vector.as_deref(),
1118 query_vector_bin.as_deref(),
1119 search_limit,
1120 filter.as_deref(),
1121 recency_enabled,
1122 importance_enabled,
1123 vector_stage_only,
1124 &raw_query,
1125 rerank_depth,
1126 use_graph_arm,
1127 alpha,
1128 pool_n,
1129 explain,
1130 view,
1131 );
1132 // Receiver may have been dropped if the caller went
1133 // away; nothing to do in that case.
1134 let _ = respond.send(result);
1135 }
1136 ReaderRequest::GetById { logical_ids, view, respond } => {
1137 let result = read_get_by_id_in_tx(&mut connection, &logical_ids, &view);
1138 let _ = respond.send(result);
1139 }
1140 ReaderRequest::ReadCollection { collection, after_id, limit, respond } => {
1141 let result = read_collection_in_tx(&mut connection, &collection, after_id, limit);
1142 let _ = respond.send(result);
1143 }
1144 ReaderRequest::ReadList { kind, predicates, limit, view, respond } => {
1145 let result = read_list_in_tx(&mut connection, &kind, &predicates, limit, &view);
1146 let _ = respond.send(result);
1147 }
1148 ReaderRequest::GraphNeighbors { root_logical_id, depth, direction, view, respond } => {
1149 let result = graph_neighbors_in_tx(
1150 &mut connection,
1151 &root_logical_id,
1152 depth,
1153 direction,
1154 &view,
1155 );
1156 let _ = respond.send(result);
1157 }
1158 ReaderRequest::CrossedBoundarySince { since, view, respond } => {
1159 let result = crossed_boundary_since_in_tx(&mut connection, since, &view);
1160 let _ = respond.send(result);
1161 }
1162 ReaderRequest::SearchExpand { search_hits, depth, respond } => {
1163 let result = search_expand_in_tx(&mut connection, &search_hits, depth);
1164 let _ = respond.send(result);
1165 }
1166 ReaderRequest::ExplainGraphNeighbors { root_logical_id, depth, direction, respond } => {
1167 let result = explain_graph_neighbors_in_tx(
1168 &mut connection,
1169 &root_logical_id,
1170 depth,
1171 direction,
1172 );
1173 let _ = respond.send(result);
1174 }
1175 #[cfg(debug_assertions)]
1176 ReaderRequest::LookasideStatus { respond } => {
1177 let _ = respond.send(read_lookaside_used_hiwtr(&connection));
1178 }
1179 #[cfg(debug_assertions)]
1180 ReaderRequest::CacheStatus { snapshot_label, respond } => {
1181 let (hit, miss, used) = read_cache_status(&connection);
1182 let _ = respond.send((snapshot_label, hit, miss, used));
1183 }
1184 #[cfg(debug_assertions)]
1185 ReaderRequest::SecureDeleteStatus { respond } => {
1186 let value: i64 =
1187 connection.query_row("PRAGMA secure_delete", [], |r| r.get(0)).unwrap_or(-1);
1188 let _ = respond.send(value);
1189 }
1190 }
1191 }
1192
1193 // Per `dev/design/engine.md` § Close path, uninstall the profile
1194 // callback before dropping the connection so SQLite cannot fire
1195 // one last callback against a `ProfileContext` whose Box is about
1196 // to free.
1197 uninstall_profile_callback(&connection);
1198 drop(connection);
1199}
1200
1201/// 0.8.20 keystone closeout fix-3 — a test-only rendezvous hook fired at the TOP
1202/// of [`read_search_in_tx`], BEFORE the reader opens its deferred transaction.
1203///
1204/// It exists ONLY to make the validate/execute TOCTOU race deterministic: a test
1205/// arms a closure that parks the reader worker here (after the caller-side search
1206/// setup, before the reader pins its snapshot), performs a concurrent
1207/// `configure_projections` DROP of a `filterable` attribute on the writer
1208/// connection, then releases the reader. The reader then pins a snapshot that
1209/// INCLUDES the drop — exactly the window that used to yield an opaque `no such
1210/// column` `Storage` error and now yields a typed `InvalidFilter`. Kept OFF the
1211/// governed surface (`_for_test`), mirroring the sanctioned
1212/// `set_vector_stage_only_for_test` seam pattern. Disarmed by default: a single
1213/// `Relaxed` atomic load per search (same class as the four hot-path atomics
1214/// already read here), fires at most once (the closure is `take`n), and is a
1215/// no-op in production because nothing ever arms it.
1216mod reader_search_hook {
1217 use std::sync::atomic::{AtomicBool, Ordering};
1218 use std::sync::Mutex;
1219
1220 static ARMED: AtomicBool = AtomicBool::new(false);
1221 #[allow(clippy::type_complexity)]
1222 static HOOK: Mutex<Option<Box<dyn Fn() + Send>>> = Mutex::new(None);
1223
1224 pub(crate) fn arm(hook: Box<dyn Fn() + Send>) {
1225 *HOOK.lock().expect("reader-search hook mutex") = Some(hook);
1226 ARMED.store(true, Ordering::SeqCst);
1227 }
1228
1229 pub(crate) fn clear() {
1230 ARMED.store(false, Ordering::SeqCst);
1231 *HOOK.lock().expect("reader-search hook mutex") = None;
1232 }
1233
1234 /// Fire the armed hook exactly ONCE, then disarm. Cheap early-out when
1235 /// disarmed (the production and common-test path).
1236 pub(crate) fn fire() {
1237 if !ARMED.load(Ordering::SeqCst) {
1238 return;
1239 }
1240 // Disarm first so a re-entrant / second reader never re-fires.
1241 ARMED.store(false, Ordering::SeqCst);
1242 let hook = HOOK.lock().expect("reader-search hook mutex").take();
1243 if let Some(hook) = hook {
1244 hook();
1245 }
1246 }
1247}
1248
1249/// 0.8.20 keystone closeout fix-3 — arm the [`reader_search_hook`] (test-only).
1250/// See that module's docs. `#[doc(hidden)]`, `_for_test`; never re-exported from
1251/// the `fathomdb` facade.
1252#[doc(hidden)]
1253pub fn arm_reader_search_hook_for_test(hook: Box<dyn Fn() + Send>) {
1254 reader_search_hook::arm(hook);
1255}
1256
1257/// 0.8.20 keystone closeout fix-3 — disarm the [`reader_search_hook`] (test-only).
1258#[doc(hidden)]
1259pub fn clear_reader_search_hook_for_test() {
1260 reader_search_hook::clear();
1261}
1262
1263impl ProjectionRuntime {
1264 fn new(
1265 path: PathBuf,
1266 embedder: Option<Arc<dyn Embedder>>,
1267 embedder_identity: EmbedderIdentity,
1268 mean_already_pinned: bool,
1269 subscribers: Arc<lifecycle::SubscriberRegistry>,
1270 ) -> Self {
1271 // EU-5b/EU-5f — only allocate the streaming accumulator when the
1272 // workspace's identity is MC-required AND no mean has been pinned
1273 // yet on disk. Allocating it for an already-pinned workspace would
1274 // let a later 256-doc run RE-pin and overwrite the compute-once
1275 // mean (violating `dev/design/embedder.md` §0.3). Other identities
1276 // pay no memory cost (`Option::None`).
1277 let mc_required = identity_requires_mean_centering(&embedder_identity);
1278 let mean_accumulator = if mc_required && !mean_already_pinned {
1279 Some(MeanAccumulator::new(embedder_identity.dimension as usize))
1280 } else {
1281 None
1282 };
1283 let shared = Arc::new(ProjectionRuntimeShared {
1284 path,
1285 embedder,
1286 embedder_identity,
1287 subscribers,
1288 state: Mutex::new(ProjectionRuntimeState::default()),
1289 state_cvar: Condvar::new(),
1290 queue: Mutex::new(VecDeque::new()),
1291 queue_cvar: Condvar::new(),
1292 retry_delays_ms: Mutex::new(DEFAULT_PROJECTION_RETRY_DELAYS_MS.to_vec()),
1293 embed_timeout_ms: AtomicU64::new(DEFAULT_EMBED_TIMEOUT_MS),
1294 embed_serialize: Mutex::new(()),
1295 live_embed_threads: Arc::new(AtomicU64::new(0)),
1296 embed_circuit_open: AtomicBool::new(false),
1297 embed_circuit_threshold: AtomicU64::new(DEFAULT_EMBED_CIRCUIT_THRESHOLD),
1298 mean_accumulator: Mutex::new(mean_accumulator),
1299 pending_events: Mutex::new(Vec::new()),
1300 commit_gate: Mutex::new(()),
1301 search_limit_override: AtomicUsize::new(SEARCH_RERANK_LIMIT),
1302 recency_reweight_enabled: AtomicBool::new(false),
1303 importance_reweight_enabled: AtomicBool::new(false),
1304 vector_stage_only_for_test: AtomicBool::new(false),
1305 #[cfg(debug_assertions)]
1306 force_recompute_failure: AtomicBool::new(false),
1307 #[cfg(debug_assertions)]
1308 force_projection_commit_failure: AtomicUsize::new(0),
1309 #[cfg(debug_assertions)]
1310 projection_commit_failure_pause: Mutex::new(None),
1311 #[cfg(debug_assertions)]
1312 projection_stop_ack: Mutex::new(None),
1313 });
1314
1315 let dispatcher_shared = Arc::clone(&shared);
1316 let dispatcher = thread::spawn(move || projection_dispatcher_loop(dispatcher_shared));
1317
1318 let mut workers = Vec::with_capacity(PROJECTION_WORKERS);
1319 for _ in 0..PROJECTION_WORKERS {
1320 let worker_shared = Arc::clone(&shared);
1321 workers.push(thread::spawn(move || projection_worker_loop(worker_shared)));
1322 }
1323
1324 Self { shared, dispatcher: Mutex::new(Some(dispatcher)), workers: Mutex::new(workers) }
1325 }
1326
1327 fn notify_new_work(&self) {
1328 if let Ok(mut state) = self.shared.state.lock() {
1329 state.pending_scan = true;
1330 self.shared.state_cvar.notify_all();
1331 }
1332 }
1333
1334 fn set_frozen(&self, frozen: bool) {
1335 if let Ok(mut state) = self.shared.state.lock() {
1336 state.frozen = frozen;
1337 if !frozen {
1338 state.pending_scan = true;
1339 }
1340 self.shared.state_cvar.notify_all();
1341 }
1342 }
1343
1344 fn wait_for_idle(&self, timeout_ms: u64) -> bool {
1345 let deadline = Instant::now() + Duration::from_millis(timeout_ms);
1346 let mut state = match self.shared.state.lock() {
1347 Ok(state) => state,
1348 Err(_) => return false,
1349 };
1350 loop {
1351 if state.active_jobs == 0 && state.queued_jobs == 0 {
1352 drop(state);
1353 if !database_has_pending_projection_work(&self.shared.path).unwrap_or(true) {
1354 return true;
1355 }
1356 state = match self.shared.state.lock() {
1357 Ok(state) => state,
1358 Err(_) => return false,
1359 };
1360 }
1361 let now = Instant::now();
1362 if now >= deadline {
1363 return false;
1364 }
1365 let wait = deadline.saturating_duration_since(now);
1366 let Ok((next_state, _)) = self.shared.state_cvar.wait_timeout(state, wait) else {
1367 return false;
1368 };
1369 state = next_state;
1370 }
1371 }
1372
1373 fn set_retry_delays_for_test(&self, delays_ms: &[u64]) {
1374 if let Ok(mut delays) = self.shared.retry_delays_ms.lock() {
1375 *delays = delays_ms.to_vec();
1376 }
1377 }
1378
1379 #[cfg(debug_assertions)]
1380 fn force_next_projection_commit_failure_for_test(&self) {
1381 self.shared.force_projection_commit_failure.store(1, Ordering::SeqCst);
1382 }
1383
1384 #[cfg(debug_assertions)]
1385 fn force_next_projection_storage_failure_for_test(&self) {
1386 self.shared.force_projection_commit_failure.store(2, Ordering::SeqCst);
1387 }
1388
1389 #[cfg(debug_assertions)]
1390 fn pause_projection_commit_failure_cleanup_for_test(
1391 &self,
1392 reported: Arc<Barrier>,
1393 release: Arc<Barrier>,
1394 ) {
1395 *self
1396 .shared
1397 .projection_commit_failure_pause
1398 .lock()
1399 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((reported, release));
1400 }
1401
1402 #[cfg(debug_assertions)]
1403 fn acknowledge_projection_stop_for_test(&self, acknowledged: Arc<Barrier>) {
1404 *self.shared.projection_stop_ack.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) =
1405 Some(acknowledged);
1406 }
1407
1408 fn set_embed_timeout_ms_for_test(&self, timeout_ms: u64) {
1409 self.shared.embed_timeout_ms.store(timeout_ms, Ordering::Relaxed);
1410 }
1411
1412 fn set_embed_circuit_threshold_for_test(&self, threshold: u64) {
1413 self.shared.embed_circuit_threshold.store(threshold, Ordering::Relaxed);
1414 }
1415
1416 fn embed_circuit_open_for_test(&self) -> bool {
1417 self.shared.embed_circuit_open.load(Ordering::Relaxed)
1418 }
1419
1420 fn stop(&self) {
1421 if let Ok(mut state) = self.shared.state.lock() {
1422 if state.stopping {
1423 return;
1424 }
1425 state.stopping = true;
1426 state.pending_scan = false;
1427 self.shared.state_cvar.notify_all();
1428 }
1429 #[cfg(debug_assertions)]
1430 if let Some(acknowledged) = self
1431 .shared
1432 .projection_stop_ack
1433 .lock()
1434 .unwrap_or_else(|poisoned| poisoned.into_inner())
1435 .take()
1436 {
1437 acknowledged.wait();
1438 }
1439 if let Ok(mut queue) = self.shared.queue.lock() {
1440 queue.clear();
1441 self.shared.queue_cvar.notify_all();
1442 }
1443
1444 if let Ok(mut dispatcher) = self.dispatcher.lock() {
1445 if let Some(handle) = dispatcher.take() {
1446 let _ = handle.join();
1447 }
1448 }
1449 if let Ok(mut workers) = self.workers.lock() {
1450 for handle in workers.drain(..) {
1451 let _ = handle.join();
1452 }
1453 }
1454 }
1455}
1456
1457#[derive(Clone, Debug, Eq, PartialEq)]
1458pub struct OpenReport {
1459 pub schema_version_before: u32,
1460 pub schema_version_after: u32,
1461 pub migration_steps: Vec<MigrationStepReport>,
1462 pub embedder_warmup_ms: u64,
1463 pub query_backend: &'static str,
1464 pub default_embedder: EmbedderIdentity,
1465 /// Total wall time the loader spent materializing default-embedder
1466 /// weights — covers HF GETs, sha256 verification, atomic rename,
1467 /// parent-dir fsync (POSIX), and cache directory writes. This is
1468 /// the "engine open paid by the embedder" envelope, useful for SLA
1469 /// budgeting; it is intentionally wider than just the bytes-flowing
1470 /// time so callers see the full first-use cost.
1471 ///
1472 /// `Some(ms)` when network bytes flowed (`bytes_downloaded > 0`);
1473 /// `None` for caller-supplied embedders (loader bypassed) and on
1474 /// full cache hits (no bytes flowed). For pure per-file network
1475 /// analysis, use the `DefaultEmbedderDownload` events on
1476 /// [`embedder_events`](Self::embedder_events) — each event carries
1477 /// the file's bytes + sha256 + cache path.
1478 pub embedder_download_ms: Option<u64>,
1479 /// Structured loader events (`dev/design/embedder.md` §7). Empty for
1480 /// caller-supplied embedders; populated from `LoadedWeights.events`
1481 /// for the Default path.
1482 pub embedder_events: Vec<EmbedderEvent>,
1483 /// Static identity capability (`dev/design/embedder.md` §0.6). True
1484 /// iff the live embedder identity is the bge-small default, which is
1485 /// the only identity that ships with the EU-5a2 mean-centering apply
1486 /// paths. `false` for `fathomdb-noop` and for any other
1487 /// caller-supplied identity. EU-5b's identity flip makes the Default
1488 /// path return `true` here.
1489 pub embedder_mean_centering_required: bool,
1490 /// Dynamic workspace state (`dev/design/embedder.md` §0.6). True iff
1491 /// `_fathomdb_embedder_profiles.mean_vec IS NOT NULL` for the default
1492 /// profile. EU-5a2 reads from the schema column added in migration
1493 /// step 10; the value is dimension-validated (§0.2) at open time
1494 /// and fails closed via `EmbedderIdentityMismatch` on drift.
1495 pub embedder_mean_vec_pinned: bool,
1496 /// 0.8.18 Slice 5 (#5 vector-equivalence probe, R-VEQ-6) — degraded-open
1497 /// observability. `true` iff the open-time #5 self-check re-embedded the 45
1498 /// committed probes and found a divergence beyond the frozen D4 floor (a
1499 /// Phase-1 mean-centered `embedding_bin` sign flip OR a Phase-2 un-centered
1500 /// L2 over `VECTOR_EQUIVALENCE_L2_EPSILON`). When `true`, `Engine::open`
1501 /// SUCCEEDED but every vector-dependent arm refuses at query time with
1502 /// `EngineError::VectorEquivalenceMismatch`; the text-only/FTS-only path stays
1503 /// serviceable. The state is RE-DERIVED at every open (the probe re-runs), so
1504 /// a reopen with a still-divergent backend stays degraded (never silently
1505 /// re-enables dense) and a reopen with a matching backend clears it.
1506 pub dense_disabled: bool,
1507 /// R-VEQ-6 — human-readable reason for `dense_disabled` (which representation
1508 /// tripped: P1 flip count or P2 L2). `None` when `dense_disabled == false`.
1509 pub dense_disabled_reason: Option<String>,
1510}
1511
1512#[derive(Debug)]
1513pub struct OpenedEngine {
1514 pub engine: Engine,
1515 pub report: OpenReport,
1516}
1517
1518/// EU-5b — loader-supplied open-time telemetry threaded into
1519/// `OpenReport.embedder_download_ms` and `OpenReport.embedder_events`.
1520#[derive(Clone, Debug)]
1521struct LoaderInfo {
1522 download_ms: Option<u64>,
1523 events: Vec<EmbedderEvent>,
1524}
1525
1526#[derive(Clone, Debug, Eq, PartialEq)]
1527pub struct WriteReceipt {
1528 /// The batch high-water cursor — the `write_cursor` of the last row written
1529 /// (also the engine's new `next_cursor`). Unchanged from 0.7.x.
1530 pub cursor: u64,
1531 /// G0 (Slice 15) — the per-row `write_cursor` of each row in the batch, 1:1
1532 /// with input order. This is the `write_cursor`-as-row-id identity carrier
1533 /// (HITL-accepted for 0.8.0; a dedicated `row_id` is deferred). For an
1534 /// N-row batch this is `[cursor-N+1, …, cursor]`.
1535 pub row_cursors: Vec<u64>,
1536 /// G8 (Slice 20 / F10) — count of edge endpoints in this batch that point at
1537 /// a non-existent **or superseded** canonical node. An endpoint is dangling
1538 /// when no **active** node (`superseded_at IS NULL`) carries its `logical_id`;
1539 /// `from_id` and `to_id` are probed independently, so one edge contributes 0,
1540 /// 1, or 2. This is **informational** (default FLAG-AND-COUNT: the batch
1541 /// commits regardless) and `0` whenever the batch committed no active edges.
1542 pub dangling_edge_endpoints: u64,
1543}
1544
1545/// Soft-fallback signal carried on hybrid `search` results.
1546///
1547/// Per `dev/design/retrieval.md` § Soft-fallback signal, this record is
1548/// present only when one non-essential branch could not contribute. Total
1549/// request failure is not expressed via this carrier.
1550#[derive(Clone, Debug, Eq, PartialEq)]
1551pub struct SoftFallback {
1552 pub branch: SoftFallbackBranch,
1553}
1554
1555/// Which retrieval branch produced a hit (or could not contribute).
1556///
1557/// `Vector` = ANN vector branch (node bodies); `Text` = node-body FTS branch;
1558/// `TextEdge` = edge-body hit (FTS via `search_index_edges` OR vector-projected
1559/// edge facts — both produce the same kind="edge_fact" row shape and share the
1560/// same downstream handling in `search_expand_in_tx`). `Vector`/`Text` also
1561/// used as soft-fallback signal when the respective branch is empty.
1562/// `GraphArm` = R3 (Slice 30) BFS-reachable node from the temporal fact-edge
1563/// graph arm. Owned by `dev/design/retrieval.md`.
1564#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1565pub enum SoftFallbackBranch {
1566 Vector,
1567 Text,
1568 /// G11 (Slice 15) — edge-body hit from `search_index_edges` FTS or from
1569 /// `vector_default` edge-fact projection. `kind = "edge_fact"` in both cases.
1570 TextEdge,
1571 /// R3 (Slice 30) — BFS-reachable node from the temporal fact-edge graph arm.
1572 /// Only present when `use_graph_arm = true`. Nodes in the graph arm were NOT
1573 /// in the initial vector/text fused result (newly-reached nodes only).
1574 GraphArm,
1575}
1576
1577/// A single structured search hit (G1 / AC-057a-clean).
1578///
1579/// Both retrieval branches emit this shape. `id` is a typed [`IdSpace`] — the
1580/// **permanent** caller-facing identity since C-2 (0.8.19 / TC-8), NOT the
1581/// interim `write_cursor: u64` the pre-0.8.19 releases carried and NOT an
1582/// interim carrier awaiting a later swap. The positional `write_cursor` field
1583/// below survives as engine-internal book-keeping and the SDK bindings do not
1584/// surface it. See the field docs on [`SearchHit::id`] / [`IdSpace`].
1585/// `score` is the **G9 RRF-fused** relevance (`Σ 1/(RRF_K + rank)` over the
1586/// branches that surfaced this body; higher = more relevant), optionally
1587/// recency-reweighted when the dedicated recency flag is on. Raw `vec_distance_l2`
1588/// and `bm25()` are fused on **rank**, never compared raw (they are not
1589/// comparable). `branch` tags which retrieval branch produced the representative
1590/// hit (vector-first when a body is surfaced by both).
1591///
1592/// `source_id` (G0 Phase-2 / BLOCK-2; generalised by TC-31 in 0.8.20 Slice 10a)
1593/// carries the source-document provenance of a hit — the identifier
1594/// [`Engine::erase_source`] consumes. It is populated on **every** hit path:
1595/// - **Node hits** (text/BM25F, vector, and the pre-step-12 legacy text
1596/// fallback) carry the **node's own** `canonical_nodes.source_id`.
1597/// - **Edge hits** (edge-FTS from `search_index_edges`, and edge-fact hits
1598/// hydrated by the vector arm) carry the **edge's own**
1599/// `canonical_edges.source_id`.
1600/// - **GraphArm** hits carry the **traversed edge's** `source_id` (the session
1601/// the fact-edge was extracted from) — unchanged by TC-31 — enabling
1602/// `doc_id_of` to resolve a graph-reached entity back to a gold session id.
1603///
1604/// Before TC-31 only the GraphArm branch populated this, which left
1605/// `erase_source` shipping with its argument unreachable from a text or vector
1606/// hit (0.8.19 also stopped surfacing `write_cursor` to the SDKs, removing the
1607/// only fallback route). It stays `Option<String>`: a row written before 0.8.20,
1608/// or a GOVERNED row deliberately spared by the step-21 backfill under the TC-11
1609/// pin, legitimately carries NULL at rest and must read back as `None` rather
1610/// than a fabricated value.
1611///
1612/// The field never participates in ranking, so result order and scores are
1613/// unaffected.
1614///
1615/// C-2 (0.8.19 / OPP-12 record-lifecycle Phase-1, TC-8) — the **id-space** of a
1616/// [`SearchHit::id`]. A closed, typed enum (NOT a magic-prefixed string) — the
1617/// C-2 binding ratified in the OPP-12 protocol:
1618/// - [`Logical`](IdSpaceKind::Logical) — `"l:"`, a governed/canonical node keyed
1619/// by its `logical_id` (the only lifecycle-addressable space).
1620/// - [`Content`](IdSpaceKind::Content) — `"h:"`, a doc-seeded/anonymous node
1621/// keyed by a content hash of its body (the dominant corpus hit class).
1622/// - [`Passage`](IdSpaceKind::Passage) — `"p:"`, a synthetic `rerank_passages`
1623/// hit keyed by the caller-supplied passage ordinal.
1624#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1625pub enum IdSpaceKind {
1626 /// `"l:"` — governed/canonical node (its `logical_id`).
1627 Logical,
1628 /// `"h:"` — doc-seeded/anonymous node (content hash of the body).
1629 Content,
1630 /// `"p:"` — synthetic rerank passage (caller-supplied ordinal).
1631 Passage,
1632}
1633
1634impl IdSpaceKind {
1635 /// The two-char id-space prefix (`"l:"` / `"h:"` / `"p:"`) used in the
1636 /// prefixed string form. Byte-identical to the pre-swap `derive_stable_id`
1637 /// tags so real-gold keying stays a no-op.
1638 #[must_use]
1639 pub fn prefix(self) -> &'static str {
1640 match self {
1641 Self::Logical => "l:",
1642 Self::Content => "h:",
1643 Self::Passage => "p:",
1644 }
1645 }
1646
1647 /// The lowercase discriminant (`"logical"` / `"content"` / `"passage"`)
1648 /// surfaced through the SDK bindings as the `IdSpace.space` field (mirrors
1649 /// how `SoftFallbackBranch` is surfaced as a `branch` string).
1650 #[must_use]
1651 pub fn as_str(self) -> &'static str {
1652 match self {
1653 Self::Logical => "logical",
1654 Self::Content => "content",
1655 Self::Passage => "passage",
1656 }
1657 }
1658}
1659
1660/// C-2 (0.8.19 / OPP-12 Phase-1, TC-8) — the typed, non-null, id-space-**total**
1661/// carrier for [`SearchHit::id`]. Subsumes the interim `write_cursor` id AND the
1662/// additive Cause-A `stable_id` field of prior releases: the `value` is the BARE
1663/// id (prefix stripped), and [`to_prefixed`](IdSpace::to_prefixed) reproduces the
1664/// pre-swap `stable_id` string byte-for-byte (`l:`/`h:` unchanged) so
1665/// cross-session real-gold keying continues on `id` as a true no-op.
1666///
1667/// Lifecycle-addressability is a type check consumed downstream by the
1668/// `transition`/`purge` verbs: only [`Logical`](IdSpaceKind::Logical) is
1669/// lifecycle-addressable; `Content`/`Passage` are total-but-not-addressable.
1670#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1671pub struct IdSpace {
1672 /// The typed id-space (`Logical`/`Content`/`Passage`).
1673 pub space: IdSpaceKind,
1674 /// The bare id value (id-space prefix stripped).
1675 pub value: String,
1676}
1677
1678impl IdSpace {
1679 /// A `Logical` (`"l:"`) id carrying `value` (a `logical_id`).
1680 pub fn logical(value: impl Into<String>) -> Self {
1681 Self { space: IdSpaceKind::Logical, value: value.into() }
1682 }
1683
1684 /// A `Content` (`"h:"`) id carrying `value` (a content hash).
1685 pub fn content(value: impl Into<String>) -> Self {
1686 Self { space: IdSpaceKind::Content, value: value.into() }
1687 }
1688
1689 /// A `Passage` (`"p:"`) id carrying `value` (a caller-supplied ordinal).
1690 pub fn passage(value: impl Into<String>) -> Self {
1691 Self { space: IdSpaceKind::Passage, value: value.into() }
1692 }
1693
1694 /// The prefixed string form (`{prefix}{value}`) — byte-identical to the
1695 /// pre-swap `derive_stable_id` output for `l:`/`h:`.
1696 #[must_use]
1697 pub fn to_prefixed(&self) -> String {
1698 format!("{}{}", self.space.prefix(), self.value)
1699 }
1700
1701 /// Parse the prefixed string form back into a typed `IdSpace`. Round-trip
1702 /// stable: `IdSpace::parse(&x.to_prefixed()) == Some(x)`. Only the FIRST
1703 /// two-char id-space prefix is stripped, so a value that itself contains
1704 /// `":"` round-trips unchanged. Returns `None` for an untagged string.
1705 #[must_use]
1706 pub fn parse(s: &str) -> Option<Self> {
1707 if let Some(v) = s.strip_prefix("l:") {
1708 Some(Self::logical(v))
1709 } else if let Some(v) = s.strip_prefix("h:") {
1710 Some(Self::content(v))
1711 } else {
1712 s.strip_prefix("p:").map(Self::passage)
1713 }
1714 }
1715}
1716
1717impl std::fmt::Display for IdSpace {
1718 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1719 write!(f, "{}{}", self.space.prefix(), self.value)
1720 }
1721}
1722
1723/// Derives `Clone, Debug, PartialEq` but **not `Eq`** — `score: f64` forbids
1724/// total equality.
1725#[derive(Clone, Debug, PartialEq)]
1726pub struct SearchHit {
1727 /// C-2 (0.8.19 / TC-8) — the typed, non-null, id-space-total hit id
1728 /// ([`IdSpace`]). Was the interim `write_cursor: u64` in prior releases; now
1729 /// carries the cross-session-stable key: `value` is the BARE (prefix-stripped)
1730 /// id, and [`to_prefixed`](IdSpace::to_prefixed) (== `{prefix}{value}`)
1731 /// reproduces the pre-swap `stable_id` byte-for-byte (the real-gold-keying
1732 /// no-op). Governed hits are `l:`, doc-seeded hits `h:`, synthetic
1733 /// passages `p:`. This is the caller-facing identity; the positional
1734 /// `write_cursor` below is engine-internal book-keeping.
1735 pub id: IdSpace,
1736 /// Engine-internal positional cursor (the value `id` carried before the C-2
1737 /// swap). Reassigned on every re-projection/re-ingest — NOT cross-session
1738 /// stable, NOT the caller-facing id. Retained because the engine still needs
1739 /// a positional cursor for its own book-keeping (vector rowid mapping, the
1740 /// `state='active'` filter lookups, RRF recency/importance reweight keys,
1741 /// telemetry `result_ids` keying, `search_expand` re-resolution). The SDK
1742 /// bindings do NOT surface it.
1743 pub write_cursor: u64,
1744 pub kind: String,
1745 pub body: String,
1746 pub score: f64,
1747 pub branch: SoftFallbackBranch,
1748 pub source_id: Option<String>,
1749 /// 0.8.5 (EXP-0) — per-candidate cross-encoder score `ce_norm =
1750 /// sigmoid(ce_logit) ∈ [0,1]`. `Some` ONLY for hits inside the reranked pool
1751 /// (the top `pool_n` when the CE model is loaded); `None` for the unreranked
1752 /// remainder, the `rerank_depth == 0` identity path, an empty list, and the
1753 /// no-CE-model soft-fallback. Additive + nullable: it never participates in
1754 /// ranking, so default-path ordering/scores stay byte-stable.
1755 pub ce_score: Option<f64>,
1756}
1757
1758/// G0 Phase-2 (E0a / BLOCK-1) — graph-arm frontier instrumentation. A
1759/// **side-channel** meter (deliberately NOT a `SearchResult`/`SearchHit` field —
1760/// byte stability) that proves whether the graph arm seeds a non-empty frontier.
1761/// Under the current doc-seeded path the frontier is empty (doc nodes carry
1762/// `logical_id = NULL`), so `seeds_resolved == 0` and `resolved_seed_rate == 0.0`
1763/// — this meter is the measurement that proves it (and, post-C1, the 0→>0 flip).
1764///
1765/// `resolved_seed_rate = seeds_resolved / seeds_considered`, with `0/0 → 0.0`.
1766#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1767pub struct GraphFrontierStats {
1768 /// Hits inspected as seed candidates (the `take(SEED_N)` window, skipping TextEdge).
1769 pub seeds_considered: u32,
1770 /// Seed candidates that resolved to an active `logical_id` (pushed onto the frontier).
1771 pub seeds_resolved: u32,
1772 /// Whether the BFS frontier was non-empty after seeding.
1773 pub frontier_nonempty: bool,
1774 /// Number of graph-arm `SearchHit`s emitted (reachable, not already in the two-arm result).
1775 pub graph_candidates_emitted: u32,
1776}
1777
1778impl GraphFrontierStats {
1779 /// `seeds_resolved / seeds_considered`, defined as `0.0` when nothing was considered.
1780 pub fn resolved_seed_rate(&self) -> f64 {
1781 if self.seeds_considered == 0 {
1782 0.0
1783 } else {
1784 f64::from(self.seeds_resolved) / f64::from(self.seeds_considered)
1785 }
1786 }
1787}
1788
1789/// Slice 30 (G2) — an active canonical node row returned by `read.get` /
1790/// `read.get_many`.
1791///
1792/// `logical_id` is the queried stable identity (echoed). `write_cursor` is the
1793/// interim id carrier (same column `SearchHit.id` carries). Only ACTIVE rows
1794/// (`superseded_at IS NULL`) are ever materialised into this shape; a missing or
1795/// superseded `logical_id` is a normal absence (`None`), never an error.
1796#[derive(Clone, Debug, Eq, PartialEq)]
1797pub struct NodeRecord {
1798 pub logical_id: String,
1799 pub kind: String,
1800 pub body: String,
1801 pub write_cursor: u64,
1802}
1803
1804/// 0.8.20 Slice 10b (R-20-RV / R-20-NV) — the **read view**: the single knob
1805/// that decides which `canonical_nodes` rows a read verb may see.
1806///
1807/// Every field is a *relaxation*: `ReadView::default()` is the STRICT view and
1808/// compiles to exactly the predicates the five read verbs carried before this
1809/// slice (`superseded_at IS NULL AND state = 'active'`), so the default read
1810/// path is behaviourally unchanged. Flags compose INDEPENDENTLY — each one
1811/// drops exactly one conjunct and no other.
1812///
1813/// The view is applied UNIFORMLY by [`Engine::read_get`],
1814/// [`Engine::read_get_many`], [`Engine::read_list`],
1815/// [`Engine::read_list_filter`] and [`Engine::graph_neighbors`] — and, inside
1816/// `graph_neighbors`, at EVERY position of EVERY direction's recursive CTE
1817/// (anchor, recursive join, final projection), so a relaxation cannot silently
1818/// apply on one traversal position and not another.
1819///
1820/// # World-time only
1821///
1822/// `valid_as_of` selects along the **world-time** (validity) axis only.
1823/// Transaction-time / `history_as_of` is explicitly OUT OF SCOPE — this type
1824/// deliberately has no way to ask "what did the database believe at time T".
1825#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1826pub struct ReadView {
1827 /// Relax `superseded_at IS NULL` — include superseded (historical) versions
1828 /// of a row, not just the current one. `false` (default) keeps the shipped
1829 /// current-version-only behaviour.
1830 ///
1831 /// On the point-lookup verbs ([`Engine::read_get`] /
1832 /// [`Engine::read_get_many`]) a `logical_id` can now match several rows;
1833 /// the slot resolves DETERMINISTICALLY to the highest `write_cursor` (the
1834 /// most recent version). Use [`Engine::read_list`] to enumerate history.
1835 pub include_superseded: bool,
1836
1837 /// Relax `state = 'active'` — include rows in a non-`active` lifecycle
1838 /// state (`pending` / `deleted` / `purged`). `false` (default) keeps the
1839 /// shipped active-only behaviour.
1840 pub include_inactive: bool,
1841
1842 /// Relax the validity-window predicate ENTIRELY — return rows whatever
1843 /// their `[valid_from, valid_until)` window, ignoring `valid_as_of`.
1844 /// `false` (default) filters to rows valid at the selected instant.
1845 ///
1846 /// Note this is a NO-OP on any row with an unbounded (NULL/NULL) window,
1847 /// which is every row that predates schema step 22.
1848 pub include_out_of_window: bool,
1849
1850 /// The instant (INTEGER epoch SECONDS, UTC) at which validity is evaluated.
1851 /// `None` (default) resolves to *now* at query time.
1852 ///
1853 /// This is the **`:now` seam**: whichever way it resolves, the instant is
1854 /// compiled as a BOUND PARAMETER, never a `datetime('now')` SQL literal —
1855 /// which is what makes node validity deterministically testable without
1856 /// clock games. (The shipped EDGE temporal filter still inlines
1857 /// `datetime('now')`; that path is untouched by this slice.)
1858 pub valid_as_of: Option<i64>,
1859}
1860
1861impl ReadView {
1862 /// The instant to bind for the validity predicate, or `None` when the view
1863 /// relaxes validity entirely (in which case no `:now` parameter is emitted
1864 /// and none must be bound).
1865 fn now_param(&self) -> Option<i64> {
1866 if self.include_out_of_window {
1867 return None;
1868 }
1869 Some(self.valid_as_of.unwrap_or_else(current_epoch_seconds))
1870 }
1871
1872 /// The existence conjunct for node-table `alias`. Each flag drops exactly
1873 /// one conjunct; the strict view reproduces the pre-slice predicate pair
1874 /// verbatim. Always begins with ` AND ` (or is empty), so every call site
1875 /// must already have a preceding `WHERE` predicate.
1876 fn existence_sql(&self, alias: &str) -> String {
1877 let mut sql = String::new();
1878 if !self.include_superseded {
1879 sql.push_str(&format!(" AND {alias}.superseded_at IS NULL"));
1880 }
1881 if !self.include_inactive {
1882 sql.push_str(&format!(" AND {alias}.state = 'active'"));
1883 }
1884 sql
1885 }
1886
1887 /// The validity conjunct for node-table `alias`, bound to positional
1888 /// parameter `?{now_idx}`. Empty when validity is relaxed.
1889 ///
1890 /// Encodes the HALF-OPEN window `[valid_from, valid_until)` with NULL
1891 /// meaning unbounded on that side — so a NULL/NULL row is valid at every
1892 /// instant and this conjunct never changes its visibility.
1893 fn validity_sql(&self, alias: &str, now_idx: usize) -> String {
1894 if self.include_out_of_window {
1895 return String::new();
1896 }
1897 format!(
1898 " AND ({alias}.valid_from IS NULL OR {alias}.valid_from <= ?{now_idx}) \
1899 AND ({alias}.valid_until IS NULL OR {alias}.valid_until > ?{now_idx})"
1900 )
1901 }
1902
1903 /// The full node predicate (existence + validity) for `alias`. This is the
1904 /// ONE function every read site calls, so no site can drift from another.
1905 fn node_sql(&self, alias: &str, now_idx: usize) -> String {
1906 format!("{}{}", self.existence_sql(alias), self.validity_sql(alias, now_idx))
1907 }
1908
1909 /// 0.8.20 Slice 15b fix-3 (F2) — resolve this view's validity instant ONCE
1910 /// and hand back a [`FrozenView`] that carries the resolved value.
1911 ///
1912 /// This is the ONLY constructor of a `FrozenView`, and therefore the only
1913 /// point on the search path where the wall clock is read.
1914 fn freeze(self) -> FrozenView {
1915 // TC-33: resolve the instant ONCE, unconditionally, and derive both
1916 // axes from it. `valid_as_of.unwrap_or_else(current_epoch_seconds)` is
1917 // exactly what `now_param()` computes, so the clock is read the same
1918 // number of times as before on every path that reads it at all.
1919 let resolved = self.valid_as_of.unwrap_or_else(current_epoch_seconds);
1920 let now = if self.include_out_of_window { None } else { Some(resolved) };
1921 FrozenView { view: self, now, edge_now: resolved }
1922 }
1923
1924 /// 0.8.20 Slice 15b fix-2 — the `search` path honours the VALIDITY axis of a
1925 /// `ReadView` and refuses the EXISTENCE axis. See [`Engine::search_view`] for
1926 /// why refusing beats silently ignoring.
1927 fn reject_existence_relaxation_on_search(&self) -> Result<(), EngineError> {
1928 let relaxed = match (self.include_superseded, self.include_inactive) {
1929 (true, true) => "include_superseded + include_inactive",
1930 (true, false) => "include_superseded",
1931 (false, true) => "include_inactive",
1932 (false, false) => return Ok(()),
1933 };
1934 Err(EngineError::InvalidArgument {
1935 msg: format!(
1936 "ReadView.{relaxed} is not supported on the search path; search hydrates from \
1937 projection indexes that are not version-complete, so only the validity axis \
1938 (valid_as_of / include_out_of_window) is honoured. Use read_list for history."
1939 ),
1940 })
1941 }
1942}
1943
1944/// 0.8.20 Slice 10b (R-20-NV) — one node that crossed a validity boundary
1945/// inside the interrogated interval, as reported by
1946/// [`Engine::crossed_boundary_since`].
1947///
1948/// A node can cross BOTH boundaries in the same interval (a window that opened
1949/// and closed inside it), so the two fields are independent `Option`s rather
1950/// than one enum.
1951#[derive(Clone, Debug, Eq, PartialEq)]
1952pub struct BoundaryCrossing {
1953 /// The node that crossed.
1954 pub node: NodeRecord,
1955 /// `Some(valid_from)` when the node BECAME VALID inside the interval.
1956 pub became_valid_at: Option<i64>,
1957 /// `Some(valid_until)` when the node BECAME INVALID inside the interval.
1958 pub became_invalid_at: Option<i64>,
1959}
1960
1961/// 0.8.20 Slice 15b fix-3 (F2) — a [`ReadView`] whose validity instant has
1962/// ALREADY been resolved, produced only by [`ReadView::freeze`].
1963///
1964/// R-20-NV requires `:now` to bind ONCE PER QUERY — not per row, and not per
1965/// ARM. The multi-arm search path made that easy to violate: each arm held a
1966/// `ReadView` and could call `now_param()`, which for the default view
1967/// (`valid_as_of == None`) reads the wall clock. Two arms, two instants, and a
1968/// query straddling a validity boundary gets nondeterministic membership.
1969///
1970/// The fix is TYPE-LEVEL rather than a comment asking future arms to behave:
1971/// the instant is resolved once at the top of `read_search_in_tx` and every arm
1972/// receives a `FrozenView`, which stores the resolved value in `now` and has NO
1973/// path back to the clock. An arm cannot re-resolve the instant because it
1974/// never holds anything that could — the failure mode is unreachable, not
1975/// merely discouraged.
1976#[derive(Clone, Copy, Debug)]
1977struct FrozenView {
1978 /// The underlying view — consulted for SQL SHAPE only (which conjuncts to
1979 /// emit), never to re-resolve the instant.
1980 view: ReadView,
1981 /// The instant resolved at freeze time. `None` ⇔ the view relaxes validity
1982 /// entirely, in which case no conjunct is emitted and nothing is bound.
1983 now: Option<i64>,
1984 /// TC-33 — the instant EDGE validity is evaluated at. Always present.
1985 ///
1986 /// The EXISTENCE-relaxation flag `include_out_of_window` belongs to the NODE
1987 /// validity axis and does NOT relax edge recency: an edge invalidated in the
1988 /// past stays excluded regardless. So this is the resolved instant even when
1989 /// `now` is `None`, and it is resolved from the SAME clock read.
1990 edge_now: i64,
1991}
1992
1993impl FrozenView {
1994 /// The instant to bind, resolved at freeze time. Unlike
1995 /// [`ReadView::now_param`] this is a stored value: calling it a second time
1996 /// cannot yield a different answer, and it never touches the clock.
1997 fn now_param(&self) -> Option<i64> {
1998 self.now
1999 }
2000
2001 /// TC-33 — the instant to bind for the EDGE-validity conjunct
2002 /// ([`edge_validity_sql`]). Frozen, like [`FrozenView::now_param`].
2003 ///
2004 /// Honouring `valid_as_of` here is what finally UNIFIES the node and edge
2005 /// temporal axes: step 22 recorded "the shipped EDGE path still inlines
2006 /// `datetime('now')`" as the reason they could not be unified. For the
2007 /// DEFAULT view (`valid_as_of == None`) this is the wall clock, i.e. exactly
2008 /// the pre-TC-33 behaviour.
2009 fn edge_now(&self) -> i64 {
2010 self.edge_now
2011 }
2012
2013 /// The validity conjunct — delegated to the one generator every read site
2014 /// shares, so the search arms cannot drift from the five read verbs.
2015 fn validity_sql(&self, alias: &str, now_idx: usize) -> String {
2016 self.view.validity_sql(alias, now_idx)
2017 }
2018}
2019
2020/// 0.8.20 Slice 15b fix-3 (F2) — how many times [`current_epoch_seconds`] has
2021/// been called in this process. Test-only observation; see
2022/// [`clock_reads_for_test`].
2023static CLOCK_READS: AtomicU64 = AtomicU64::new(0);
2024
2025/// Test seam — the process-wide count of wall-clock reads on the validity path.
2026/// Kept OFF the governed surface (`#[doc(hidden)]`, `_for_test`), mirroring the
2027/// sanctioned `set_vector_stage_only_for_test` / `vector_phase1_sql_for_test`
2028/// pattern; it is never re-exported from the `fathomdb` facade.
2029///
2030/// The counter is PROCESS-WIDE, so a test asserting on a delta must hold a
2031/// lock that excludes every other clock-reading test in its binary (test
2032/// binaries are separate processes, so only intra-binary contention matters).
2033/// `slice15b_search_validity_recall.rs` does this with a file-local mutex.
2034#[doc(hidden)]
2035#[must_use]
2036pub fn clock_reads_for_test() -> u64 {
2037 CLOCK_READS.load(Ordering::Relaxed)
2038}
2039
2040/// Wall-clock now as INTEGER epoch SECONDS (UTC), saturating at 0 before the
2041/// Unix epoch. The single place the node-validity path reads the clock — and it
2042/// is read in RUST, then BOUND, never inlined into SQL as `datetime('now')`.
2043fn current_epoch_seconds() -> i64 {
2044 // 0.8.20 Slice 15b fix-3 (F2) — meter every wall-clock read on the validity
2045 // path. R-20-NV requires `:now` to bind ONCE PER QUERY (not per row, not per
2046 // ARM): if two arms of one query each resolve *now*, a query that straddles
2047 // a validity boundary can have its arms disagree about which side they are
2048 // on. That is invisible to a result-shape assertion and unreachable by a
2049 // deterministic test — you cannot assert on a race. Counting the reads makes
2050 // the property testable WITHOUT racing the clock, and keeps failing for any
2051 // arm added later that re-reads it. `Relaxed` is sufficient: the counter is
2052 // an observation, never a synchronization point.
2053 CLOCK_READS.fetch_add(1, Ordering::Relaxed);
2054 std::time::SystemTime::now()
2055 .duration_since(std::time::UNIX_EPOCH)
2056 .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
2057 .unwrap_or(0)
2058}
2059
2060/// TC-33 — the edge-validity conjunct, bound to positional parameter
2061/// `?{now_idx}`. THE one generator for "is this edge valid at `:now`", so no
2062/// read site can drift from another (the same discipline
2063/// [`ReadView::validity_sql`] applies to node validity).
2064///
2065/// An edge is valid at `t` iff it has no invalid-time, or its invalid-time is
2066/// strictly in the future. `t_invalid` is INTEGER epoch seconds since step 23,
2067/// so this is a direct integer comparison — no `datetime()` conversion per row.
2068///
2069/// **`:now` is a BOUND PARAMETER, never `datetime('now')`.** Before TC-33 every
2070/// edge read site inlined `datetime('now')`, which made the predicate
2071/// non-deterministic, untestable, and re-evaluated per row; step 22's comment
2072/// flagged that as the reason node and edge validity could not be unified. They
2073/// are unified now.
2074///
2075/// Always begins with ` AND `, so every call site must already have a preceding
2076/// `WHERE` predicate.
2077fn edge_validity_sql(alias: &str, now_idx: usize) -> String {
2078 format!(" AND ({alias}.t_invalid IS NULL OR {alias}.t_invalid > ?{now_idx})")
2079}
2080
2081/// TC-33 — parse one ISO-8601 timestamp to INTEGER epoch seconds using SQLite's
2082/// own date parser, via a BOUND parameter.
2083///
2084/// Returns `None` when SQLite cannot resolve the value — `strftime` yields SQL
2085/// NULL for junk (`'not a date'`, `''`, `'2020-13-45T99:99:99Z'`, a bare epoch
2086/// string, whitespace-padded input, non-ASCII digits) and a digit string for
2087/// anything it understands.
2088///
2089/// **Why SQLite and not a date crate:** there is no `chrono`/`time` dependency
2090/// anywhere in the workspace, and HITL directed this spelling rather than adding
2091/// one. The value is BOUND, never interpolated.
2092///
2093/// **This does not violate the inline-clock rule.** That rule forbids
2094/// `datetime('now')` / `strftime('%s','now')` — an inline CLOCK. Parsing a bound
2095/// user value is deterministic and reads no clock. The current instant still
2096/// comes from the bound `:now` seam ([`current_epoch_seconds`]).
2097///
2098/// The `CAST` matters: `strftime('%s', ...)` returns TEXT (a digit string), not
2099/// an integer, and the column is `typeof(...) = 'integer'`-checked.
2100///
2101/// # TC-33 fix-5 — strict ISO-8601 SHAPE gate before delegating to SQLite
2102///
2103/// `strftime('%s', ?)` alone is NOT an ISO-8601 validator: SQLite's date parser
2104/// is MORE lenient than the declared wire contract. A bare number is read as a
2105/// **Julian day** (`strftime('%s','2451545.0')` → `946728000`, i.e. year 2000)
2106/// and `strftime('%s','0')` resolves to a pre-year-0000 epoch — so non-ISO input
2107/// was ACCEPTED and stored as an unrelated instant despite the "hard-reject
2108/// ISO-8601" contract HITL ratified (2026-07-21). [`is_iso8601_shape`] runs
2109/// FIRST and returns `None` for anything that is not a strict ISO-8601
2110/// date/datetime shape, so the existing hard-reject path fires. The shape gate
2111/// does NOT replace SQLite's calendar math — a shape-valid but impossible date
2112/// (`2025-13-45T00:00:00Z`) still `None`s out via `strftime` and hard-rejects.
2113///
2114/// # TC-47 — the calendar-DATE ROUND-TRIP backstop (keystone terminal codex P2)
2115///
2116/// The shape gate checks FORMAT, not CALENDAR VALIDITY, and `strftime('%s', ?)`
2117/// does NOT fully validate the calendar: it **rolls over an impossible DAY**
2118/// rather than returning NULL — `strftime('%s','2025-02-30T00:00:00Z')` yields
2119/// the epoch for `2025-03-02`, and `2025-04-31` yields `2025-05-01`. So a
2120/// shape-valid Feb-30 would parse to a DIFFERENT instant than the provider
2121/// supplied, bypassing the hard-reject contract. (An impossible MONTH like
2122/// `2025-13-01`, and impossible TIMES like `25:00:00` / `:60` / `:61`, already
2123/// NULL out; only impossible DAYS roll over — that is the sole residue.)
2124///
2125/// The `WHERE` clause is the round-trip: the literal calendar DATE component of
2126/// the input (`substr(?1, 1, 10)` — the `YYYY-MM-DD` the shape gate guarantees is
2127/// present) must survive SQLite's own calendar math UNCHANGED. If it rolled over,
2128/// `strftime('%Y-%m-%d', substr(?1,1,10))` differs from the literal substring and
2129/// the `WHERE` yields zero rows => `query_row` -> `QueryReturnedNoRows` -> `None`
2130/// => the existing hard-reject fires. This is a superset of the shape gate: it
2131/// also rejects the TC-44 Julian string `2451545.0` (its `substr(1,10)` renders
2132/// to `2000-01-01`, not itself).
2133///
2134/// **Why the DATE component and not the raw string or the UTC-rendered instant:**
2135/// a raw-string or `unixepoch`-rendered comparison would FALSE-REJECT valid
2136/// equivalent forms. `Z` vs `+00:00`, a non-UTC offset like `+05:00`, date-only,
2137/// and fractional seconds are all valid and store the correct (offset-shifted)
2138/// epoch — but a UTC re-render shifts the wall clock, so its date can differ from
2139/// the input's literal date. Comparing ONLY the literal DATE field is
2140/// tz-INVARIANT (the offset never alters the input's own `YYYY-MM-DD` text) while
2141/// still catching every DAY rollover, because the rollover happens in the
2142/// calendar math BEFORE any offset is applied. **Pure SQL — no date crate.**
2143fn iso8601_to_epoch_seconds(connection: &Connection, raw: &str) -> Option<i64> {
2144 if !is_iso8601_shape(raw) {
2145 return None;
2146 }
2147 connection
2148 .query_row(
2149 "SELECT CAST(strftime('%s', ?1) AS INTEGER) \
2150 WHERE strftime('%Y-%m-%d', substr(?1, 1, 10)) IS substr(?1, 1, 10)",
2151 params![raw],
2152 |r| r.get::<_, Option<i64>>(0),
2153 )
2154 .ok()
2155 .flatten()
2156}
2157
2158/// TC-33 fix-5 — strict ISO-8601 date/datetime SHAPE gate. Hand-rolled on ASCII
2159/// bytes (NO new dependency: the workspace has no `chrono`/`time`, and `regex`
2160/// is only a transitive dep of `jsonschema`, not a direct one — adding either as
2161/// a direct dep would violate the "no new dependency" constraint).
2162///
2163/// Accepts EXACTLY:
2164/// - `YYYY-MM-DD` (date only); optionally followed by
2165/// - a `T` **or** a single space separator, then `HH:MM:SS`; optionally followed
2166/// by `.fff` fractional seconds (one or more digits); optionally followed by
2167/// a zone: `Z`, or `±HH:MM`, or `±HHMM`.
2168///
2169/// Rejects bare numbers (`0`, `2451545.0`), partial junk, whitespace, non-ASCII
2170/// digits, and anything with trailing characters. All digit positions require
2171/// ASCII `0..=9` (`u8::is_ascii_digit`), so non-ASCII digit look-alikes cannot
2172/// slip through. This is a SHAPE check only — calendar validity (e.g. month 13,
2173/// day 45) is still enforced by SQLite's `strftime` after this gate passes.
2174fn is_iso8601_shape(s: &str) -> bool {
2175 let b = s.as_bytes();
2176 let d = |c: u8| c.is_ascii_digit();
2177
2178 // Date: YYYY-MM-DD (exactly 10 bytes).
2179 if b.len() < 10 {
2180 return false;
2181 }
2182 if !(d(b[0])
2183 && d(b[1])
2184 && d(b[2])
2185 && d(b[3])
2186 && b[4] == b'-'
2187 && d(b[5])
2188 && d(b[6])
2189 && b[7] == b'-'
2190 && d(b[8])
2191 && d(b[9]))
2192 {
2193 return false;
2194 }
2195 if b.len() == 10 {
2196 return true; // date-only
2197 }
2198
2199 // Separator (`T` or a single space) + time HH:MM:SS (indices 10..=18).
2200 if b[10] != b'T' && b[10] != b' ' {
2201 return false;
2202 }
2203 if b.len() < 19 {
2204 return false;
2205 }
2206 if !(d(b[11])
2207 && d(b[12])
2208 && b[13] == b':'
2209 && d(b[14])
2210 && d(b[15])
2211 && b[16] == b':'
2212 && d(b[17])
2213 && d(b[18]))
2214 {
2215 return false;
2216 }
2217
2218 let mut i = 19;
2219
2220 // Optional fractional seconds `.fff` (one or more digits).
2221 if i < b.len() && b[i] == b'.' {
2222 i += 1;
2223 let start = i;
2224 while i < b.len() && d(b[i]) {
2225 i += 1;
2226 }
2227 if i == start {
2228 return false; // `.` with no digits
2229 }
2230 }
2231
2232 // Optional zone.
2233 if i == b.len() {
2234 return true; // no zone
2235 }
2236 match b[i] {
2237 b'Z' => i += 1,
2238 b'+' | b'-' => {
2239 i += 1;
2240 // `HH`
2241 if i + 2 > b.len() || !d(b[i]) || !d(b[i + 1]) {
2242 return false;
2243 }
2244 i += 2;
2245 // `:MM` or `MM`
2246 if i < b.len() && b[i] == b':' {
2247 i += 1;
2248 }
2249 if i + 2 > b.len() || !d(b[i]) || !d(b[i + 1]) {
2250 return false;
2251 }
2252 i += 2;
2253 }
2254 _ => return false,
2255 }
2256
2257 i == b.len() // no trailing junk
2258}
2259
2260/// TC-33 — render INTEGER epoch seconds back to an ISO-8601 UTC string for the
2261/// BYO-LLM wire. The exact inverse of [`iso8601_to_epoch_seconds`].
2262///
2263/// Storage and the governed SDK are epoch seconds, but the harness protocols
2264/// (`fathomdb.extract.v1` and the consolidation harness) carry ISO-8601 — LLMs
2265/// reason about dates as text, and pushing epoch integers onto them would make
2266/// the wire hostile to the very providers it exists to serve. So the boundary
2267/// converts in BOTH directions and the representation split stays a boundary
2268/// concern rather than leaking into the protocol.
2269fn epoch_seconds_to_iso8601(connection: &Connection, epoch: i64) -> Option<String> {
2270 connection
2271 .query_row("SELECT strftime('%Y-%m-%dT%H:%M:%SZ', ?1, 'unixepoch')", params![epoch], |r| {
2272 r.get::<_, Option<String>>(0)
2273 })
2274 .ok()
2275 .flatten()
2276}
2277
2278/// TC-33 fix-1 — the inclusive epoch-seconds range SQLite's
2279/// `strftime(..., 'unixepoch')` can render back to ISO-8601. SQLite's date
2280/// functions cover years 0000..=9999 ONLY, so:
2281/// - `MIN` = `0000-01-01T00:00:00Z`
2282/// - `MAX` = `9999-12-31T23:59:59Z`
2283///
2284/// TC-33 fix-5 makes these the ACTUAL rejection predicate (a numeric
2285/// `[MIN, MAX]` bounds check), not just message text. Renderability was too
2286/// weak: `strftime(..., 'unixepoch')` renders a below-`MIN` value like
2287/// `-62_167_219_201` to `-001-12-31T23:59:59Z` (NON-NULL), so a pre-year-0000
2288/// epoch slipped the renderability guard even though it is outside the declared
2289/// years 0000..=9999. Both bounds are verified against SQLite to correspond
2290/// EXACTLY to the first/last renderable instant:
2291/// `strftime('%Y-%m-%dT%H:%M:%SZ', MIN, 'unixepoch') = 0000-01-01T00:00:00Z` and
2292/// `= 9999-12-31T23:59:59Z` for `MAX` (`MAX+1` and `MIN-1` are the first
2293/// out-of-range instants).
2294const MIN_RENDERABLE_EPOCH: i64 = -62_167_219_200; // 0000-01-01T00:00:00Z
2295const MAX_RENDERABLE_EPOCH: i64 = 253_402_300_799; // 9999-12-31T23:59:59Z
2296
2297/// TC-33 fix-1 — reject an edge epoch that SQLite cannot render back to
2298/// ISO-8601, at the governed write boundary, so it is UNSTORABLE.
2299///
2300/// # Why this is the primary layer
2301///
2302/// Storage and `PreparedWrite::Edge` carry INTEGER epoch seconds and accept an
2303/// arbitrary `i64`. The consolidation path renders each candidate's
2304/// `t_valid`/`t_invalid` to ISO-8601 for the LLM via `strftime(..., 'unixepoch')`,
2305/// which only spans years 0000..=9999. An epoch outside that range renders to
2306/// NULL, and the render site would then send a silent `null` for a timestamp
2307/// that is actually stored NON-NULL — the OUTBOUND twin of the fail-open TC-33
2308/// removes. A `null` `t_invalid` reads as "still valid", and the consolidation
2309/// reference stub echoes a winner's `t_valid` straight back as the verdict's
2310/// `t_invalid`, so the `null` round-trips through the inbound normaliser as
2311/// "still valid": an invalidated edge silently resurrected.
2312///
2313/// Inbound ISO normalisation can never MINT such an epoch (a 4-digit-year ISO
2314/// string maxes at 9999), so the governed integer surface is the only ingress —
2315/// which is exactly where this guard sits. Like the inbound
2316/// [`normalize_extractor_timestamp`] hard-reject, it is a typed
2317/// [`EngineError::InvalidArgument`] naming the offending value and the bound,
2318/// never a silent coercion.
2319///
2320/// ⚠ It no longer mirrors the `validate_write` Node branch's
2321/// `valid_from >= valid_until` refusal: decision #18 (0.8.20 Slice 22) moved
2322/// THAT refusal onto the message-less [`EngineError::WriteValidation`] unit
2323/// variant, which carries no value at all. The two refusals are deliberately in
2324/// different families now — a malformed submitted write SHAPE is
2325/// `WriteValidation`; an out-of-domain scalar on this render path stays
2326/// `InvalidArgument`. Do not restate them as one pattern.
2327fn reject_unrenderable_edge_epoch(field: &str, value: Option<i64>) -> Result<(), EngineError> {
2328 // TC-33 fix-5 — an explicit numeric MIN/MAX bounds check, NOT a renderability
2329 // test. `strftime(..., 'unixepoch')` renders a below-`MIN` epoch (e.g.
2330 // `-62_167_219_201`, year -0001) to a NON-NULL string, so a renderability
2331 // guard would let pre-year-0000 values through even though they are outside
2332 // the declared years 0000..=9999. No `Connection` is needed now that the
2333 // predicate is pure integer arithmetic.
2334 if let Some(ts) = value {
2335 if !(MIN_RENDERABLE_EPOCH..=MAX_RENDERABLE_EPOCH).contains(&ts) {
2336 return Err(EngineError::InvalidArgument {
2337 msg: format!(
2338 "edge field `{field}` = {ts} is outside the epoch-seconds range SQLite can \
2339 render to ISO-8601 ([{MIN_RENDERABLE_EPOCH}, {MAX_RENDERABLE_EPOCH}], i.e. \
2340 years 0000..=9999). REJECTED rather than stored: such an epoch renders to a \
2341 silent NULL (or a nonsensical out-of-range instant) on the consolidation \
2342 wire, and a NULL `t_invalid` reads as \"still valid\" — resurrecting an \
2343 invalidated edge."
2344 ),
2345 });
2346 }
2347 }
2348 Ok(())
2349}
2350
2351/// The JSON type name of `value`, for diagnosing a mistyped extractor field.
2352fn json_type_name(value: &Value) -> &'static str {
2353 match value {
2354 Value::Null => "null",
2355 Value::Bool(_) => "boolean",
2356 Value::Number(_) => "number",
2357 Value::String(_) => "string",
2358 Value::Array(_) => "array",
2359 Value::Object(_) => "object",
2360 }
2361}
2362
2363/// TC-33 — normalise one timestamp arriving on the **BYO-LLM extractor
2364/// boundary** (`fathomdb.extract.v1`) into the INTEGER epoch seconds the storage
2365/// and governed-SDK layers use. **HARD-REJECTS** anything it cannot normalise.
2366///
2367/// This is the layering boundary HITL ratified on 2026-07-21:
2368/// - the **extractor wire format stays ISO-8601 strings** — LLMs emit text, and
2369/// this function is the one place that changes;
2370/// - **storage and the governed SDK surface are INTEGER epoch seconds.**
2371///
2372/// # Why rejection, not coercion — fail-open is the defect
2373///
2374/// A NULL `t_invalid` means **"still valid"**. So any path that turns an
2375/// unparseable timestamp into NULL silently **resurrects an invalidated edge**.
2376/// Two distinct fail-opens are closed here:
2377///
2378/// 1. **Malformed strings.** Previously NOTHING parsed or validated these; junk
2379/// went verbatim into the INSERT. Under the old TEXT column it then failed
2380/// CLOSED by accident (`datetime('junk')` → NULL ⇒ the read disjunct is
2381/// falsy ⇒ the row vanished). Under INTEGER that polarity would INVERT.
2382/// 2. **Non-string JSON — a fail-open that PREDATES TC-33.** The old site read
2383/// `edge.get("t_invalid").and_then(|v| v.as_str())`, and `as_str()` returns
2384/// `None` for a JSON number/bool/object. So `"t_invalid": 1710000000` — a
2385/// plausible mistake, and exactly the epoch form storage now uses — had its
2386/// invalidation SILENTLY DISCARDED and the edge stored as "still valid".
2387///
2388/// `None`/JSON `null`/absent is the ONLY sanctioned way to say "unknown"; it
2389/// maps to `Ok(None)` and keeps the NULL-means-still-valid semantic.
2390///
2391/// Refuses with a typed [`EngineError::InvalidArgument`] CARRYING the offending
2392/// value, so a caller can see what was rejected.
2393///
2394/// ⚠ This is NOT the same pattern as the `validate_write` `Node` branch's
2395/// `valid_from >= valid_until` check, which that comment used to cite: decision
2396/// #18 (0.8.20 Slice 22) moved that refusal onto the message-less
2397/// [`EngineError::WriteValidation`] unit variant, which carries **no value at
2398/// all**. Both the family AND the carry-the-value property differ.
2399fn normalize_extractor_timestamp(
2400 connection: &Connection,
2401 field: &str,
2402 raw: Option<&Value>,
2403) -> Result<Option<i64>, EngineError> {
2404 match raw {
2405 None | Some(Value::Null) => Ok(None),
2406 Some(Value::String(text)) => match iso8601_to_epoch_seconds(connection, text) {
2407 Some(epoch) => Ok(Some(epoch)),
2408 None => Err(EngineError::InvalidArgument {
2409 msg: format!(
2410 "extractor edge field `{field}` must be a valid, calendar-real ISO-8601 \
2411 timestamp; got {text:?}, which either `strftime('%s', ?)` resolves to NULL \
2412 or fails the calendar round-trip (a shape-valid but impossible DAY like \
2413 `2025-02-30` that SQLite would silently ROLL OVER to a different instant). \
2414 REJECTED rather than stored: a NULL `t_invalid` reads as \"still valid\" and \
2415 a rolled-over date stores the WRONG instant — both breach the hard-reject \
2416 contract. Use JSON null for \"unknown\"."
2417 ),
2418 }),
2419 },
2420 Some(other) => Err(EngineError::InvalidArgument {
2421 msg: format!(
2422 "extractor edge field `{field}` must be an ISO-8601 string or JSON null; got a \
2423 JSON {kind} ({other}). The `fathomdb.extract.v1` wire format carries ISO-8601 at \
2424 this boundary — INTEGER epoch seconds are the STORAGE representation, not the \
2425 wire one. REJECTED rather than coerced to NULL, which reads as \"still valid\".",
2426 kind = json_type_name(other)
2427 ),
2428 }),
2429 }
2430}
2431
2432/// Slice 30 (G3) — one `operational_mutations` row returned by `read.collection`
2433/// / `read.mutations`. `id` is the autoincrement PK (the after-id cursor key).
2434#[derive(Clone, Debug, Eq, PartialEq)]
2435pub struct OpStoreRow {
2436 pub id: i64,
2437 pub collection: String,
2438 pub record_key: String,
2439 pub op_kind: String,
2440 pub payload: String,
2441 pub schema_id: Option<String>,
2442 pub write_cursor: u64,
2443}
2444
2445/// Hybrid `search` result. `results` carries structured [`SearchHit`]s in
2446/// vector-first, dedup-on-body order. Derives `Clone, Debug, PartialEq` but
2447/// **not `Eq`** — each hit carries a `score: f64`.
2448#[derive(Clone, Debug, PartialEq)]
2449// 0.8.8 EXP-OBS (field-set ratification): non_exhaustive so future additive fields
2450// (e.g. the deferred QueryTrace.timings_ms, Q3) are non-breaking. All construction
2451// is in-crate (engine + tests); external crates read fields only.
2452#[non_exhaustive]
2453pub struct SearchResult {
2454 pub projection_cursor: u64,
2455 pub soft_fallback: Option<SoftFallback>,
2456 pub results: Vec<SearchHit>,
2457 /// 0.8.8 EXP-OBS (Slice 5) — opt-in retrieval explanation **sidecar**.
2458 /// `Some` ONLY on the `search_explained` path; `None` for every default
2459 /// (`explain=false`) search, so `results` + `projection_cursor` stay
2460 /// byte-identical to the pre-0.8.8 shape (R-OBS-2 zero-cost contract,
2461 /// HITL-ratified sidecar carrier — see
2462 /// `dev/design/0.8.8-explain-and-telemetry-adr.md` §A.2). Field-set is
2463 /// PROPOSED/ratification-pending; additive inside `Explanation` so later
2464 /// amendments do not reshape `SearchResult`/`SearchHit`.
2465 pub explanation: Option<Explanation>,
2466}
2467
2468/// 0.8.8 EXP-OBS (Slice 5) — the opt-in retrieval explanation payload returned
2469/// behind `search_explained` (the `explain=true` surface). Built from the
2470/// engine's OWN fusion/rerank machinery (`fuse_three_arms` per-arm ranks,
2471/// `ce_rerank` blend components) — no parallel machinery (R-OBS-3). Carries a
2472/// query-level [`QueryTrace`] plus a per-hit breakdown parallel to (and in the
2473/// same order as) `SearchResult.results`.
2474///
2475/// Derives `Clone, Debug, PartialEq` but **not `Eq`** — scores are `f64`.
2476#[derive(Clone, Debug, PartialEq)]
2477#[non_exhaustive] // 0.8.8 field-set ratification — additive-safe sidecar
2478pub struct Explanation {
2479 pub trace: QueryTrace,
2480 pub per_hit: Vec<PerHitExplain>,
2481}
2482
2483/// 0.8.8 EXP-OBS (Slice 5) — query-level retrieval trace. Reuses the existing
2484/// `search_reranked` knobs + the active embedder identity; timings are coarse
2485/// per-stage wall-clock (monotonic) captured only on the explain path.
2486#[derive(Clone, Debug, PartialEq)]
2487// 0.8.8 field-set ratification — HARD: leaf absorbs the deferred `timings_ms` (Q3)
2488// and any future trace field without a contract break.
2489#[non_exhaustive]
2490pub struct QueryTrace {
2491 /// Query LENGTH only (chars) — never the query text (privacy; ADR §C).
2492 pub query_chars: u32,
2493 /// Final result limit (`SEARCH_RERANK_LIMIT`-derived `final_limit`).
2494 pub k: u32,
2495 pub rerank_depth: u32,
2496 pub pool_n: u32,
2497 pub alpha: f64,
2498 pub use_graph_arm: bool,
2499 /// Recency reweight (the dedicated G12 flag) was applied.
2500 pub recency: bool,
2501 /// Active embedder identity `name@revision` (+ dim), or empty when none.
2502 pub embedder_id: String,
2503 /// The CE cross-encoder actually reranked the pool (model loaded + depth>0).
2504 pub ce_active: bool,
2505 /// Per-arm input hit counts (pre-fusion).
2506 pub vector_hits: u32,
2507 pub text_hits: u32,
2508 pub graph_hits: u32,
2509 /// Edge-FTS candidates rejected only because an attribute predicate is
2510 /// node-scoped. Present on the opt-in explanation so this deliberate
2511 /// filtering never looks like an absent corpus.
2512 pub dropped_edge_hits: u32,
2513}
2514
2515/// 0.8.8 EXP-OBS (Slice 5) — per-hit provenance + score breakdown. One entry per
2516/// returned `SearchHit`, same order. `*_rank` is the 0-based rank the hit's body
2517/// held in that arm's pre-fusion list (`None` = absent from that arm).
2518///
2519/// Derives `Clone, Debug, PartialEq` but **not `Eq`** — scores are `f64`.
2520#[derive(Clone, Debug, PartialEq)]
2521// 0.8.8 field-set ratification — HARD: leaf absorbs future arms / score components.
2522#[non_exhaustive]
2523pub struct PerHitExplain {
2524 /// The hit's engine-internal positional `write_cursor` (the pre-C-2
2525 /// `SearchHit.id`). Post-0.8.19 the caller-facing `SearchHit.id` is a typed
2526 /// [`IdSpace`]; this field keeps carrying the positional cursor so the explain
2527 /// sidecar cross-references the telemetry `result_ids` space. Correlate a
2528 /// `PerHitExplain` to its `SearchHit` by position (both lists are 1:1, same
2529 /// order).
2530 pub id: u64,
2531 /// Winning arm after RRF dedup (vector-first), == `SearchHit.branch`.
2532 pub arm: SoftFallbackBranch,
2533 pub vector_rank: Option<u32>,
2534 pub text_rank: Option<u32>,
2535 pub graph_rank: Option<u32>,
2536 /// Raw RRF fused score AFTER recency reweight, BEFORE CE blend (the value
2537 /// `ce_rerank` normalizes). Faithful to the engine computation — downstream
2538 /// may normalize. (ADR §A.4 Q1: raw exposed; normalization deferred.)
2539 pub fused_score: f64,
2540 /// In-pool cross-encoder score `sigmoid(ce_logit) ∈ [0,1]`, == the returned
2541 /// `SearchHit.ce_score`; `None` outside the reranked pool / no-CE path.
2542 pub ce_score: Option<f64>,
2543 /// Final blended score, == the returned `SearchHit.score`.
2544 pub blended: f64,
2545 /// 0.8.16 Slice 5 / F9 — the node `importance` scalar applied to this hit's
2546 /// fused contribution when the importance reweight is ON, else the raw stored
2547 /// value. `None` = never assigned (graceful-absent, ranks NEUTRAL). Additive
2548 /// (`#[non_exhaustive]` leaf absorbs the new score component).
2549 pub importance: Option<f64>,
2550 /// 0.8.16 Slice 5 / F9 — the edge `confidence` scalar applied to this hit's
2551 /// graph-arm contribution when the importance reweight is ON, else the raw
2552 /// stored value. `None` for node hits / edges without a confidence
2553 /// (graceful-absent, ranks NEUTRAL).
2554 pub confidence: Option<f64>,
2555}
2556
2557// ===== G4 filter grammar types (Slice 35) ===============================
2558
2559/// G4 (Slice 35) — scalar value for [`Predicate`] comparisons.
2560///
2561/// Shared vocabulary with G10 — defined once at the `fathomdb-engine` crate
2562/// root so reserved-gap 37 (full G4↔G10 unification) can import it without a
2563/// path change. Derives `Clone, Debug, PartialEq` per the ADR contract
2564/// (D-F1 exhaustiveness: exactly `{Text, Integer, Bool}`).
2565#[derive(Clone, Debug, PartialEq)]
2566pub enum ScalarValue {
2567 Text(String),
2568 Integer(i64),
2569 Bool(bool),
2570}
2571
2572/// G4 (Slice 35) — comparison operator for [`Predicate::JsonPathCompare`].
2573///
2574/// Shared vocabulary (same crate-root export as `ScalarValue`). Closed
2575/// enum: `{Gt, Gte, Lt, Lte}` per D-F1. Derives `Clone, Debug, PartialEq`.
2576#[derive(Clone, Debug, PartialEq)]
2577pub enum ComparisonOp {
2578 Gt,
2579 Gte,
2580 Lt,
2581 Lte,
2582}
2583
2584/// Allowed JSON paths for [`Predicate`] constructors. The SQL compilation in
2585/// [`Engine::read_list`] uses the **allowlist constant** (a server-side literal),
2586/// never the caller-supplied string, so only paths in this set reach
2587/// `json_extract`. Callers receive [`EngineError::InvalidFilter`] for any
2588/// non-allowlisted path — no passthrough, no panic.
2589///
2590/// To extend: add an entry here. No API change is needed; the constructor
2591/// accepts the new path string once it appears in this array.
2592const PREDICATE_PATH_ALLOWLIST: &[&str] =
2593 &["$.status", "$.priority", "$.tags", "$.kind", "$.created_at", "$.action_kind"];
2594
2595/// G4 (Slice 35) — closed typed predicate for [`Engine::read_list`] filter.
2596///
2597/// Exactly two variants per ADR D-F1 (`{JsonPathEq, JsonPathCompare}`).
2598/// The fused variants (`JsonPathFused*`) and all `*_unchecked` builders are
2599/// explicitly EXCLUDED (ADR D-F2). Use the validated constructors
2600/// [`Predicate::json_path_eq`] / [`Predicate::json_path_compare`]; they
2601/// enforce the path allowlist at construction time.
2602///
2603/// Multiple predicates in [`Engine::read_list`] are combined by implicit AND
2604/// (D-F5). Compilation target: `json_extract(body, '$.field') <op> ?` with
2605/// a bound parameter (never interpolated — injection-safe per D-F4).
2606#[derive(Clone, Debug, PartialEq)]
2607pub enum Predicate {
2608 /// `json_extract(body, path) = ?` (equality).
2609 JsonPathEq { path: String, value: ScalarValue },
2610 /// `json_extract(body, path) <op> ?` (inequality).
2611 JsonPathCompare { path: String, op: ComparisonOp, value: ScalarValue },
2612}
2613
2614impl Predicate {
2615 /// Construct a `JsonPathEq` predicate with allowlist validation.
2616 ///
2617 /// Returns [`EngineError::InvalidFilter`] if `path` is not in
2618 /// [`PREDICATE_PATH_ALLOWLIST`]; never panics on bad input.
2619 pub fn json_path_eq(path: impl Into<String>, value: ScalarValue) -> Result<Self, EngineError> {
2620 let path = path.into();
2621 if !PREDICATE_PATH_ALLOWLIST.contains(&path.as_str()) {
2622 return Err(EngineError::InvalidFilter {
2623 reason: format!("path '{path}' is not in the predicate path allowlist"),
2624 });
2625 }
2626 Ok(Self::JsonPathEq { path, value })
2627 }
2628
2629 /// Construct a `JsonPathCompare` predicate with allowlist validation.
2630 ///
2631 /// Returns [`EngineError::InvalidFilter`] if `path` is not in
2632 /// [`PREDICATE_PATH_ALLOWLIST`]; never panics on bad input.
2633 pub fn json_path_compare(
2634 path: impl Into<String>,
2635 op: ComparisonOp,
2636 value: ScalarValue,
2637 ) -> Result<Self, EngineError> {
2638 let path = path.into();
2639 if !PREDICATE_PATH_ALLOWLIST.contains(&path.as_str()) {
2640 return Err(EngineError::InvalidFilter {
2641 reason: format!("path '{path}' is not in the predicate path allowlist"),
2642 });
2643 }
2644 Ok(Self::JsonPathCompare { path, op, value })
2645 }
2646
2647 /// Return the validated path string for use in SQL compilation.
2648 /// This always returns a path that is in `PREDICATE_PATH_ALLOWLIST`.
2649 fn path(&self) -> &str {
2650 match self {
2651 Self::JsonPathEq { path, .. } => path.as_str(),
2652 Self::JsonPathCompare { path, .. } => path.as_str(),
2653 }
2654 }
2655
2656 /// Compile this predicate to a SQL WHERE clause fragment.
2657 /// The path is validated at construction time and is always an allowlist
2658 /// constant — never the raw caller-supplied string.
2659 fn to_sql_clause(&self, param_idx: usize) -> String {
2660 // The path is already validated against the allowlist at construction.
2661 // We use the allowlist entry (the stored path) directly as a SQL literal.
2662 // The VALUE is always a bound `?` parameter (injection-safe).
2663 //
2664 // Type guards prevent cross-type matches caused by SQLite's json_extract
2665 // coercing JSON booleans to integer 1/0:
2666 // - Bool predicates: AND json_type IN ('true', 'false') — exclude integers
2667 // - Integer predicates: AND json_type = 'integer' — exclude booleans
2668 // Text predicates need no guard: json_extract returns TEXT for strings and
2669 // the coercion never conflates TEXT with integer/bool.
2670 let path = self.path();
2671 match self {
2672 Self::JsonPathEq { value, .. } => match value {
2673 ScalarValue::Bool(_) => format!(
2674 "json_extract(body, '{path}') = ?{param_idx} \
2675 AND json_type(body, '{path}') IN ('true', 'false')"
2676 ),
2677 ScalarValue::Integer(_) => format!(
2678 "json_extract(body, '{path}') = ?{param_idx} \
2679 AND json_type(body, '{path}') = 'integer'"
2680 ),
2681 ScalarValue::Text(_) => {
2682 format!("json_extract(body, '{path}') = ?{param_idx}")
2683 }
2684 },
2685 Self::JsonPathCompare { op, value, .. } => {
2686 let op_str = match op {
2687 ComparisonOp::Gt => ">",
2688 ComparisonOp::Gte => ">=",
2689 ComparisonOp::Lt => "<",
2690 ComparisonOp::Lte => "<=",
2691 };
2692 match value {
2693 ScalarValue::Bool(_) => format!(
2694 "json_extract(body, '{path}') {op_str} ?{param_idx} \
2695 AND json_type(body, '{path}') IN ('true', 'false')"
2696 ),
2697 ScalarValue::Integer(_) => format!(
2698 "json_extract(body, '{path}') {op_str} ?{param_idx} \
2699 AND json_type(body, '{path}') = 'integer'"
2700 ),
2701 ScalarValue::Text(_) => format!(
2702 "json_extract(body, '{path}') {op_str} ?{param_idx} \
2703 AND json_type(body, '{path}') = 'text'"
2704 ),
2705 }
2706 }
2707 }
2708 }
2709
2710 /// Bind the value of this predicate as a rusqlite parameter.
2711 fn bind_value(&self) -> rusqlite::types::Value {
2712 let value = match self {
2713 Self::JsonPathEq { value, .. } => value,
2714 Self::JsonPathCompare { value, .. } => value,
2715 };
2716 match value {
2717 ScalarValue::Text(s) => rusqlite::types::Value::Text(s.clone()),
2718 ScalarValue::Integer(i) => rusqlite::types::Value::Integer(*i),
2719 ScalarValue::Bool(b) => rusqlite::types::Value::Integer(i64::from(*b)),
2720 }
2721 }
2722}
2723
2724// ===== Slice 20 (G5/G6) — graph traversal types =========================
2725
2726/// Slice 20 (G5) — direction of graph traversal for
2727/// [`Engine::graph_neighbors`] / [`Engine::search_expand`].
2728///
2729/// `Outgoing` follows edges where the root is the `from_id` (source).
2730/// `Incoming` follows edges where the root is the `to_id` (target).
2731/// `Both` follows edges in either direction.
2732#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2733pub enum TraversalDirection {
2734 Outgoing,
2735 Incoming,
2736 Both,
2737}
2738
2739/// Slice 20 (G6) — result of [`Engine::search_expand`]: initial search hits
2740/// plus nodes reached by bounded BFS expansion that are not already in the
2741/// search hit set.
2742#[derive(Clone, Debug)]
2743pub struct SearchExpandResult {
2744 /// Original RRF-scored search results (G1+G9 hybrid).
2745 pub search_hits: Vec<SearchHit>,
2746 /// Nodes reached by graph traversal but NOT already in `search_hits`.
2747 /// Each entry is `(node, hop_count)` where `hop_count` is the BFS depth
2748 /// from the nearest search hit that reached this node.
2749 pub expanded: Vec<(NodeRecord, u32)>,
2750 /// Deduplicated union of all logical_ids (search hits first, then expanded).
2751 pub all_logical_ids: Vec<String>,
2752}
2753
2754/// G10 — closed metadata filter for [`Engine::search_filtered`] (Slice 10).
2755///
2756/// All fields are optional; a `None` field imposes no constraint, and an
2757/// all-`None` filter (or `None` filter) is the unfiltered path whose phase-1 SQL
2758/// is byte-identical to 0.7.2. This is a **closed struct**, not an open filter
2759/// DSL (ADR-0.8.0-agent-memory-retrieval-and-identity Q1); the filter-grammar /
2760/// `list` decision stays a later-slice concern.
2761///
2762/// `created_after` is a `created_at >= bound` lower bound in unix seconds.
2763/// `status` is wired through to the vec0 `status` metadata column. vec0 TEXT
2764/// metadata columns are **NOT NULL-able**, so the "no real population yet" state
2765/// is an **empty-string sentinel** `''` (a forced deviation from the planned
2766/// "NULL plumbing"; a real population source is reserved-gap candidate 13). A
2767/// `status = Some("open")`-style filter therefore prunes every row until that
2768/// population slice lands.
2769// 0.8.20 Slice 15e fix-2 (Finding 2) — `#[non_exhaustive]`: the `attributes`
2770// field was added additively in 0.8.20. Marking the struct non-exhaustive means
2771// EXTERNAL crates can no longer use a struct literal `SearchFilter { .. }` and
2772// must go through `..Default::default()` (or a constructor), so a FUTURE field
2773// add is not a source break for them. Internal (in-workspace) construction is
2774// unaffected — `#[non_exhaustive]` only constrains other crates — and every
2775// in-crate literal already spreads `..Default::default()`. Governed-surface
2776// status: PROPOSED / NOT SIGNED.
2777#[derive(Clone, Debug, Default, Eq, PartialEq)]
2778#[non_exhaustive]
2779pub struct SearchFilter {
2780 pub source_type: Option<String>,
2781 pub kind: Option<String>,
2782 pub created_after: Option<i64>,
2783 pub status: Option<String>,
2784 /// 0.8.20 Slice 15e (R-20-PR, ADR-0.8.11 D3) — declared-`filterable`-attribute
2785 /// equality predicates, each `(attribute_name, value)`. Lowered into the
2786 /// **indexed pre-KNN** vec0 metadata column `attr_<hex>` by
2787 /// [`vector_filter_clause`] (NOT a post-KNN `json_extract`). Empty ⇒ the
2788 /// byte-identical unfiltered path is preserved. `attribute_name` is the
2789 /// registry projection name; the encoded column is derived by
2790 /// [`attr_vec0_column`].
2791 pub attributes: Vec<(String, String)>,
2792}
2793
2794impl SearchFilter {
2795 /// True when no field constrains the search — equivalent to `None`. Used to
2796 /// keep the unfiltered code path (and its byte-identical SQL) on the
2797 /// all-`None` struct.
2798 fn is_unfiltered(&self) -> bool {
2799 self.source_type.is_none()
2800 && self.kind.is_none()
2801 && self.created_after.is_none()
2802 && self.status.is_none()
2803 && self.attributes.is_empty()
2804 }
2805}
2806
2807// ===== 0.8.11 Slice 40 (#17) — unified filter grammar (G4 + G10) =========
2808
2809/// 0.8.11 Slice 40 (#17) — a single closed `FilterTerm` of the **unified**
2810/// filter grammar (ADR-0.8.11-filter-grammar-unification, Option A; closes
2811/// reserved-gap 37). Exactly **five** variants: the four G10 shorthand metadata
2812/// fields (`SourceType`/`Kind`/`CreatedAfter`/`Status`) plus the general G4
2813/// json-path [`Predicate`] (`Json`). The shorthand fields are dedicated typed
2814/// variants — NOT `Json(Predicate)` over `$.source_type` etc. — precisely so the
2815/// vec0 search backend can lower them to the *indexed* pre-KNN metadata columns
2816/// while typed-rejecting an arbitrary `Json` term (D3: no demotion to post-KNN
2817/// `json_extract`).
2818///
2819/// The grammar stays **closed** (inherits ADR-0.8.0 D-F1/D-F2/D-F4/D-F5): no
2820/// DSL, no caller SQL, no `JsonPathFused*`, no `*_unchecked`, no OR/nesting
2821/// (implicit AND only); `Json` terms are built ONLY via the validated
2822/// [`Predicate::json_path_eq`] / [`Predicate::json_path_compare`] constructors
2823/// (path allowlist enforced at construction). The shipped `ScalarValue` /
2824/// `ComparisonOp` / `Predicate` vocabulary is reused verbatim — no new grammar.
2825#[derive(Clone, Debug, PartialEq)]
2826pub enum FilterTerm {
2827 /// vec0 partition-key metadata column `source_type` (pre-KNN). On
2828 /// `read.list` it **constant-folds** against `resolve_source_type(kind)`
2829 /// (the column does not exist in `canonical_nodes`).
2830 SourceType(String),
2831 /// `kind` — the vec0 metadata column (pre-KNN). On `read.list` it
2832 /// constant-folds against the partition `kind` argument (D1 impl decision:
2833 /// constant-fold, the simpler total option vs a redundant column clause).
2834 Kind(String),
2835 /// `created_at >= bound` (unix seconds). vec0 metadata column (pre-KNN);
2836 /// lowers to `json_extract(body,'$.created_at') >= ?` on `read.list`.
2837 CreatedAfter(i64),
2838 /// vec0 metadata column `status` (pre-KNN); lowers to
2839 /// `json_extract(body,'$.status') = ?` on `read.list`.
2840 Status(String),
2841 /// The general G4 json-path predicate (unchanged shipped grammar). Resolves
2842 /// **only** on the `read.list` (canonical_nodes) backend; **typed-rejected**
2843 /// on `search_filtered` because it would require a post-KNN `json_extract`
2844 /// that defeats the indexed pre-KNN filter (D3 no-demotion guarantee).
2845 Json(Predicate),
2846}
2847
2848/// 0.8.11 Slice 40 (#17) — the unified closed `Filter` contract. ONE superset
2849/// type with implicit-AND [`FilterTerm`]s, dispatched to one of **two** internal
2850/// compilation backends (Option A — the TYPE unifies, the COMPILATION
2851/// dispatches): the vec0-metadata indexed pre-KNN `WHERE` for `search_filtered`,
2852/// and `json_extract` over `canonical_nodes.body` for `read.list`. The shipped
2853/// `SearchFilter` (G10) and `Predicate` lists (G4) re-express as sugar that
2854/// lowers into this type (D4); the `filter=None` byte-identical-0.7.2-SQL pin is
2855/// preserved because the vec0 lowering routes back through the shipped
2856/// `vector_filter_clause` compilation verbatim.
2857#[derive(Clone, Debug, Default, PartialEq)]
2858pub struct Filter {
2859 /// AND-combined terms (implicit AND, inherits D-F5). Empty = unfiltered.
2860 pub terms: Vec<FilterTerm>,
2861}
2862
2863impl TryFrom<&SearchFilter> for Filter {
2864 type Error = EngineError;
2865
2866 /// D4 sugar lowering — the shipped G10 [`SearchFilter`] re-expressed as the
2867 /// unified [`Filter`]. Attribute equality is intentionally not part of the
2868 /// unified grammar, so it is refused rather than silently discarded. Field
2869 /// → term uses canonical order (`source_type`, `kind`, `created_after`,
2870 /// `status`) so an attribute-free round-trip stays byte-identical.
2871 fn try_from(sf: &SearchFilter) -> Result<Self, Self::Error> {
2872 if !sf.attributes.is_empty() {
2873 return Err(EngineError::InvalidFilter {
2874 reason:
2875 "projected attribute predicates are not supported by the unified Filter grammar"
2876 .to_string(),
2877 });
2878 }
2879 let mut terms = Vec::new();
2880 if let Some(s) = &sf.source_type {
2881 terms.push(FilterTerm::SourceType(s.clone()));
2882 }
2883 if let Some(k) = &sf.kind {
2884 terms.push(FilterTerm::Kind(k.clone()));
2885 }
2886 if let Some(c) = sf.created_after {
2887 terms.push(FilterTerm::CreatedAfter(c));
2888 }
2889 if let Some(s) = &sf.status {
2890 terms.push(FilterTerm::Status(s.clone()));
2891 }
2892 Ok(Filter { terms })
2893 }
2894}
2895
2896impl Filter {
2897 /// Backend dispatch for `search_filtered` (vec0 — indexed pre-KNN). Lowers
2898 /// the metadata subset `{SourceType, Kind, CreatedAfter, Status}` back into a
2899 /// [`SearchFilter`] (which the shipped `vector_filter_clause` compiles to the
2900 /// pre-KNN `WHERE`), and **typed-rejects** a [`FilterTerm::Json`] term with
2901 /// [`EngineError::InvalidFilter`] — the explicit no-demotion guarantee (D3).
2902 /// Field-by-variant assignment makes the output canonical-order-independent
2903 /// of `terms` ordering (hand-built router filters included). A later
2904 /// duplicate metadata term overwrites the earlier (last-wins).
2905 pub fn to_search_filter(&self) -> Result<SearchFilter, EngineError> {
2906 let mut sf = SearchFilter::default();
2907 for term in &self.terms {
2908 match term {
2909 FilterTerm::SourceType(s) => sf.source_type = Some(s.clone()),
2910 FilterTerm::Kind(k) => sf.kind = Some(k.clone()),
2911 FilterTerm::CreatedAfter(c) => sf.created_after = Some(*c),
2912 FilterTerm::Status(s) => sf.status = Some(s.clone()),
2913 FilterTerm::Json(_) => {
2914 return Err(EngineError::InvalidFilter {
2915 reason: "arbitrary json-path predicate not supported on search_filtered; \
2916 it would require a post-KNN json_extract that defeats the \
2917 indexed pre-KNN filter (ADR-0.8.11 D3 no-demotion guarantee)"
2918 .to_string(),
2919 });
2920 }
2921 }
2922 }
2923 Ok(sf)
2924 }
2925
2926 /// Backend dispatch for `read.list` (canonical_nodes — `json_extract`). The
2927 /// full set resolves here. Returns:
2928 /// - `Ok(Some(preds))` — the implicit-AND [`Predicate`] list to run; or
2929 /// - `Ok(None)` — a constant-folded **guaranteed-empty** result (a `Kind` or
2930 /// `SourceType` term that cannot match this partition), so the caller
2931 /// returns an empty `Vec` without touching SQL; or
2932 /// - `Err(InvalidFilter)` — a non-allowlisted path (defense-in-depth; the
2933 /// shorthand lowerings only ever use allowlisted paths).
2934 ///
2935 /// Lowering (D3): `Json(p)` → `p`; `Status(s)` →
2936 /// `json_path_eq("$.status", Text(s))`; `CreatedAfter(b)` →
2937 /// `json_path_compare("$.created_at", Gte, Integer(b))`; `Kind(k)` →
2938 /// constant-fold vs the partition `kind` arg (no-op if equal, empty if not);
2939 /// `SourceType(s)` → constant-fold vs `resolve_source_type(kind)` (no-op if
2940 /// equal, empty otherwise — the column does not exist in `body`).
2941 fn lower_for_read_list(&self, kind: &str) -> Result<Option<Vec<Predicate>>, EngineError> {
2942 let mut preds = Vec::new();
2943 for term in &self.terms {
2944 match term {
2945 FilterTerm::Json(p) => preds.push(p.clone()),
2946 FilterTerm::Status(s) => {
2947 preds.push(Predicate::json_path_eq("$.status", ScalarValue::Text(s.clone()))?);
2948 }
2949 FilterTerm::CreatedAfter(b) => {
2950 preds.push(Predicate::json_path_compare(
2951 "$.created_at",
2952 ComparisonOp::Gte,
2953 ScalarValue::Integer(*b),
2954 )?);
2955 }
2956 FilterTerm::Kind(k) => {
2957 // Constant-fold vs the partition argument (D1 impl decision).
2958 if k != kind {
2959 return Ok(None);
2960 }
2961 }
2962 FilterTerm::SourceType(s) => {
2963 // source_type is NOT a canonical_nodes column; it is a pure
2964 // function of `kind`. Constant-fold (D2/D3).
2965 match resolve_source_type(kind) {
2966 Ok(resolved) if resolved == s.as_str() => {}
2967 _ => return Ok(None),
2968 }
2969 }
2970 }
2971 }
2972 Ok(Some(preds))
2973 }
2974
2975 /// 0.8.11 Slice 40 — test seam: expose the vec0 backend dispatch so the
2976 /// unification suite can pin the typed-rejection (RED→GREEN) and that a
2977 /// metadata-only Filter lowers losslessly. Returns the lowered
2978 /// [`SearchFilter`] (or `InvalidFilter` for a `Json` term).
2979 #[doc(hidden)]
2980 pub fn to_search_filter_for_test(&self) -> Result<SearchFilter, EngineError> {
2981 self.to_search_filter()
2982 }
2983
2984 /// 0.8.11 Slice 40 — test seam: expose the `read.list` backend lowering so
2985 /// the unification suite can pin total dispatch incl. the `SourceType`/`Kind`
2986 /// constant-folds. `Ok(None)` == constant-folded-empty.
2987 #[doc(hidden)]
2988 pub fn lower_for_read_list_for_test(
2989 &self,
2990 kind: &str,
2991 ) -> Result<Option<Vec<Predicate>>, EngineError> {
2992 self.lower_for_read_list(kind)
2993 }
2994}
2995
2996/// G11 (Slice 15) — a document sent to a BYO-LLM extraction harness via
2997/// [`Engine::ingest_with_extractor`].
2998#[derive(Clone, Debug)]
2999pub struct ExtractDocument {
3000 /// Stable opaque identifier for this document. Used as `source_id` on
3001 /// ingested edges and for provenance tracking.
3002 pub source_doc_id: String,
3003 /// Full text body of the document to extract entities and relationships from.
3004 pub body: String,
3005}
3006
3007/// G11 (Slice 15) — receipt returned by [`Engine::ingest_with_extractor`].
3008#[derive(Clone, Debug, Default)]
3009pub struct IngestWithExtractorReceipt {
3010 /// Number of `canonical_nodes` rows written (new entity insertions; skipped
3011 /// for entities that already have a matching active logical_id).
3012 pub nodes_written: u64,
3013 /// Number of `canonical_edges` rows written (new fact-edge insertions;
3014 /// superseded prior edges are ALSO counted as rows written).
3015 pub edges_written: u64,
3016 /// Number of documents processed (including no-facts documents).
3017 pub docs_processed: u64,
3018}
3019
3020/// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — one (subject-entity, relation) axis to
3021/// consolidate via [`Engine::consolidate_with_provider`]. FathomDB assembles the
3022/// competing fact-edge cluster for this axis DETERMINISTICALLY (CPU-only, no
3023/// LLM) by querying active `canonical_edges` where `from_id = subject_logical_id`
3024/// AND `kind = relation`.
3025#[derive(Clone, Debug)]
3026pub struct ConsolidateAxis {
3027 /// Stable `logical_id` of the subject entity (edge `from_id`).
3028 pub subject_logical_id: String,
3029 /// The relation/edge `kind` whose competing fact-edges form the cluster.
3030 pub relation: String,
3031}
3032
3033/// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — one competing fact-edge in a candidate
3034/// cluster sent to the consolidation harness. Assembled deterministically from
3035/// `canonical_edges`; sent to the harness as the request payload; the harness's
3036/// verdict references edges back by `edge_ref` (the edge's stable `logical_id`).
3037#[derive(Clone, Debug)]
3038pub struct ConsolidateCandidateEdge {
3039 /// The edge's stable `logical_id` — the ref the harness uses in its verdict.
3040 pub edge_ref: String,
3041 /// The fact/relationship text (never rewritten by consolidation — §2.1).
3042 pub body: Option<String>,
3043 /// Event valid-time as INTEGER epoch seconds (UTC), if known.
3044 ///
3045 /// TC-33: epoch seconds, NOT ISO-8601. ISO-8601 lives only on the BYO-LLM
3046 /// extractor wire; `normalize_extractor_timestamp` is the one boundary.
3047 pub t_valid: Option<i64>,
3048 /// Event invalid-time as INTEGER epoch seconds (UTC), if already
3049 /// invalidated. `None` = still valid.
3050 pub t_invalid: Option<i64>,
3051 /// Extraction confidence ∈ [0.0, 1.0], if known.
3052 pub confidence: Option<f64>,
3053 /// Provenance: originating document id.
3054 pub source_doc_id: Option<String>,
3055 /// Provenance: extractor model id from the original BYO-LLM ingest.
3056 pub extractor_model_id: Option<String>,
3057}
3058
3059/// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — receipt returned by
3060/// [`Engine::consolidate_with_provider`]. Consolidation records supersession /
3061/// recency METADATA only (§2.1): edge bodies are never rewritten and no row is
3062/// ever deleted, so these counts describe metadata transitions, not content
3063/// changes.
3064#[derive(Clone, Debug, Default)]
3065pub struct ConsolidateReceipt {
3066 /// Number of (subject, relation) axes with a non-empty cluster that were
3067 /// dispatched to the harness.
3068 pub clusters_processed: u64,
3069 /// Number of candidate edges presented across all clusters.
3070 pub edges_examined: u64,
3071 /// Number of edges the harness ruled `keep` (no metadata change).
3072 pub edges_kept: u64,
3073 /// Number of edges the harness ruled `invalidate` (t_invalid set; row + body
3074 /// preserved).
3075 pub edges_invalidated: u64,
3076 /// Number of edges the harness ruled `supersede`/`merge` (marked superseded
3077 /// via the existing G0 tombstone column; row + body preserved).
3078 pub edges_superseded: u64,
3079}
3080
3081/// 0.8.20 Slice 5c (R-20-E3) — the provenance of a canonical row: which source
3082/// document it is attributable to, and therefore what `excise_source` must erase
3083/// when that source is withdrawn.
3084///
3085/// **Why a newtype and not `Option<String>`.** Erasure runs through provenance:
3086/// a row whose `source_id` is NULL is reachable by NO `excise_source` call and
3087/// is therefore **un-erasable**. Before 0.8.20 the public `PreparedWrite`
3088/// carried `source_id: Option<String>`, so a caller could express "no
3089/// provenance" and silently create such a row. A *runtime* rejection would not
3090/// have closed this: the facade crate re-exports `PreparedWrite` and
3091/// `Engine::write` is `pub`, so a caller can build the value directly and skip
3092/// any validation the engine performs. Replacing the field's type is what makes
3093/// the absence of provenance **inexpressible** rather than merely rejected —
3094/// the guarantee is enforced by `rustc`, not by a branch. `tests/ui/` in the
3095/// facade crate holds the compile-fail witness.
3096///
3097/// **This is a BREAKING change**, shipped ON by default as part of the 0.8.20
3098/// coordinated breaking-pair release. There is deliberately no compatibility
3099/// shim and no deprecation window: a shim would re-open the hole it closes.
3100///
3101/// **Reserved namespace.** Ids beginning with `_` belong to the engine and are
3102/// rejected by [`SourceId::new`]. Two are currently minted internally:
3103///
3104/// * [`SourceId::ENGINE_PREFIX`] (`_engine:`) — rows the engine derives for
3105/// itself (EXP-S coverage/graph substrate rows), which never pass through
3106/// `PreparedWrite` (design §4 item 6).
3107/// * [`SourceId::LEGACY_PRE_0_8_20`] (`_legacy:pre-0.8.20`) — stamped by schema
3108/// migration step 21 onto pre-0.8.20 rows that were stored with NULL
3109/// provenance, so they become erasable (R-20-E8). **Gated to UNGOVERNED rows
3110/// only** (`logical_id IS NULL`); a governed row keeps NULL `source_id` and
3111/// stays `purge`-addressable by its `logical_id` (TC-11 pin).
3112///
3113/// **`source_id` must not be PII.** It survives the erasure it authorises: the
3114/// `excise_source` audit row in `operational_mutations` records it verbatim, and
3115/// while 0.8.20 makes that audit row durable (design §2 defect D-A) the rule was
3116/// always that the handle you erase BY must not itself be the thing needing
3117/// erasure. Use an opaque document id, not an email address.
3118#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
3119pub struct SourceId(String);
3120
3121impl SourceId {
3122 /// Reserved prefix for engine-derived rows (design §4 item 6).
3123 pub const ENGINE_PREFIX: &'static str = "_engine:";
3124
3125 /// Reserved provenance stamped by schema migration step 21 onto pre-0.8.20
3126 /// UNGOVERNED rows that were stored with NULL provenance (R-20-E8).
3127 pub const LEGACY_PRE_0_8_20: &'static str = "_legacy:pre-0.8.20";
3128
3129 /// The single public constructor. Rejects the two ways a caller could
3130 /// express "effectively no provenance":
3131 ///
3132 /// * an empty or whitespace-only id — it names no source, and
3133 /// `excise_source` already refuses the empty string, so such a row would
3134 /// be un-erasable in practice;
3135 /// * an id in the engine's reserved `_`-prefixed namespace — a caller who
3136 /// could mint `_legacy:pre-0.8.20` could hide rows among the migration's
3137 /// back-filled ones, or mint `_engine:` rows that read as engine
3138 /// substrate.
3139 ///
3140 /// # Errors
3141 ///
3142 /// [`EngineError::WriteValidation`] for either rejection above.
3143 pub fn new(id: impl Into<String>) -> Result<Self, EngineError> {
3144 let id = id.into();
3145 if id.trim().is_empty() || id.starts_with('_') {
3146 return Err(EngineError::WriteValidation);
3147 }
3148 Ok(Self(id))
3149 }
3150
3151 /// Mint a reserved `_engine:*` provenance for an engine-derived row. Crate
3152 /// -internal by construction: the reserved namespace is exactly what
3153 /// [`SourceId::new`] refuses, so a caller cannot reach this spelling.
3154 pub(crate) fn engine_derived(role: &str) -> Self {
3155 Self(format!("{}{role}", Self::ENGINE_PREFIX))
3156 }
3157
3158 /// The on-disk `source_id` text.
3159 #[must_use]
3160 pub fn as_str(&self) -> &str {
3161 &self.0
3162 }
3163
3164 /// Consume into the owned on-disk text.
3165 #[must_use]
3166 pub fn into_string(self) -> String {
3167 self.0
3168 }
3169}
3170
3171impl AsRef<str> for SourceId {
3172 fn as_ref(&self) -> &str {
3173 &self.0
3174 }
3175}
3176
3177impl Display for SourceId {
3178 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
3179 f.write_str(&self.0)
3180 }
3181}
3182
3183impl TryFrom<String> for SourceId {
3184 type Error = EngineError;
3185
3186 fn try_from(value: String) -> Result<Self, Self::Error> {
3187 Self::new(value)
3188 }
3189}
3190
3191impl TryFrom<&str> for SourceId {
3192 type Error = EngineError;
3193
3194 fn try_from(value: &str) -> Result<Self, Self::Error> {
3195 Self::new(value)
3196 }
3197}
3198
3199/// Batch input shape for [`Engine::write`].
3200///
3201/// Marked `#[non_exhaustive]` per ADR-0.6.0-prepared-write-shape; new
3202/// entity variants land in 0.6.x without a major bump. Adding fields to
3203/// existing variants remains a binding-coordination change.
3204#[non_exhaustive]
3205#[derive(Clone, Debug, PartialEq)]
3206pub enum PreparedWrite {
3207 Node {
3208 kind: String,
3209 body: String,
3210 /// REQ-026 / AC-028 / AC-042 recovery seam, made **structurally
3211 /// mandatory** in 0.8.20 (R-20-E3). Was `Option<String>`; a `None`
3212 /// landed NULL on disk and produced a row no `excise_source` call could
3213 /// reach. See [`SourceId`] for why the fix is a type change rather than
3214 /// a validation check.
3215 source_id: SourceId,
3216 /// G0 (Slice 15) — stable cross-re-ingestion identity. `Some(id)`
3217 /// makes this write a transaction-time supersession of the prior
3218 /// active version of `(logical_id, kind)` (tombstone-then-insert).
3219 /// `None` is the legacy/own-identity default: a plain insert with a
3220 /// NULL `logical_id` (NULL-safe — never collides with other NULLs).
3221 logical_id: Option<String>,
3222 /// OPP-12 Phase-1 (0.8.19 Slice 5) — the create-time existence state.
3223 /// `InitialState::Active` (the [`Default`]) is the back-compat default and
3224 /// lands `state = 'active'` on disk (value-identical to the migration
3225 /// step-20 column DEFAULT). `InitialState::Pending` creates a quarantined
3226 /// node excluded from default retrieval. A `deleted`/`purged` node is
3227 /// UNREPRESENTABLE at create time (the [`InitialState`] type is the typed
3228 /// rejection) — those states are reachable only via the Slice-10
3229 /// `transition`/`purge` verbs.
3230 state: InitialState,
3231 /// OPP-12 Phase-1 (0.8.19 Slice 5) — advisory cause for the create-time
3232 /// `state` (e.g. the quarantine cause for a `pending` node), stored
3233 /// verbatim in `canonical_nodes.reason`. Engine never interprets it. `None`
3234 /// lands NULL (the back-compat default).
3235 reason: Option<String>,
3236 /// 0.8.20 Slice 15b (TC-34) — world-time validity window, INCLUSIVE lower
3237 /// bound, INTEGER epoch SECONDS UTC. `None` lands NULL = unbounded below.
3238 ///
3239 /// Slice 10b added the `valid_from`/`valid_until` columns, the [`ReadView`]
3240 /// validity predicate and [`Engine::crossed_boundary_since`] but NO writer,
3241 /// so a window could only be authored with raw SQL. These two fields are
3242 /// that writer. They are deliberately FIELDS rather than a new verb,
3243 /// exactly as [`PreparedWrite::Edge`] already carries `t_valid`/`t_invalid`:
3244 /// the governed command surface is unchanged.
3245 ///
3246 /// The pair is validated together — see `valid_until`.
3247 valid_from: Option<i64>,
3248 /// 0.8.20 Slice 15b (TC-34) — world-time validity window, EXCLUSIVE upper
3249 /// bound, INTEGER epoch SECONDS UTC. `None` lands NULL = unbounded above.
3250 ///
3251 /// The window is half-open `[valid_from, valid_until)`, matching the read
3252 /// predicate in `ReadView::validity_sql` exactly. Because it is half-open,
3253 /// a pair with `valid_from >= valid_until` describes an EMPTY window that no
3254 /// instant can ever satisfy — so [`Engine::write`] refuses it with
3255 /// [`EngineError::WriteValidation`] rather than storing a row that no
3256 /// default read could ever return. A ONE-SIDED window is never empty and is
3257 /// never refused, however extreme its single bound.
3258 ///
3259 /// **BREAKING (0.8.20 Slice 22, decision #18).** This refusal used to be
3260 /// [`EngineError::InvalidArgument`] NAMING both bounds. It is now the
3261 /// message-less `WriteValidation` unit variant — the one family the
3262 /// taxonomy of record assigns to a malformed submitted write SHAPE — so
3263 /// **the offending bounds are no longer carried in the error**. A caller
3264 /// that parsed them out must validate the pair before calling.
3265 valid_until: Option<i64>,
3266 },
3267 Edge {
3268 kind: String,
3269 from: String,
3270 to: String,
3271 /// REQ-026 / AC-028 / AC-042 recovery seam — see Node. Structurally
3272 /// mandatory since 0.8.20 (R-20-E3).
3273 source_id: SourceId,
3274 /// G0 (Slice 15) — see Node. Supersession semantics are identical on
3275 /// edges (keyed by `(logical_id, kind)`).
3276 logical_id: Option<String>,
3277 /// G11 (Slice 15) — the fact/relationship text. When `Some`, triggers
3278 /// FTS projection into `search_index_edges` and vector projection via
3279 /// the projection scheduler (kind `"edge_fact"`). Also triggers
3280 /// invalidate-not-accumulate on `(from_id, to_id, kind)`.
3281 body: Option<String>,
3282 /// G11 (Slice 15) — event valid-time. NULL = unknown / still valid.
3283 ///
3284 /// **TC-33 (HITL-RATIFIED 2026-07-21): INTEGER epoch seconds (UTC), not
3285 /// ISO-8601.** This is the GOVERNED SDK WRITE SURFACE, which carries the
3286 /// same representation as storage. ISO-8601 survives ONLY on the BYO-LLM
3287 /// extractor wire (`fathomdb.extract.v1`), where
3288 /// `normalize_extractor_timestamp` converts it with hard rejection.
3289 t_valid: Option<i64>,
3290 /// G11 (Slice 15) — event invalid-time. NULL = still valid.
3291 ///
3292 /// **TC-33: INTEGER epoch seconds (UTC)** — see `t_valid`. The
3293 /// NULL-means-still-valid semantic is load-bearing and unchanged, which
3294 /// is why the schema pins the type with a `typeof` CHECK rather than
3295 /// `NOT NULL`.
3296 t_invalid: Option<i64>,
3297 /// G11 (Slice 15) — extraction confidence ∈ [0.0, 1.0]. NULL for
3298 /// non-BYO-LLM-ingested edges.
3299 confidence: Option<f64>,
3300 /// G11 (Slice 15) — opaque model/provider id from the BYO-LLM harness
3301 /// `ready.model` field. NULL for non-BYO-LLM edges.
3302 extractor_model_id: Option<String>,
3303 /// R3 (Slice 30, SCHEMA-GATE-1, HITL-SIGNED 2026-06-13) — set when the
3304 /// ELPS extractor defaulted this edge's `t_valid` to `created_at` rather
3305 /// than deriving it from the document text. Such edges have untrustworthy
3306 /// event times and are excluded from graph-arm BFS temporal queries.
3307 /// `None`/`false` = not a fallback; `Some(true)` = fallback.
3308 temporal_fallback: Option<bool>,
3309 },
3310 OpStore {
3311 collection: String,
3312 record_key: String,
3313 schema_id: Option<String>,
3314 body: String,
3315 },
3316 AdminSchema {
3317 name: String,
3318 kind: String,
3319 schema_json: String,
3320 retention_json: String,
3321 },
3322}
3323
3324/// EXP-S (0.8.14 Slice 5, D1) — structural-role tag for a canonical row.
3325///
3326/// A SEPARATE axis from the doc-type `kind` (email/article/paper/meeting/
3327/// note/todo/doc/edge_fact): `row_kind` describes *what structural role* a row
3328/// plays in the "one store, many indexes" substrate, not what document type it
3329/// carries. Stored in `canonical_nodes.row_kind` (schema migration step 16).
3330///
3331/// `Leaf` is the default (a normal record; every existing/normal write is a
3332/// leaf — back-compat preserving). `Coverage` = coverage/summary rows;
3333/// `Graph` = graph structural rows. Engine-internal in 0.8.14 — there is NO
3334/// public Py/TS SDK surface for `row_kind` this release (`Leaf` for all normal
3335/// writes; `Coverage`/`Graph` are set only by internal paths). Cross-binding
3336/// parity (X1) is a Slice-40 concern.
3337#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3338pub enum RowKind {
3339 Leaf,
3340 Coverage,
3341 Graph,
3342}
3343
3344impl RowKind {
3345 /// On-disk `canonical_nodes.row_kind` spelling. Must match the migration
3346 /// step-16 `DEFAULT 'leaf'` and the schema vocabulary (D1).
3347 #[must_use]
3348 pub fn as_str(self) -> &'static str {
3349 match self {
3350 RowKind::Leaf => "leaf",
3351 RowKind::Coverage => "coverage",
3352 RowKind::Graph => "graph",
3353 }
3354 }
3355}
3356
3357/// OPP-12 record-lifecycle Phase-1 (0.8.19 Slice 5) — the existence axis.
3358///
3359/// One mutually-exclusive typed enum stored as TEXT in the `canonical_nodes.state`
3360/// column (schema migration step-20). Semantics (design §2 / plan §1):
3361/// `Pending` = present + versioned but NOT admitted to default retrieval
3362/// (quarantine / promotion gate);
3363/// `Active` = admitted to default retrieval (the shipped-corpus default);
3364/// `Deleted` = soft-deleted, retained + recoverable, excluded from default
3365/// reads, stays indexed behind the flag;
3366/// `Purged` = terminal, physically erased.
3367/// `Deleted`/`Purged` are reachable only through the Phase-2/Slice-10
3368/// `transition`/`purge` verbs — they can NEVER be a create-time state (see
3369/// [`InitialState`]).
3370#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3371pub enum LifecycleState {
3372 Pending,
3373 Active,
3374 Deleted,
3375 Purged,
3376}
3377
3378impl LifecycleState {
3379 /// On-disk `canonical_nodes.state` spelling. Must match the migration step-20
3380 /// `DEFAULT 'active'` and the `state = 'active'` default-read exclusion.
3381 #[must_use]
3382 pub fn as_str(self) -> &'static str {
3383 match self {
3384 LifecycleState::Pending => "pending",
3385 LifecycleState::Active => "active",
3386 LifecycleState::Deleted => "deleted",
3387 LifecycleState::Purged => "purged",
3388 }
3389 }
3390
3391 /// Parse the on-disk spelling back into the typed enum. `None` for any value
3392 /// outside the closed vocabulary (a corrupt/foreign `state`).
3393 #[must_use]
3394 pub fn from_str_opt(value: &str) -> Option<Self> {
3395 match value {
3396 "pending" => Some(LifecycleState::Pending),
3397 "active" => Some(LifecycleState::Active),
3398 "deleted" => Some(LifecycleState::Deleted),
3399 "purged" => Some(LifecycleState::Purged),
3400 _ => None,
3401 }
3402 }
3403
3404 /// OPP-12 Phase-1 (0.8.19 Slice 10) — the target states legally reachable from
3405 /// `self` via the `transition` VERB (design §2 legal-transition table). This
3406 /// is the verb-specific enumeration reported by `IllegalTransitionError.legal`:
3407 /// `Pending` → `[Active, Deleted]` (promote / reject)
3408 /// `Active` → `[Deleted]` (soft-delete)
3409 /// `Deleted` → `[Active]` (undelete)
3410 /// `Purged` → `[]` (terminal; nothing is reachable)
3411 /// `Purged` is DELIBERATELY excluded even from `Deleted`: reaching `purged` is
3412 /// the `purge` verb's job (see [`Engine::purge`]), NOT a legal `transition`
3413 /// target, so reporting it here would mislead a caller into thinking
3414 /// `transition(deleted → purged)` is legal when it is not. Likewise `Pending`
3415 /// is create-time-only and is never a `transition` target. Derived directly
3416 /// from [`is_legal_transition_move`] so this can never drift from the table.
3417 #[must_use]
3418 pub fn legal_next_states(self) -> Vec<LifecycleState> {
3419 [
3420 LifecycleState::Pending,
3421 LifecycleState::Active,
3422 LifecycleState::Deleted,
3423 LifecycleState::Purged,
3424 ]
3425 .into_iter()
3426 .filter(|&to| is_legal_transition_move(self, to))
3427 .collect()
3428 }
3429}
3430
3431/// OPP-12 Phase-1 (0.8.19 Slice 10) — whether `(from, to)` is one of the four
3432/// legal `transition`-verb moves (design §2 table): `pending→active` (promote),
3433/// `pending→deleted` (reject), `active→deleted` (soft-delete), `deleted→active`
3434/// (undelete). Every other pair — self-loops, any move to `Purged` (purge-only)
3435/// or `Pending` (create-only), or from `Purged` — is illegal via `transition`.
3436#[must_use]
3437fn is_legal_transition_move(from: LifecycleState, to: LifecycleState) -> bool {
3438 matches!(
3439 (from, to),
3440 (LifecycleState::Pending, LifecycleState::Active)
3441 | (LifecycleState::Pending, LifecycleState::Deleted)
3442 | (LifecycleState::Active, LifecycleState::Deleted)
3443 | (LifecycleState::Deleted, LifecycleState::Active)
3444 )
3445}
3446
3447/// OPP-12 Phase-1 (0.8.19 Slice 5) — the CREATE-TIME subset of [`LifecycleState`].
3448///
3449/// A write can only bring a node into existence as `Pending` or `Active` (design
3450/// §2 / gap-6). You CANNOT create a `Deleted`/`Purged` node — those states are
3451/// reachable only via the `transition`/`purge` verbs (Slice 10). Making the
3452/// create-time surface a separate two-variant type is the TYPED rejection: a
3453/// `deleted`/`purged` create is simply unrepresentable in the Rust API (the SDK
3454/// bindings map an out-of-subset string to a typed write-validation error).
3455#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
3456pub enum InitialState {
3457 Pending,
3458 /// The back-compat default: every pre-lifecycle write lands `Active`, matching
3459 /// the migration step-20 `DEFAULT 'active'`.
3460 #[default]
3461 Active,
3462}
3463
3464impl InitialState {
3465 /// On-disk `canonical_nodes.state` spelling for a create-time state.
3466 #[must_use]
3467 pub fn as_str(self) -> &'static str {
3468 match self {
3469 InitialState::Pending => "pending",
3470 InitialState::Active => "active",
3471 }
3472 }
3473
3474 /// The full [`LifecycleState`] this create-time state corresponds to.
3475 #[must_use]
3476 pub fn to_lifecycle_state(self) -> LifecycleState {
3477 match self {
3478 InitialState::Pending => LifecycleState::Pending,
3479 InitialState::Active => LifecycleState::Active,
3480 }
3481 }
3482
3483 /// Parse a caller-supplied create-time `state` string into the create-time
3484 /// subset. `Some(state)` for `"pending"`/`"active"`; `None` for `"deleted"`,
3485 /// `"purged"`, or any unknown value — the SDK bindings turn `None` into a
3486 /// typed write-validation rejection (you cannot CREATE a deleted/purged node).
3487 #[must_use]
3488 pub fn from_create_str(value: &str) -> Option<Self> {
3489 match value {
3490 "pending" => Some(InitialState::Pending),
3491 "active" => Some(InitialState::Active),
3492 _ => None,
3493 }
3494 }
3495}
3496
3497/// F5 (0.8.14 Slice 10) — per-field BM25F weights for the `search_index_v2`
3498/// multi-column FTS index. One weight per indexed field
3499/// (`kind`/`body`/`status`), applied as the field's contribution multiplier in
3500/// the BM25F weighted-term-frequency accumulation.
3501///
3502/// The default is uniform (`1.0` each) — the "unweighted" baseline the R-F5-1
3503/// acceptance test contrasts against. Boosting a field (e.g. `kind`) makes a
3504/// match in that field outrank a same-strength match in a lower-weighted field.
3505/// Engine-internal for 0.8.14: there is NO public Py/TS SDK surface for these
3506/// tunables this release (cross-binding parity is a Slice-40/X1 concern).
3507#[derive(Clone, Copy, Debug, PartialEq)]
3508pub struct Bm25fFieldWeights {
3509 pub kind: f64,
3510 pub body: f64,
3511 pub status: f64,
3512}
3513
3514impl Default for Bm25fFieldWeights {
3515 fn default() -> Self {
3516 Self { kind: 1.0, body: 1.0, status: 1.0 }
3517 }
3518}
3519
3520/// F5 (0.8.14 Slice 10) — the compiled BM25F query plan for the fielded lexical
3521/// arm (`ADR-0.8.1` §3.2 `BM25fQueryPlan`). Carries the tunable per-field
3522/// `weights` and the tunable length-normalization `b` (and the term-saturation
3523/// `k1`).
3524///
3525/// NOTE on `b`: SQLite FTS5's built-in `bm25()` auxiliary function pins its
3526/// internal `k1`/`b` and exposes ONLY per-column weights — it cannot express a
3527/// tunable `b`. So the score is computed in-engine (a textbook BM25F over the
3528/// FTS5-recalled candidates) rather than delegated to the built-in `bm25()`:
3529/// that is what makes `b` (and `k1`) genuinely tunable here, not a dead
3530/// parameter. The `search_index_v2` FTS5 index is still load-bearing — it does
3531/// the candidate recall (`MATCH`) that the scorer then ranks.
3532///
3533/// Defaults match Robertson/SQLite BM25 (`b = 0.75`, `k1 = 1.2`) with uniform
3534/// field weights. Engine-internal for 0.8.14 (no SDK surface).
3535#[derive(Clone, Copy, Debug, PartialEq)]
3536pub struct Bm25fQueryPlan {
3537 pub weights: Bm25fFieldWeights,
3538 pub b: f64,
3539 pub k1: f64,
3540}
3541
3542impl Default for Bm25fQueryPlan {
3543 fn default() -> Self {
3544 Self { weights: Bm25fFieldWeights::default(), b: 0.75, k1: 1.2 }
3545 }
3546}
3547
3548/// Snapshot of engine-internal counters returned by [`Engine::counters`].
3549///
3550/// Public key set is owned by `dev/design/lifecycle.md` § Public key set
3551/// and locked by AC-004a. Reading a snapshot is non-perturbing per
3552/// AC-004c. The 0.6.0 surface exposes exactly these seven fields.
3553#[derive(Clone, Debug, Default, Eq, PartialEq)]
3554pub struct CounterSnapshot {
3555 pub queries: u64,
3556 pub writes: u64,
3557 pub write_rows: u64,
3558 pub errors_by_code: BTreeMap<String, u64>,
3559 pub admin_ops: u64,
3560 pub cache_hit: u64,
3561 pub cache_miss: u64,
3562}
3563
3564pub use lifecycle::Subscription;
3565
3566/// Stable corruption-on-open detail carried by
3567/// [`EngineOpenError::Corruption`].
3568///
3569/// Layout owned by `dev/design/errors.md` § Corruption detail owner.
3570#[derive(Clone, Debug, Eq, PartialEq)]
3571pub struct CorruptionDetail {
3572 pub kind: CorruptionKind,
3573 pub stage: OpenStage,
3574 pub locator: CorruptionLocator,
3575 pub recovery_hint: RecoveryHint,
3576}
3577
3578/// Open-path corruption category.
3579///
3580/// 0.6.0 emits exactly the four members below; per
3581/// `dev/design/errors.md` § Engine.open corruption table, doctor-only
3582/// finding codes are not represented here.
3583#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3584pub enum CorruptionKind {
3585 WalReplayFailure,
3586 HeaderMalformed,
3587 SchemaInconsistent,
3588 EmbedderIdentityDrift,
3589}
3590
3591/// `Engine.open` stage at which corruption was detected.
3592///
3593/// Per ADR-0.6.0-corruption-open-behavior, `LockAcquisition` is intentionally
3594/// not a member here; lock contention is surfaced via
3595/// [`EngineOpenError::DatabaseLocked`].
3596#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3597pub enum OpenStage {
3598 WalReplay,
3599 HeaderProbe,
3600 SchemaProbe,
3601 EmbedderIdentity,
3602}
3603
3604/// Locator pointing at the corrupted region of the database file.
3605///
3606/// Variant set owned by `dev/design/errors.md` § CorruptionLocator
3607/// ownership. `OpaqueSqliteError` is the required fallback when SQLite
3608/// surfaces corruption without a usable structured locator.
3609#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3610pub enum CorruptionLocator {
3611 FileOffset { offset: u64 },
3612 PageId { page: u32 },
3613 TableRow { table: &'static str, rowid: i64 },
3614 Vec0ShadowRow { partition: &'static str, rowid: i64 },
3615 MigrationStep { from: u32, to: u32 },
3616 OpaqueSqliteError { sqlite_extended_code: i32 },
3617}
3618
3619/// Recovery dispatch surface attached to a corruption detail.
3620///
3621/// `code` is the stable dispatch key used by bindings and doctor output;
3622/// `doc_anchor` points at the documentation section that explains the
3623/// remediation path.
3624#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3625pub struct RecoveryHint {
3626 pub code: &'static str,
3627 pub doc_anchor: &'static str,
3628}
3629
3630#[derive(Clone, Debug, Eq, PartialEq)]
3631pub enum EngineOpenError {
3632 DatabaseLocked {
3633 holder_pid: Option<u32>,
3634 },
3635 Corruption(CorruptionDetail),
3636 IncompatibleSchemaVersion {
3637 seen: u32,
3638 supported: u32,
3639 },
3640 MigrationError {
3641 schema_version_before: u32,
3642 schema_version_current: u32,
3643 step_id: u32,
3644 },
3645 EmbedderIdentityMismatch {
3646 stored: EmbedderIdentity,
3647 supplied: EmbedderIdentity,
3648 },
3649 EmbedderDimensionMismatch {
3650 stored: u32,
3651 supplied: u32,
3652 },
3653 /// Embedder runtime returned a typed error during `Engine::open`.
3654 Embedder(RuntimeEmbedderError),
3655 Io {
3656 message: String,
3657 },
3658}
3659
3660/// Caller-facing selector for the embedder used by an opened engine
3661/// (`dev/design/embedder.md` §0).
3662#[derive(Clone)]
3663pub enum EmbedderChoice {
3664 /// Use the engine's default embedder. With the `default-embedder`
3665 /// Cargo feature enabled, this materializes a `CandleBgeEmbedder`
3666 /// via the EU-3 loader at `Engine::open`; on first use the loader
3667 /// downloads pinned bge-small-en-v1.5 weights from HuggingFace per
3668 /// `ADR-0.7.1-default-embedder-weight-fetch`. Without the feature,
3669 /// this returns `EmbedderError::Failed` directing the caller to
3670 /// rebuild with `--features default-embedder` or supply
3671 /// `EmbedderChoice::Caller`.
3672 Default,
3673 /// Caller supplies the embedder instance. The supplied embedder's
3674 /// `identity()` becomes the workspace's default-profile identity.
3675 Caller(Arc<dyn Embedder>),
3676 /// No embedder configured. Engine opens; subsequent vector writes
3677 /// fail with `EngineError::EmbedderNotConfigured`. Useful for
3678 /// read-only or canonical-only flows.
3679 None,
3680}
3681
3682impl Display for EngineOpenError {
3683 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
3684 match self {
3685 Self::DatabaseLocked { holder_pid } => match holder_pid {
3686 Some(pid) => write!(f, "database is locked by process {pid}"),
3687 None => write!(f, "database is locked by another engine instance"),
3688 },
3689 Self::Corruption(detail) => {
3690 write!(
3691 f,
3692 "engine corruption at {:?} stage: {}",
3693 detail.stage, detail.recovery_hint.code
3694 )
3695 }
3696 Self::IncompatibleSchemaVersion { seen, supported } => write!(
3697 f,
3698 "database schema version {seen} is incompatible with supported version {supported}"
3699 ),
3700 Self::MigrationError {
3701 schema_version_before,
3702 schema_version_current,
3703 step_id,
3704 } => write!(
3705 f,
3706 "schema migration failed at step {step_id}; schema version remained between {schema_version_before} and {schema_version_current}"
3707 ),
3708 Self::EmbedderIdentityMismatch { stored, supplied } => write!(
3709 f,
3710 "embedder identity mismatch: stored {}@{}, supplied {}@{}",
3711 stored.name, stored.revision, supplied.name, supplied.revision,
3712 ),
3713 Self::EmbedderDimensionMismatch { stored, supplied } => write!(
3714 f,
3715 "embedder vector dimension mismatch: stored {stored}, supplied {supplied}",
3716 ),
3717 Self::Embedder(err) => match err {
3718 RuntimeEmbedderError::Timeout => write!(f, "embedder timeout during open"),
3719 RuntimeEmbedderError::Failed { message } => {
3720 write!(f, "embedder failure during open: {message}")
3721 }
3722 },
3723 Self::Io { message } => write!(f, "database I/O error: {message}"),
3724 }
3725 }
3726}
3727
3728impl Error for EngineOpenError {}
3729
3730#[derive(Clone, Debug, Eq, PartialEq)]
3731pub enum EngineError {
3732 Storage,
3733 Projection,
3734 Vector,
3735 Embedder,
3736 EmbedderNotConfigured,
3737 KindNotVectorIndexed,
3738 EmbedderDimensionMismatch {
3739 expected: u32,
3740 actual: u32,
3741 },
3742 Scheduler,
3743 OpStore,
3744 WriteValidation,
3745 SchemaValidation,
3746 Overloaded,
3747 Closing,
3748 /// G11 (Slice 15) — BYO-LLM extractor subprocess error (protocol mismatch,
3749 /// spawn failure, or harness-returned error code).
3750 Extractor,
3751 /// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — BYO-LLM consolidation provider
3752 /// error (protocol mismatch, spawn/handshake failure, task not advertised in
3753 /// `supported_tasks`, or a malformed/out-of-cluster verdict). Rides the SAME
3754 /// `provider_session` transport as `Extractor`; this is the task-specific leaf.
3755 Consolidator,
3756 /// G4 (Slice 35) — filter predicate construction error: non-allowlisted
3757 /// path or invalid filter argument. NOT a panic — returned as a typed error
3758 /// from [`Predicate::json_path_eq`] / [`Predicate::json_path_compare`].
3759 InvalidFilter {
3760 reason: String,
3761 },
3762 /// Slice 20 (G5/G6) — an argument is out of the accepted range (e.g.
3763 /// `depth > 3` for graph traversal). The `msg` field carries a
3764 /// human-readable explanation; it is intentionally non-exhaustive so the
3765 /// binding layer can forward it as a `ValueError` / `TypeError`.
3766 InvalidArgument {
3767 msg: String,
3768 },
3769 /// 0.8.18 Slice 5 (#5 vector-equivalence probe KEYSTONE) — the open-time
3770 /// self-check re-embedded the 45 committed probes with the live backend and
3771 /// found a divergence beyond the frozen D4 floor (a Phase-1 mean-centered
3772 /// `embedding_bin` sign flip, OR a Phase-2 un-centered L2 distance over
3773 /// `VECTOR_EQUIVALENCE_L2_EPSILON`). `Engine::open` succeeded into a degraded
3774 /// state (`dense_disabled = true`); this query-time error is raised at the
3775 /// single choke point [`Engine::search_inner_with_stats`] BEFORE any embedding
3776 /// / vector SQL / graph seeding / CE rerank, refusing EVERY vector-dependent
3777 /// arm (`search`, `search_expand`, explain/rerank, graph-arm). The explicit
3778 /// text-only/FTS-only path ([`Engine::search_text_only`]) stays serviceable.
3779 /// Sibling of the open-time `EngineOpenError::EmbedderIdentityMismatch`; per
3780 /// ADR-0.8.18 codex R2 U1-1 the refusal surfaces as an `EngineError` (queries
3781 /// never surface `EngineOpenError`). `reason` carries a human-readable summary.
3782 VectorEquivalenceMismatch {
3783 reason: String,
3784 },
3785 /// OPP-12 Phase-1 (0.8.19 Slice 10) — a lifecycle `transition`/`purge` move
3786 /// that the engine-enforced legal-transition table (design §2) forbids.
3787 /// Raised for an illegal `transition` target (`purged`/`pending` are never
3788 /// `transition` targets; self-loops; a from→to pair not in the table) AND for
3789 /// a `purge` precondition failure (purge is legal only from `deleted`).
3790 /// `from_state`/`to_state` use the FULL, parity-safe field names (S7 — `from`
3791 /// is a Python reserved word); `legal` enumerates the target states reachable
3792 /// from `from_state` in the full state machine.
3793 IllegalTransition {
3794 from_state: LifecycleState,
3795 to_state: LifecycleState,
3796 legal: Vec<LifecycleState>,
3797 },
3798 /// OPP-12 Phase-1 (0.8.19 Slice 10) — a lifecycle verb (`transition`/`purge`)
3799 /// was addressed with a non-`Logical` id space (a `Content`/`h:` doc-seeded or
3800 /// `Passage`/`p:` synthetic id). Only the `Logical` (`l:`) space is
3801 /// lifecycle-addressable (design §3); this is a typed refusal, never a panic
3802 /// or a silent no-op. `id_space` carries the offending [`IdSpaceKind`].
3803 NotLifecycleAddressable {
3804 id_space: IdSpaceKind,
3805 },
3806 /// 0.8.20 Slice 5b (R-20-E5, design `0.8.20-slice0-erasure-design.md` §4
3807 /// item 4) — an erasure verb (`purge` / `excise_source` /
3808 /// `excise_collection_record`) deleted its rows but could NOT complete the
3809 /// erasure **at rest**, so it refuses to report success.
3810 ///
3811 /// The motivating case is the write-ahead log. `PRAGMA secure_delete=ON`
3812 /// zeroes pages freed inside the database file, but the erased content also
3813 /// sits in the WAL as committed frames from the ORIGINAL insert: an erasure
3814 /// DELETE appends new frames, it never rewrites old ones. Only a
3815 /// `wal_checkpoint(TRUNCATE)` removes them, and a concurrent reader pinning a
3816 /// WAL snapshot makes that checkpoint return `busy`. After a bounded retry
3817 /// the verb raises THIS error rather than returning `Ok` over erased bytes
3818 /// that are still `grep`-able on disk.
3819 ///
3820 /// **Contract: an erasure verb must never report success on an incomplete
3821 /// erasure.** The row deletions are committed and durable when this is
3822 /// raised; what failed is the at-rest scrub. The remedy is to retry the verb
3823 /// (or `recover --truncate-wal`) once the blocking reader has finished.
3824 /// `stage` names the uncompleted step (e.g. `"wal_checkpoint"`,
3825 /// `"telemetry_redaction"`); `detail` is a human-readable summary.
3826 ErasureIncomplete {
3827 stage: String,
3828 detail: String,
3829 },
3830 /// 0.8.20 Slice 15d (R-20-PR) — `configure_projections` refused an
3831 /// incompatible/DESTRUCTIVE change to an existing projection `name` that was
3832 /// NOT accompanied by an explicit `drop`. Omission from the spec never drops
3833 /// (C3, `api-surface.md:27`); a role REMOVAL or a tokenizer/embedder change
3834 /// on a live projection would silently discard an expensive-to-rebuild
3835 /// resource, so it is refused with the destructive `delta` surfaced. The
3836 /// caller re-issues with `drop: [name]` to consciously rebuild.
3837 ProjectionDestructive {
3838 name: String,
3839 delta: String,
3840 },
3841}
3842
3843impl Display for EngineError {
3844 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
3845 match self {
3846 Self::Storage => write!(f, "storage error"),
3847 Self::Projection => write!(f, "projection error"),
3848 Self::Vector => write!(f, "vector error"),
3849 Self::Embedder => write!(f, "embedder error"),
3850 Self::EmbedderNotConfigured => write!(f, "embedder is not configured"),
3851 Self::KindNotVectorIndexed => write!(f, "kind is not configured for vector indexing"),
3852 Self::EmbedderDimensionMismatch { expected, actual } => {
3853 write!(f, "embedder dimension mismatch: expected {expected}, actual {actual}")
3854 }
3855 Self::Scheduler => write!(f, "scheduler error"),
3856 Self::OpStore => write!(f, "op-store error"),
3857 Self::WriteValidation => write!(f, "write validation error"),
3858 Self::SchemaValidation => write!(f, "schema validation error"),
3859 Self::Overloaded => write!(f, "engine overloaded"),
3860 Self::Closing => write!(f, "engine is closing"),
3861 Self::Extractor => write!(f, "extractor error"),
3862 Self::Consolidator => write!(f, "consolidator error"),
3863 Self::InvalidFilter { reason } => write!(f, "invalid filter: {reason}"),
3864 Self::InvalidArgument { msg } => write!(f, "invalid argument: {msg}"),
3865 Self::VectorEquivalenceMismatch { reason } => {
3866 write!(f, "vector-equivalence self-check failed; dense retrieval refused: {reason}")
3867 }
3868 Self::IllegalTransition { from_state, to_state, legal } => {
3869 let legal_list = legal.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ");
3870 write!(
3871 f,
3872 "illegal lifecycle transition {} -> {}; legal targets from {}: [{}]",
3873 from_state.as_str(),
3874 to_state.as_str(),
3875 from_state.as_str(),
3876 legal_list,
3877 )
3878 }
3879 Self::NotLifecycleAddressable { id_space } => write!(
3880 f,
3881 "id space {:?} ({}) is not lifecycle-addressable; only the logical (l:) space is",
3882 id_space,
3883 id_space.prefix(),
3884 ),
3885 Self::ErasureIncomplete { stage, detail } => write!(
3886 f,
3887 "erasure incomplete at stage '{stage}': the rows were deleted but the erasure \
3888 could not be completed at rest ({detail})",
3889 ),
3890 Self::ProjectionDestructive { name, delta } => write!(
3891 f,
3892 "configure_projections refused a destructive change to projection '{name}' \
3893 without an explicit drop ({delta}); re-issue with drop: [\"{name}\"] to rebuild",
3894 ),
3895 }
3896 }
3897}
3898
3899impl EngineError {
3900 /// Stable machine-readable code for `errors_by_code` keys.
3901 ///
3902 /// Names match the binding-facing class stems in
3903 /// `dev/design/errors.md` § Binding-facing class matrix.
3904 fn stable_code(&self) -> &'static str {
3905 match self {
3906 Self::Storage => "StorageError",
3907 Self::Projection => "ProjectionError",
3908 Self::Vector => "VectorError",
3909 Self::Embedder => "EmbedderError",
3910 Self::EmbedderNotConfigured => "EmbedderNotConfiguredError",
3911 Self::KindNotVectorIndexed => "KindNotVectorIndexedError",
3912 Self::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
3913 Self::Scheduler => "SchedulerError",
3914 Self::OpStore => "OpStoreError",
3915 Self::WriteValidation => "WriteValidationError",
3916 Self::SchemaValidation => "SchemaValidationError",
3917 Self::Overloaded => "OverloadedError",
3918 Self::Closing => "ClosingError",
3919 Self::Extractor => "ExtractorError",
3920 Self::Consolidator => "ConsolidatorError",
3921 Self::InvalidFilter { .. } => "InvalidFilterError",
3922 Self::InvalidArgument { .. } => "InvalidArgumentError",
3923 Self::VectorEquivalenceMismatch { .. } => "VectorEquivalenceMismatchError",
3924 Self::IllegalTransition { .. } => "IllegalTransitionError",
3925 Self::NotLifecycleAddressable { .. } => "NotLifecycleAddressableError",
3926 Self::ErasureIncomplete { .. } => "ErasureIncompleteError",
3927 Self::ProjectionDestructive { .. } => "ProjectionDestructiveError",
3928 }
3929 }
3930}
3931
3932impl Error for EngineError {}
3933
3934/// Doctor `check-integrity` invocation flags. `quick` and `round_trip`
3935/// are accepted in 0.6.0 but treated as default; only `full` activates
3936/// `PRAGMA integrity_check`. Per `dev/design/recovery.md` § Doctor-only
3937/// flags.
3938#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
3939pub struct CheckIntegrityOpts {
3940 pub quick: bool,
3941 pub full: bool,
3942 pub round_trip: bool,
3943}
3944
3945/// One section of an [`IntegrityReport`]. Either every check in the
3946/// section was clean, or one or more typed [`Finding`]s describe the
3947/// detected issue. Per AC-043b.
3948#[derive(Clone, Debug, Eq, PartialEq)]
3949pub enum Section {
3950 Clean,
3951 Findings(Vec<Finding>),
3952}
3953
3954/// Single doctor finding record. Stable report-shape per AC-043c. The
3955/// `code` and `doc_anchor` strings are stable dispatch keys owned by
3956/// `dev/design/recovery.md` § Code-to-operator-action cross-reference.
3957#[derive(Clone, Debug, Eq, PartialEq)]
3958pub struct Finding {
3959 pub code: &'static str,
3960 pub stage: &'static str,
3961 pub locator: CorruptionLocator,
3962 pub doc_anchor: &'static str,
3963 pub detail: String,
3964}
3965
3966/// Three-section integrity report. AC-043a pins exactly these three
3967/// keys.
3968#[derive(Clone, Debug, Eq, PartialEq)]
3969pub struct IntegrityReport {
3970 pub physical: Section,
3971 pub logical: Section,
3972 pub semantic: Section,
3973}
3974
3975/// Result of a successful [`Engine::safe_export`] call. The returned
3976/// `manifest_sha256` equals the SHA-256 of the export file bytes (per
3977/// AC-039a) and matches the `sha256` field written into the manifest
3978/// JSON.
3979#[derive(Clone, Debug, Eq, PartialEq)]
3980pub struct SafeExportArtifact {
3981 pub export_path: PathBuf,
3982 pub manifest_path: PathBuf,
3983 pub manifest_sha256: String,
3984}
3985
3986/// Phase 9 Pack B trace report (AC-042). One event per canonical row
3987/// attributable to the requested `source_id`, ordered by `write_cursor`
3988/// ascending.
3989#[derive(Clone, Debug, Eq, PartialEq)]
3990pub struct TraceReport {
3991 pub source_ref: String,
3992 pub events: Vec<TraceEvent>,
3993}
3994
3995/// Single canonical-row tracing record. `table` is one of
3996/// `"canonical_nodes"` or `"canonical_edges"`.
3997#[derive(Clone, Debug, Eq, PartialEq)]
3998pub struct TraceEvent {
3999 pub write_cursor: u64,
4000 pub kind: String,
4001 pub table: &'static str,
4002}
4003
4004/// Which shadow-state surface a [`RebuildReport`] describes.
4005/// `Projections` covers the full FTS5 + vec0 + projection-terminal
4006/// rebuild emitted by [`Engine::rebuild_projections`]. `Vec0` covers
4007/// the vec0-only path emitted by [`Engine::rebuild_vec0`].
4008#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4009pub enum RebuildKind {
4010 Projections,
4011 Vec0,
4012}
4013
4014/// Structured result of a rebuild operation. `rows_invalidated` is the
4015/// total shadow-state rows truncated before re-derivation; `rows_rebuilt`
4016/// is the count of rows the synchronous rebuild loop re-materialised
4017/// (asynchronous re-enqueue work performed by the projection scheduler is
4018/// not counted here). `projection_cursor_after` is the post-rebuild value
4019/// of the projection cursor.
4020#[derive(Clone, Debug, Eq, PartialEq)]
4021pub struct RebuildReport {
4022 pub kind: RebuildKind,
4023 pub rows_invalidated: u64,
4024 pub rows_rebuilt: u64,
4025 pub projection_cursor_after: u64,
4026}
4027
4028/// Phase 9 Pack B excise report (AC-028a/b/c). Counts are post-excise
4029/// totals; `projections_invalidated` reports the shadow-row invalidation
4030/// total (FTS5 + vec0 + projection terminal) for the excised source.
4031#[derive(Clone, Debug, Eq, PartialEq)]
4032pub struct ExciseReport {
4033 pub source_ref: String,
4034 pub nodes_excised: u64,
4035 pub edges_excised: u64,
4036 pub projections_invalidated: u64,
4037}
4038
4039/// 0.8.20 Slice 15d (R-20-PR, C-1) — one member of a [`ProjectionSpec`]'s role
4040/// set. **Exactly three members** (HITL-ratified S8, `api-surface.md:87`):
4041/// `searchable→FTS` and `searchable→vector` are NOT roles — they are tier labels
4042/// carried by the `fts`/`vector` sub-objects of the spec, so an attribute is
4043/// `Searchable` once and the sub-objects select FTS-only / vector-only / both.
4044#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
4045pub enum ProjectionRole {
4046 /// Projects into the EAV store + its `(attr_name, attr_value)` composite
4047 /// index — cheap equality/range, built same-transaction.
4048 Filterable,
4049 /// The F9 importance/recency signal. **Graceful-absent (Q6a):** declaring
4050 /// it is legal and never errors, but the engine DEFERS the build until F9
4051 /// exists and grafts it on the next idempotent `configure_projections`.
4052 Rankable,
4053 /// Full-text / dense recall of the meaning text. The `fts`/`vector`
4054 /// sub-objects select the sub-target.
4055 Searchable,
4056}
4057
4058impl ProjectionRole {
4059 #[must_use]
4060 pub fn as_str(self) -> &'static str {
4061 match self {
4062 ProjectionRole::Filterable => "filterable",
4063 ProjectionRole::Rankable => "rankable",
4064 ProjectionRole::Searchable => "searchable",
4065 }
4066 }
4067
4068 #[must_use]
4069 pub fn from_str_opt(value: &str) -> Option<Self> {
4070 match value {
4071 "filterable" => Some(ProjectionRole::Filterable),
4072 "rankable" => Some(ProjectionRole::Rankable),
4073 "searchable" => Some(ProjectionRole::Searchable),
4074 _ => None,
4075 }
4076 }
4077}
4078
4079/// 0.8.20 Slice 15d (R-20-PR) — the `searchable→FTS` sub-target selector.
4080#[derive(Clone, Debug, Default, Eq, PartialEq)]
4081pub struct ProjectionFts {
4082 /// Optional tokenizer override; `None` ⇒ the engine default FTS5 tokenizer
4083 /// (`body`-FTS's `porter unicode61 remove_diacritics 2`). A custom
4084 /// per-attr tokenizer is the ≥0.9.x multi-field FTS work — recorded but
4085 /// not honoured here (graceful-graft later, same as `rankable`).
4086 pub tokenizer: Option<String>,
4087}
4088
4089/// 0.8.20 Slice 20 (R-20-DR) — the ENGINE-SET readiness of the
4090/// `searchable→vector` projection, per
4091/// `dev/design/record-lifecycle-protocol/projection-registry-and-async-embed.md`
4092/// §3.
4093///
4094/// **Exactly two members.** `filterable` and `searchable→FTS` are
4095/// same-transaction (non-stale on commit) so they need no readiness axis at all;
4096/// `searchable→vector` is **async, rebuild-durable**, so it carries one.
4097///
4098/// **Naming discipline (load-bearing).** The token **`pending` is RESERVED for
4099/// the admission axis** (quarantine/trust — an app judgment). Index-readiness is
4100/// a DIFFERENT, orthogonal dimension (a record can be
4101/// `active ∧ is_latest ∧ admissible` yet `dense_readiness = embedding`), so this
4102/// enum deliberately does **not** reuse that word: the non-ready member is
4103/// `Embedding`, never `Pending`.
4104#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
4105pub enum DenseReadiness {
4106 /// Every row in the vector projection's row set has reached a projection
4107 /// terminal — the dense arm is caught up. Because the vector INSERT and the
4108 /// terminal record are written in ONE transaction
4109 /// ([`commit_projection_outcomes`]), `Ready` can never be observed with the
4110 /// vector row absent (design §4.1 invariant 1).
4111 Ready,
4112 /// At least one row in the projection's row set has not yet reached a
4113 /// projection terminal — embedding is outstanding. This is the ONLY
4114 /// tolerable torn state: readiness `embedding` with the vector absent (the
4115 /// dense arm reads as partial and RRF under-ranks; it does not hide).
4116 Embedding,
4117}
4118
4119impl DenseReadiness {
4120 #[must_use]
4121 pub fn as_str(self) -> &'static str {
4122 match self {
4123 DenseReadiness::Ready => "ready",
4124 DenseReadiness::Embedding => "embedding",
4125 }
4126 }
4127
4128 /// The two accepted spellings. `"pending"` is DELIBERATELY not one of them
4129 /// (reserved for the admission axis) and so parses to `None`.
4130 #[must_use]
4131 pub fn from_str_opt(value: &str) -> Option<Self> {
4132 match value {
4133 "ready" => Some(DenseReadiness::Ready),
4134 "embedding" => Some(DenseReadiness::Embedding),
4135 _ => None,
4136 }
4137 }
4138}
4139
4140/// 0.8.20 Slice 15d (R-20-PR) — the `searchable→vector` sub-target selector.
4141///
4142/// **Slice 20 (R-20-DR) attached `dense_readiness` HERE, additively:** this
4143/// sub-object is STORED by 15d (so the shape exists and a caller can declare a
4144/// vector projection); Slice 20 hangs the READ-METADATA readiness flag off it.
4145/// Nothing in 15d's persisted shape changed (the registry columns
4146/// `vector_embedder` + `vector_declared` still round-trip the declaration) —
4147/// **readiness is DERIVED, never stored**, so there is no schema step and no
4148/// separate flag that could tear (see [`derive_dense_readiness`]).
4149#[derive(Clone, Debug, Default, Eq, PartialEq)]
4150pub struct ProjectionVector {
4151 /// Optional embedder override; `None` ⇒ the engine's shipped default.
4152 pub embedder: Option<String>,
4153 /// 0.8.20 Slice 20 (R-20-DR) — **READ METADATA, engine-set.** Populated by
4154 /// [`Engine::read_projections`]; `None` on every caller-authored spec.
4155 ///
4156 /// It is **not part of the declaration**: `configure_projections` neither
4157 /// stores nor honours it (see [`StoredProjection::from_spec`], which reads
4158 /// only `embedder`), so a value supplied here is INERT — the engine always
4159 /// reports the derived truth. This is deliberately accept-inert rather than
4160 /// hard-reject so `read.projections` output stays feedable straight back
4161 /// into `configure_projections` (the fix-4 read→configure round-trip, which
4162 /// both bindings pin with a test).
4163 ///
4164 /// **0.8.20 Slice 23 (`R-20-SV`) correction (TC-39 class).** This doc used to
4165 /// justify accept-inert by analogy with "the already-audited accept-inert
4166 /// ruling on an `fts`/`vector` sub-object declared without the `searchable`
4167 /// role". **That ruling is OVERRULED** — the HITL ruled the shape an INVALID
4168 /// SPEC on 2026-07-24 and [`apply_projection_config`] now rejects it with
4169 /// [`EngineError::WriteValidation`]. `dense_readiness` accept-inert is
4170 /// UNCHANGED and stands on its own footing: it is engine-set READ METADATA,
4171 /// never part of the declaration, so there is nothing about it to reject.
4172 ///
4173 /// The bindings still HARD-REJECT the shapes that could
4174 /// not round-trip: a readiness supplied with `vector = false`, and any
4175 /// spelling outside `{ready, embedding}`.
4176 pub dense_readiness: Option<DenseReadiness>,
4177}
4178
4179/// 0.8.20 Slice 15d (R-20-PR / C-1) — a single declarative projection
4180/// declaration. HITL-ratified shape (`api-surface.md:85-89`):
4181/// `{ name, roles: Set<ProjectionRole>, fts?, vector? }`. `roles` carries SET
4182/// semantics (dedup + membership; an attribute can be `Filterable` AND
4183/// `Searchable`) — encoded here as a sorted, de-duplicated `BTreeSet`. Named
4184/// `roles`, not `kind` (`kind` is the node/edge type discriminator).
4185#[derive(Clone, Debug, Eq, PartialEq)]
4186pub struct ProjectionSpec {
4187 pub name: String,
4188 pub roles: BTreeSet<ProjectionRole>,
4189 pub fts: Option<ProjectionFts>,
4190 pub vector: Option<ProjectionVector>,
4191 /// Optional ordered literal object-member path in the canonical node body.
4192 /// `None` preserves the legacy direct top-level lookup by `name`.
4193 pub source: Option<Vec<String>>,
4194}
4195
4196/// 0.8.20 Slice 15d (R-20-PR) — the diff [`Engine::configure_projections`]
4197/// applied. Idempotent re-registration yields `unchanged == true` with all
4198/// vecs empty (the "re-registration is a no-op" acceptance signal). A
4199/// destructive change without an explicit `drop` is an `Err`, not a delta.
4200#[derive(Clone, Debug, Default, Eq, PartialEq)]
4201pub struct ProjectionDelta {
4202 /// Attribute names whose same-transaction projections (EAV / property-FTS)
4203 /// were (re)built by this apply.
4204 pub built: Vec<String>,
4205 /// Attribute names dropped (explicit `drop` list) — their EAV + property-FTS
4206 /// rows and registry row removed.
4207 pub dropped: Vec<String>,
4208 /// Attribute names whose declared roles were persisted but NOT built:
4209 /// `rankable` (F9 not yet live) and the `searchable→vector` sub-target
4210 /// (Slice 20). These graft on a future idempotent apply. No error.
4211 pub deferred: Vec<String>,
4212 /// True iff nothing was built, dropped, or newly deferred — the whole apply
4213 /// diffed to a no-op.
4214 pub unchanged: bool,
4215 /// 0.8.20 Slice 22 (R-20-VC / **TC-67**) — **node KINDS, not attribute
4216 /// names.** The vector-eligible node kinds present in the corpus that the
4217 /// vector writer can NEVER commit, so no `searchable→vector` declaration
4218 /// will ever produce an embedding for them.
4219 ///
4220 /// # Why this field exists — the silence it replaces
4221 ///
4222 /// [`kind_is_vector_committable`] (Slice 20c fix-2) restricted enrolment to
4223 /// the kinds [`resolve_source_type`] maps, because enrolling any other kind
4224 /// is a permanent liveness wedge. That fix was correct and is unchanged —
4225 /// but it made the exclusion **silent**: the declaration persists, its name
4226 /// is pushed onto [`ProjectionDelta::deferred`], and the caller cannot tell
4227 /// "waiting on the embedder" (transient) from "this kind will never be
4228 /// embedded" (permanent). Per the HITL ruling on TC-67 the remedy is
4229 /// option **(c) REPORT** — the vocabulary is NOT grown and the Pack-1 D3
4230 /// partition-key lock is NOT touched (`dev/design/0.7.0-vector-quant-pack1.md`).
4231 ///
4232 /// # Axis, and why the name is what it is
4233 ///
4234 /// `built` / `dropped` / `deferred` are all lists of **projection attribute
4235 /// names**. This one is a list of **node kinds** — a different axis entirely,
4236 /// so the name says `kinds` explicitly and is prefixed `vector_` to bind it
4237 /// to the dense arm (an unsupported kind is still fully FTS/lexically
4238 /// searchable). Sorted and de-duplicated (`SELECT DISTINCT … ORDER BY kind`).
4239 ///
4240 /// # It is a STATE report, not a diff
4241 ///
4242 /// Unlike the other three vectors it does not describe what this call
4243 /// changed; it describes the corpus as it stands. So it is populated on an
4244 /// idempotent re-apply too (where `unchanged == true` and the other three
4245 /// are empty), and it deliberately does NOT feed [`ProjectionDelta::unchanged`].
4246 /// That is what makes the declare-time residual cheap to live with: to
4247 /// refresh the report after writing new kinds, re-apply the same spec — a
4248 /// no-op that still returns a current report.
4249 ///
4250 /// # Independent of the embedder
4251 ///
4252 /// Computed whenever a `searchable→vector` projection is declared, whether
4253 /// or not this session has a live embedder. The vocabulary is static, so
4254 /// "this kind can never be embedded" is true in a no-embedder session too —
4255 /// and must not be conflated with the Q6a graceful-absent deferral, which is
4256 /// transient and is reported through `deferred`.
4257 ///
4258 /// Empty (never absent) when there is nothing to report.
4259 pub vector_unsupported_kinds: Vec<String>,
4260}
4261
4262/// 0.8.20 Slice 5b (R-20-E7) — outcome of
4263/// [`Engine::excise_collection_record`]. `records_excised` counts the erased
4264/// `operational_mutations` versions (an append-only-log collection keeps every
4265/// version of a key); `state_rows_excised` counts the erased
4266/// `operational_state` row (0 or 1).
4267///
4268/// `record_digest` is `SHA-256(collection + 0x1F + record_key)` — the audit
4269/// handle. The raw `record_key` is deliberately NOT carried: it is arbitrary
4270/// caller-supplied text and may itself be the identifier being erased, so
4271/// echoing it into a durable audit row would defeat the erasure.
4272#[derive(Clone, Debug, Eq, PartialEq)]
4273pub struct ExciseRecordReport {
4274 pub collection: String,
4275 pub record_digest: String,
4276 pub records_excised: u64,
4277 pub state_rows_excised: u64,
4278}
4279
4280/// Typed outcome of [`Engine::verify_embedder`]. Mismatches do not raise
4281/// `EngineError`; the operator workflow needs to see the stored vs.
4282/// supplied pair to decide on next action.
4283#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4284pub enum VerifyEmbedderStatus {
4285 Match,
4286 IdentityMismatch,
4287 DimensionMismatch,
4288 BothMismatch,
4289}
4290
4291/// Result of [`Engine::verify_embedder`]. `stored_identity` is the
4292/// `name:revision` pair persisted in `_fathomdb_embedder_profiles`;
4293/// `supplied_identity` echoes the operator's input verbatim.
4294#[derive(Clone, Debug, Eq, PartialEq)]
4295pub struct VerifyEmbedderReport {
4296 pub stored_identity: String,
4297 pub stored_dimension: u32,
4298 pub supplied_identity: String,
4299 pub supplied_dimension: u32,
4300 pub status: VerifyEmbedderStatus,
4301}
4302
4303/// Single table or index entry emitted by [`Engine::dump_schema`].
4304#[derive(Clone, Debug, Eq, PartialEq)]
4305pub struct SchemaObject {
4306 pub name: String,
4307 pub sql: String,
4308}
4309
4310/// Result of [`Engine::dump_schema`]. `user_version` is the
4311/// `PRAGMA user_version` sentinel. Canonical tables appear first per
4312/// [`fathomdb_schema::CANONICAL_TABLES`], then remaining non-`sqlite_*`
4313/// tables alphabetically. Indexes follow the same alphabetical rule.
4314#[derive(Clone, Debug, Eq, PartialEq)]
4315pub struct DumpSchemaReport {
4316 pub user_version: u32,
4317 pub tables: Vec<SchemaObject>,
4318 pub indexes: Vec<SchemaObject>,
4319}
4320
4321/// Single canonical-table row count emitted by [`Engine::dump_row_counts`].
4322#[derive(Clone, Debug, Eq, PartialEq)]
4323pub struct TableRowCount {
4324 pub name: String,
4325 pub rows: u64,
4326}
4327
4328/// Result of [`Engine::dump_row_counts`]. Canonical tables only;
4329/// projection / FTS / vec0 shadow tables are excluded. Order matches
4330/// [`fathomdb_schema::CANONICAL_TABLES`].
4331#[derive(Clone, Debug, Eq, PartialEq)]
4332pub struct DumpRowCountsReport {
4333 pub counts: Vec<TableRowCount>,
4334}
4335
4336/// 0.8.20 Slice 5d (R-20-E8) — one `source_id` bucket in an
4337/// [`OrphanProvenanceReport`]. `source_id` is `None` for the NULL-provenance
4338/// bucket, which after migration step 21 should contain ONLY governed NODES.
4339#[derive(Clone, Debug, Eq, PartialEq)]
4340pub struct OrphanProvenanceSource {
4341 /// `None` = the NULL-`source_id` bucket.
4342 pub source_id: Option<String>,
4343 /// Canonical rows (nodes + edges) carrying this provenance.
4344 pub rows: u64,
4345 /// How many of `rows` carry a `logical_id`.
4346 ///
4347 /// NOT the same thing as "purge-addressable": only a NODE's `logical_id`
4348 /// confers purge-addressability. An EDGE's `logical_id` is a supersession
4349 /// identity and reaches no erasure verb (see
4350 /// [`Engine::orphan_provenance`]), so governed edges are counted here but
4351 /// are NOT subtracted from
4352 /// [`OrphanProvenanceReport::unerasable_rows`].
4353 pub governed_rows: u64,
4354 /// True for the engine's reserved `_`-prefixed namespace (`_engine:*`,
4355 /// `_legacy:pre-0.8.20`). Reserved buckets are reachable only through the
4356 /// operator seam `excise_source`, never through the governed
4357 /// [`Engine::erase_source`].
4358 pub reserved: bool,
4359}
4360
4361/// Result of [`Engine::orphan_provenance`] — the per-`source_id` census behind
4362/// `fathomdb doctor orphan-provenance` (design §4 item 11).
4363///
4364/// `unerasable_rows` is the load-bearing field: canonical rows carrying
4365/// NEITHER a `source_id` NOR a `logical_id`. Such a row is reachable by no
4366/// erasure verb at all — `purge` keys on `logical_id`, `erase_source` keys on
4367/// `source_id` — so it can never be deleted on request. Slice 5c made that
4368/// state unwritable and migration step 21 back-filled the historical cases, so
4369/// a non-zero count means the invariant has been violated and the verb exits
4370/// `DOCTOR_FOUND_ISSUES`.
4371#[derive(Clone, Debug, Eq, PartialEq)]
4372pub struct OrphanProvenanceReport {
4373 /// Per-`source_id` buckets, ordered by descending `rows` then `source_id`
4374 /// so the output is deterministic (a diagnostic that reorders between runs
4375 /// cannot be diffed).
4376 pub sources: Vec<OrphanProvenanceSource>,
4377 /// Total canonical rows surveyed.
4378 pub total_rows: u64,
4379 /// Rows with NO `source_id` AND NO `logical_id` — un-erasable by any verb.
4380 pub unerasable_rows: u64,
4381}
4382
4383/// Result of [`Engine::dump_profile`]. Mirrors the open-time embedder
4384/// posture + the per-kind vector configuration registered in
4385/// `_fathomdb_vector_kinds`.
4386#[derive(Clone, Debug, Eq, PartialEq)]
4387pub struct DumpProfileReport {
4388 pub embedder_identity: String,
4389 pub embedder_dimension: u32,
4390 pub vectorized_kinds: Vec<String>,
4391}
4392
4393/// 0.7.2 PR-2b — result of [`Engine::recompute_mean`] (the manual
4394/// `doctor recompute-mean` path) and of the shared in-transaction
4395/// recompute core. `drift_cos_before` is the cosine between the freshly
4396/// derived corpus mean and the previously-pinned mean (1.0 when nothing
4397/// was pinned yet, i.e. a first pin). `mean_was_pinned` distinguishes a
4398/// refresh of an existing mean from an initial pin. See
4399/// `dev/design/embedder.md` §0.3.
4400#[derive(Clone, Debug, PartialEq)]
4401pub struct MeanRecomputeReport {
4402 pub dim: u32,
4403 pub old_doc_count: u64,
4404 pub doc_count_requantized: u64,
4405 pub drift_cos_before: f32,
4406 pub mean_was_pinned: bool,
4407 pub elapsed_ms: u64,
4408}
4409
4410/// Typed outcome of [`Engine::truncate_wal`]. `Done` matches SQLite's
4411/// `busy = 0` return from `PRAGMA wal_checkpoint(TRUNCATE)`; any other
4412/// value surfaces as `Busy`.
4413#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4414pub enum TruncateWalStatus {
4415 Done,
4416 Busy,
4417}
4418
4419/// Result of [`Engine::truncate_wal`]. Carries the three counters
4420/// returned by `PRAGMA wal_checkpoint(TRUNCATE)`: `busy`, `log_frames`,
4421/// `checkpointed_frames`.
4422#[derive(Clone, Debug, Eq, PartialEq)]
4423pub struct TruncateWalReport {
4424 pub status: TruncateWalStatus,
4425 pub busy: u32,
4426 pub log_frames: u32,
4427 pub checkpointed_frames: u32,
4428}
4429
4430impl Drop for Engine {
4431 fn drop(&mut self) {
4432 let _ = self.close();
4433 }
4434}
4435
4436impl Engine {
4437 pub fn open(path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
4438 Self::open_with_embedder_and_subscriber(
4439 path,
4440 default_embedder_identity(),
4441 None,
4442 None,
4443 None,
4444 &mut |_| {},
4445 )
4446 }
4447
4448 /// Open an engine with an explicit [`EmbedderChoice`].
4449 ///
4450 /// Per `dev/design/embedder.md` §0 + the 0.7.1 EU-5 campaign, this is
4451 /// the canonical entry point for selecting how the workspace's
4452 /// default embedder is supplied. See [`EmbedderChoice`] for the
4453 /// semantics of each variant; in particular `Default` materializes
4454 /// the pinned BGE embedder via the loader when the `default-embedder`
4455 /// feature is enabled.
4456 pub fn open_with_choice(
4457 path: impl Into<PathBuf>,
4458 choice: EmbedderChoice,
4459 ) -> Result<OpenedEngine, EngineOpenError> {
4460 match choice {
4461 EmbedderChoice::Default => Self::open_default_embedder(path),
4462 EmbedderChoice::Caller(embedder) => {
4463 let identity = embedder.identity();
4464 Self::open_with_embedder_and_subscriber(
4465 path,
4466 identity,
4467 Some(embedder),
4468 None,
4469 None,
4470 &mut |_| {},
4471 )
4472 }
4473 EmbedderChoice::None => Self::open_with_embedder_and_subscriber(
4474 path,
4475 default_embedder_identity(),
4476 None,
4477 None,
4478 None,
4479 &mut |_| {},
4480 ),
4481 }
4482 }
4483
4484 /// EU-5b: materialize the engine's pinned default embedder
4485 /// (`CandleBgeEmbedder` backed by the EU-3 loader) and open the
4486 /// workspace with it. Without the `default-embedder` feature, fails
4487 /// with a typed `Embedder` error rather than touching the network.
4488 #[cfg(feature = "default-embedder")]
4489 fn open_default_embedder(path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
4490 use std::time::Instant as DownloadInstant;
4491 let download_start = DownloadInstant::now();
4492 let weights = fathomdb_embedder::loader::load_pinned_default_embedder().map_err(|err| {
4493 EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
4494 message: format!("default embedder loader: {err}"),
4495 })
4496 })?;
4497 let events = weights.events.clone();
4498 let download_ms = if weights.bytes_downloaded > 0 {
4499 Some(u64::try_from(download_start.elapsed().as_millis()).unwrap_or(u64::MAX))
4500 } else {
4501 None
4502 };
4503 let embedder =
4504 fathomdb_embedder::CandleBgeEmbedder::new_from_weights(weights).map_err(|err| {
4505 EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
4506 message: format!("default embedder construct: {err}"),
4507 })
4508 })?;
4509 let embedder: Arc<dyn Embedder> = Arc::new(embedder);
4510 let identity = embedder.identity();
4511 let loader_info = LoaderInfo { download_ms, events };
4512 Self::open_with_embedder_and_subscriber(
4513 path,
4514 identity,
4515 Some(embedder),
4516 Some(loader_info),
4517 None,
4518 &mut |_| {},
4519 )
4520 }
4521
4522 #[cfg(not(feature = "default-embedder"))]
4523 fn open_default_embedder(_path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
4524 Err(EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
4525 message: "EmbedderChoice::Default requires the `default-embedder` Cargo feature"
4526 .to_string(),
4527 }))
4528 }
4529
4530 pub fn open_with_migration_event_sink(
4531 path: impl Into<PathBuf>,
4532 mut emit_migration_event: impl FnMut(&MigrationStepReport),
4533 ) -> Result<OpenedEngine, EngineOpenError> {
4534 Self::open_with_embedder_and_subscriber(
4535 path,
4536 default_embedder_identity(),
4537 None,
4538 None,
4539 None,
4540 &mut emit_migration_event,
4541 )
4542 }
4543
4544 #[cfg(debug_assertions)]
4545 #[doc(hidden)]
4546 pub fn open_with_migrations_for_test(
4547 path: impl Into<PathBuf>,
4548 migrations: &'static [fathomdb_schema::Migration],
4549 mut emit_migration_event: impl FnMut(&MigrationStepReport),
4550 ) -> Result<OpenedEngine, EngineOpenError> {
4551 Self::open_with_migrations(
4552 path,
4553 migrations,
4554 default_embedder_identity(),
4555 None,
4556 None,
4557 &mut emit_migration_event,
4558 None,
4559 )
4560 }
4561
4562 #[doc(hidden)]
4563 pub fn open_with_subscriber_for_test(
4564 path: impl Into<PathBuf>,
4565 subscriber: Arc<dyn lifecycle::Subscriber>,
4566 ) -> Result<OpenedEngine, EngineOpenError> {
4567 Self::open_with_embedder_and_subscriber(
4568 path,
4569 default_embedder_identity(),
4570 None,
4571 None,
4572 Some(subscriber),
4573 &mut |_| {},
4574 )
4575 }
4576
4577 #[doc(hidden)]
4578 pub fn open_without_embedder_for_test(
4579 path: impl Into<PathBuf>,
4580 ) -> Result<OpenedEngine, EngineOpenError> {
4581 Self::open_with_embedder_and_subscriber(
4582 path,
4583 default_embedder_identity(),
4584 None,
4585 None,
4586 None,
4587 &mut |_| {},
4588 )
4589 }
4590
4591 #[doc(hidden)]
4592 pub fn open_with_embedder_for_test(
4593 path: impl Into<PathBuf>,
4594 embedder: Arc<dyn Embedder>,
4595 ) -> Result<OpenedEngine, EngineOpenError> {
4596 let identity = embedder.identity();
4597 Self::open_with_embedder_and_subscriber(
4598 path,
4599 identity,
4600 Some(embedder),
4601 None,
4602 None,
4603 &mut |_| {},
4604 )
4605 }
4606
4607 fn open_with_embedder_and_subscriber(
4608 path: impl Into<PathBuf>,
4609 embedder_identity: EmbedderIdentity,
4610 runtime_embedder: Option<Arc<dyn Embedder>>,
4611 loader_info: Option<LoaderInfo>,
4612 initial_subscriber: Option<Arc<dyn lifecycle::Subscriber>>,
4613 emit_migration_event: &mut impl FnMut(&MigrationStepReport),
4614 ) -> Result<OpenedEngine, EngineOpenError> {
4615 Self::open_with_migrations(
4616 path,
4617 MIGRATIONS,
4618 embedder_identity,
4619 runtime_embedder,
4620 loader_info,
4621 emit_migration_event,
4622 initial_subscriber,
4623 )
4624 }
4625
4626 fn open_with_migrations(
4627 path: impl Into<PathBuf>,
4628 migrations: &'static [fathomdb_schema::Migration],
4629 embedder_identity: EmbedderIdentity,
4630 runtime_embedder: Option<Arc<dyn Embedder>>,
4631 loader_info: Option<LoaderInfo>,
4632 emit_migration_event: &mut impl FnMut(&MigrationStepReport),
4633 initial_subscriber: Option<Arc<dyn lifecycle::Subscriber>>,
4634 ) -> Result<OpenedEngine, EngineOpenError> {
4635 let canonical_path = canonical_database_path(&path.into())?;
4636 let lock = acquire_lock(&canonical_path)?;
4637 let open_result = Self::open_locked(
4638 canonical_path.clone(),
4639 migrations,
4640 &embedder_identity,
4641 emit_migration_event,
4642 );
4643
4644 match open_result {
4645 Ok((connection, readers, mut report, reader_lookaside_rcs)) => {
4646 // EU-5b — splice the loader's measurements + structured
4647 // events into the report. The loader path is the only
4648 // surface that produces these today; caller-supplied
4649 // embedders and EmbedderChoice::None leave them as the
4650 // open_locked defaults (None / empty).
4651 if let Some(info) = loader_info {
4652 if info.download_ms.is_some() {
4653 report.embedder_download_ms = info.download_ms;
4654 }
4655 if !info.events.is_empty() {
4656 report.embedder_events = info.events;
4657 }
4658 }
4659
4660 // 0.8.18 Slice 5 (#5 vector-equivalence probe KEYSTONE) — run the
4661 // open-time self-check on the FINAL post-recovery connection (the
4662 // mean is already pinned/recovered inside open_locked, U1-b). First
4663 // registration persists the 45 UN-centered f32 references; a
4664 // subsequent open re-embeds + asserts P1 (mean-centered flip count,
4665 // floor 0) and P2 (un-centered L2 ε). Divergence ⇒ degraded-open
4666 // (`dense_disabled=true`), surfaced on the OpenReport (R-VEQ-6); the
4667 // query-time refusal fires later at `search_inner_with_stats`.
4668 let veq = run_vector_equivalence_probe(
4669 &connection,
4670 runtime_embedder.as_deref(),
4671 &embedder_identity,
4672 report.embedder_mean_vec_pinned,
4673 );
4674 report.dense_disabled = veq.dense_disabled;
4675 report.dense_disabled_reason = veq.reason.clone();
4676
4677 let next_cursor = load_next_cursor(&connection);
4678 let subscribers = Arc::new(lifecycle::SubscriberRegistry::new());
4679 let profiling_enabled = Arc::new(AtomicBool::new(false));
4680 let slow_threshold_ms = Arc::new(AtomicU64::new(DEFAULT_SLOW_THRESHOLD_MS));
4681 let mut profile_contexts: Vec<Box<ProfileContext>> = Vec::new();
4682 let projection_runtime = ProjectionRuntime::new(
4683 canonical_path.clone(),
4684 runtime_embedder.clone(),
4685 embedder_identity.clone(),
4686 report.embedder_mean_vec_pinned,
4687 Arc::clone(&subscribers),
4688 );
4689
4690 install_profile_callback(
4691 &connection,
4692 &subscribers,
4693 &profiling_enabled,
4694 &slow_threshold_ms,
4695 &mut profile_contexts,
4696 );
4697 for reader in &readers {
4698 install_profile_callback(
4699 reader,
4700 &subscribers,
4701 &profiling_enabled,
4702 &slow_threshold_ms,
4703 &mut profile_contexts,
4704 );
4705 }
4706
4707 let opened = OpenedEngine {
4708 engine: Self {
4709 path: canonical_path.clone(),
4710 next_cursor: AtomicU64::new(next_cursor),
4711 closed: AtomicBool::new(false),
4712 lock: Mutex::new(Some(lock)),
4713 connection: Mutex::new(Some(connection)),
4714 reader_pool: ReaderWorkerPool::new(readers),
4715 counters: lifecycle::Counters::new(),
4716 subscribers,
4717 profiling_enabled,
4718 slow_threshold_ms,
4719 runtime_embedder,
4720 runtime_embedder_identity: embedder_identity,
4721 projection_runtime,
4722 provenance_row_cap: AtomicU64::new(DEFAULT_PROVENANCE_ROW_CAP),
4723 profile_contexts: Mutex::new(profile_contexts),
4724 reader_lookaside_rcs,
4725 telemetry: Mutex::new(None),
4726 telemetry_enabled: AtomicBool::new(false),
4727 dense_disabled: AtomicBool::new(veq.dense_disabled),
4728 dense_disabled_reason: Mutex::new(veq.reason),
4729 vector_equivalence_refusals: AtomicU64::new(0),
4730 #[cfg(debug_assertions)]
4731 force_next_commit_failure: AtomicBool::new(false),
4732 },
4733 report,
4734 };
4735 if let Some(subscriber) = initial_subscriber {
4736 opened.engine.subscribers.attach_persistent(subscriber);
4737 }
4738 if database_has_pending_projection_work(&canonical_path).unwrap_or(false) {
4739 opened.engine.projection_runtime.notify_new_work();
4740 }
4741 Ok(opened)
4742 }
4743 Err(err) => {
4744 if let Some(subscriber) = initial_subscriber {
4745 emit_open_error_event(&subscriber, &err);
4746 }
4747 drop(lock);
4748 Err(err)
4749 }
4750 }
4751 }
4752
4753 fn open_locked(
4754 path: PathBuf,
4755 migrations: &'static [fathomdb_schema::Migration],
4756 embedder_identity: &EmbedderIdentity,
4757 emit_migration_event: &mut impl FnMut(&MigrationStepReport),
4758 ) -> Result<(Connection, Vec<Connection>, OpenReport, Vec<i32>), EngineOpenError> {
4759 init_perf_experiments_runtime();
4760 register_sqlite_vec_extension();
4761 let mut connection = Connection::open(&path)
4762 .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))?;
4763 // Order pinned by `dev/design/errors.md` § OpenStage matrix: each
4764 // step routes its own SQLite-level error to a distinct
4765 // `CorruptionKind` (Header → WalReplay → Schema → EmbedderIdentity).
4766 // The schema and WAL probes both happen BEFORE `pragma WAL`
4767 // because that pragma also reads page 1 — letting it run first
4768 // would reclassify schema-side corruption as a WAL replay
4769 // failure, breaking the AC-035b stable-code contract.
4770 probe_database_header(&connection)?;
4771 probe_open_integrity(&connection)?;
4772 probe_wal_sidecar(&path)?;
4773 // 0.7.0 perf-experiments: apply writer-side experiment PRAGMAs
4774 // (page_size, etc.) BEFORE journal_mode + migrations. page_size
4775 // is silently ignored once any table exists; this is the only
4776 // legal window to set it on a fresh DB. Gated on
4777 // FATHOMDB_PERF_EXPERIMENTS=1; no-op in production.
4778 apply_perf_experiment_writer_pragmas(&connection);
4779 // OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — standing
4780 // `secure_delete=ON` on the writer, applied at EVERY open (fresh + migrated).
4781 // It zeroes every page freed by a future DELETE, so the Slice-10 `purge`
4782 // hard-erase is complete WITHOUT a per-purge `VACUUM`. It is a connection
4783 // PRAGMA (not schema DDL), so it belongs here, not in the 19→20 migration.
4784 // RESIDUAL (documented, not forced): pages freed on a pre-20 DB BEFORE this
4785 // was enabled are not retroactively scrubbed; there is no migration-time
4786 // full `VACUUM` (O(db-size)). NOTE: this is a standing pragma set at EVERY
4787 // connection open (writer here, plus the reader-pool and
4788 // `open_runtime_connection`), NOT the writer alone — non-writer connections
4789 // also free pages (projection / vector-rewrite DELETEs), so a writer-only
4790 // `secure_delete` would leak freed content on disk. See the matching
4791 // reader/runtime open comment (~lines 3335-3336).
4792 connection
4793 .pragma_update(None, "secure_delete", "ON")
4794 .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
4795 connection
4796 .pragma_update(None, "journal_mode", "WAL")
4797 .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
4798
4799 reject_legacy_shape(&connection)?;
4800 let migration = migrate_with_event_sink(&connection, migrations, emit_migration_event)
4801 .map_err(map_migration_error)?;
4802 // 0.8.0 Slice 5 (G1) — global FTS5 tokenizer-default upgrade. Step 11
4803 // drops + recreates `search_index` with the new tokenizer, leaving it
4804 // EMPTY on a migrated DB. The projection scheduler will NOT
4805 // repopulate it (`database_has_pending_projection_work` keys "pending"
4806 // off `_fathomdb_projection_terminal`, which the migration does not
4807 // clear). Re-tokenize from the canonical source rows here, on the
4808 // writer connection, single-threaded, before readers spawn —
4809 // projection-only, no source-record migration.
4810 //
4811 // Crash-retryable (fix-1): step 11 commits `user_version = 11` with an
4812 // empty index in its OWN transaction; this reproject commits in a
4813 // LATER transaction. A crash in that window leaves a durable v11 + empty
4814 // index, on which a boundary-crossing guard (`before < 11`) is FALSE,
4815 // skipping repair forever. So gate on the completion marker's ABSENCE
4816 // (written atomically with the reindex) instead: idempotent, and a
4817 // crash before the reindex commit simply re-runs on the next open.
4818 if migration.schema_version_after >= SEARCH_INDEX_TOKENIZER_SCHEMA_VERSION
4819 && !search_index_tokenizer_reproject_complete(&connection).map_err(|_| {
4820 EngineOpenError::Io {
4821 message: "could not read search_index tokenizer reproject marker".to_string(),
4822 }
4823 })?
4824 {
4825 reproject_search_index_after_tokenizer_upgrade(&connection).map_err(|_| {
4826 EngineOpenError::Io {
4827 message: "could not re-tokenize search_index after tokenizer upgrade"
4828 .to_string(),
4829 }
4830 })?;
4831 }
4832 let mut embedder_mean_vec_pinned = check_embedder_profile(&connection, embedder_identity)?;
4833 ensure_vector_partition(&mut connection, embedder_identity.dimension).map_err(|_| {
4834 EngineOpenError::Io { message: "could not initialize vector partition".to_string() }
4835 })?;
4836
4837 // 0.8.20 Slice 15c (TC-33) fix-6 [codex §9 P1] — the step-23
4838 // `canonical_edges` recreate drops every edge row (NO DATA MIGRATION) and
4839 // removes their `_fathomdb_vector_rows` sidecar rows, but the vec0
4840 // `vector_default` shadow those mirror is engine-created + dim-aware, so
4841 // the migration cannot delete its rows. Left behind, an orphaned edge vec0
4842 // row (whose `canonical_edges` row is gone) still occupies a top-K KNN
4843 // candidate slot — `build_vector_phase1_sql` reads candidates DIRECTLY
4844 // from `vector_default` before hydrating them through the canonical tables
4845 // — and is then discarded at hydration, so an upgraded DB silently returns
4846 // too few / no vector results. Prune the orphans now that
4847 // `ensure_vector_partition` guarantees `vector_default` exists, BEFORE the
4848 // mean-vec row-count recovery below (so the count excludes them). One-time
4849 // and crash-retryable via the durable completion marker; a no-op on any
4850 // healthy corpus (every vec0 row has a sidecar entry), so recall / eu7
4851 // fidelity are unchanged on a DB that never dropped edges.
4852 if migration.schema_version_after >= EDGE_TEMPORAL_EPOCH_SCHEMA_VERSION
4853 && !edge_vector_prune_complete(&connection).map_err(|_| EngineOpenError::Io {
4854 message: "could not read edge-vector prune marker".to_string(),
4855 })?
4856 {
4857 prune_orphaned_edge_vectors(&connection).map_err(|_| EngineOpenError::Io {
4858 message: "could not prune orphaned edge vector rows".to_string(),
4859 })?;
4860 }
4861
4862 // 0.8.20 Slice 15d (R-20-PR, Q5) — boot re-derive the projection registry
4863 // (the engine `ProjectionSpec` is a derived cache). For every persisted
4864 // declaration, clear + backfill its EAV / property-FTS rows from the
4865 // canonical nodes so a crash window (registry row survives, projection
4866 // rows partial) self-heals idempotently. A no-op single empty-table read
4867 // on every DB that has not declared a projection. On the writer
4868 // connection, single-threaded, before readers spawn — like the tokenizer
4869 // reproject above. Runs after the fix-6 edge-vector prune above; the two
4870 // are independent boot reconciliations.
4871 rederive_projections_on_boot(&connection).map_err(|_| EngineOpenError::Io {
4872 message: "could not re-derive projection registry on boot".to_string(),
4873 })?;
4874
4875 // 0.8.20 Slice 21 fix-1 (codex §9 round 1 [P2], ledger `TC-71`) — bring an
4876 // ALREADY-ENROLLED inert vector kind into agreement with the role-aware
4877 // decision. Slice 21c closed the three forward doors, but a database that
4878 // already ran the old code under `{roles:[filterable], vector:{}}` keeps
4879 // its `_fathomdb_vector_kinds` rows — `vector_kind_needs_enrolment`
4880 // short-circuits on `kind_is_vector_indexed` and never reaches the new
4881 // predicate, and `project_canonical_node_row` reads only the registry
4882 // membership — so upgrading did not actually stop the unwanted embeddings.
4883 // Narrowly authorised (registry EXISTS, declares a `vector` sub-object,
4884 // and declares no `searchable→vector` projection) so a LEGACY workspace
4885 // with a working dense arm is never touched; see
4886 // [`registry_governs_an_inert_dense_arm`]. Deletes no embedding. Runs
4887 // BEFORE `run_vector_equivalence_probe` (which fires after `open_locked`
4888 // returns), so a database whose only enrolment was the inert one pays no
4889 // probe embeds on the healing open. Another boot reconciliation on the
4890 // writer connection, single-threaded, before readers spawn.
4891 reconcile_inert_vector_enrolments_on_boot(&connection).map_err(|_| {
4892 EngineOpenError::Io {
4893 message: "could not reconcile inert vector kind enrolments on boot".to_string(),
4894 }
4895 })?;
4896
4897 // 0.8.20 Slice 15e — reconcile the live `vector_default` attribute columns
4898 // with the registry's `filterable` set. On a DB whose vec0 shape already
4899 // matches the registry (the common case, incl. every reopen of a DB that
4900 // declared filterable projections in a prior session) this is a pure
4901 // no-op: the diff is empty, so boot never re-inserts and NEVER silently
4902 // wipes the corpus. It converges only a shape that drifted from the
4903 // registry (e.g. a restored registry row). A no-op when the table is
4904 // absent (no embedder). Runs on the writer connection, single-threaded,
4905 // before readers spawn — like the boot re-derive above.
4906 {
4907 let tx = connection.transaction().map_err(|_| EngineOpenError::Io {
4908 message: "could not begin vector-attr reconcile on boot".to_string(),
4909 })?;
4910 reconcile_vector_attr_columns(&tx, embedder_identity.dimension).map_err(|_| {
4911 EngineOpenError::Io {
4912 message: "could not reconcile vector attribute columns on boot".to_string(),
4913 }
4914 })?;
4915 tx.commit().map_err(|_| EngineOpenError::Io {
4916 message: "could not commit vector-attr reconcile on boot".to_string(),
4917 })?;
4918 }
4919
4920 // EU-5f — recovery pin (`dev/design/embedder.md` §0.3, Hazard 4). If
4921 // the identity is MC-required, no mean is pinned, yet the workspace
4922 // already holds >= MEAN_VEC_PIN_THRESHOLD vector rows (e.g. a crash
4923 // between the threshold-crossing write and its pin commit), derive
4924 // the mean from the existing un-centered rows and pin+re-quantize
4925 // now, single-threaded, before the projection workers spawn. The
4926 // NULL guard makes this idempotent on subsequent opens.
4927 if identity_requires_mean_centering(embedder_identity) && !embedder_mean_vec_pinned {
4928 let row_count: u64 = connection
4929 .query_row("SELECT COUNT(*) FROM vector_default", [], |row| row.get(0))
4930 .unwrap_or(0);
4931 if row_count >= MEAN_VEC_PIN_THRESHOLD {
4932 recover_mean_vec_pin(&mut connection, embedder_identity).map_err(|_| {
4933 EngineOpenError::Io {
4934 message: "could not recover mean-centering pin".to_string(),
4935 }
4936 })?;
4937 embedder_mean_vec_pinned = true;
4938 }
4939 }
4940
4941 let warmup_started = Instant::now();
4942 // Static identity capability — see `dev/design/embedder.md`
4943 // §0.6. Today only the bge-small identity reports `true`; the
4944 // noop scaffolding identity is `false`. EU-5b's identity flip
4945 // makes the Default path return `true` here automatically.
4946 let embedder_mean_centering_required = embedder_identity.name == BGE_SMALL_EMBEDDER_NAME;
4947 // EU-5a2 — populated from `_fathomdb_embedder_profiles.mean_vec`
4948 // by `check_embedder_profile` above (was hard-coded `false` in
4949 // EU-5a1). Dimension invariant (§0.2) enforced by that check.
4950 let report = OpenReport {
4951 schema_version_before: migration.schema_version_before,
4952 schema_version_after: migration.schema_version_after,
4953 migration_steps: migration.migration_steps,
4954 embedder_warmup_ms: u64::try_from(warmup_started.elapsed().as_millis())
4955 .unwrap_or(u64::MAX),
4956 query_backend: "fathomdb-query + sqlite-vec",
4957 default_embedder: embedder_identity.clone(),
4958 // TODO(EU-5b): surface `LoadedWeights.download_ms` from the
4959 // loader once the Default path materializes through it.
4960 embedder_download_ms: None,
4961 // TODO(EU-5b): surface `LoadedWeights.events` from the loader.
4962 embedder_events: Vec::new(),
4963 embedder_mean_centering_required,
4964 embedder_mean_vec_pinned,
4965 // 0.8.18 Slice 5 — set by the #5 self-check in `open_with_migrations`
4966 // (which has the runtime embedder in scope). `open_locked` returns the
4967 // non-degraded default; the probe runs after this returns.
4968 dense_disabled: false,
4969 dense_disabled_reason: None,
4970 };
4971
4972 let mut readers = Vec::with_capacity(READER_POOL_SIZE);
4973 let mut lookaside_rcs: Vec<i32> = Vec::with_capacity(READER_POOL_SIZE);
4974 for _ in 0..READER_POOL_SIZE {
4975 let reader = Connection::open(&path)
4976 .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))?;
4977 // Pack 6.G G.1: configure per-connection lookaside BEFORE
4978 // any PRAGMA / prepare runs on this reader. Reordering this
4979 // after the journal-mode / query_only PRAGMAs would let
4980 // SQLite silently ignore the lookaside setting.
4981 let rc: i32 = configure_reader_lookaside(&reader);
4982 debug_assert_eq!(
4983 rc,
4984 rusqlite::ffi::SQLITE_OK,
4985 "sqlite3_db_config(LOOKASIDE) must return SQLITE_OK on a freshly opened reader",
4986 );
4987 lookaside_rcs.push(rc);
4988 reader
4989 .pragma_update(None, "journal_mode", "WAL")
4990 .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
4991 // OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — `secure_delete=ON`
4992 // at EVERY connection open, not just the writer. `secure_delete` is a
4993 // per-connection pager flag, so a reader-pool connection that frees a
4994 // page (vector-rewrite / projection DELETEs run off non-writer
4995 // connections) would otherwise leave that freed content on disk,
4996 // defeating GDPR erasure. Set BEFORE `query_only=ON` so the ordering is
4997 // unambiguous (the flag is a pager setting, not a DB write).
4998 reader
4999 .pragma_update(None, "secure_delete", "ON")
5000 .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
5001 reader
5002 .pragma_update(None, "query_only", "ON")
5003 .map_err(|err| map_open_sqlite_error(err, OpenStage::SchemaProbe))?;
5004 apply_perf_experiment_reader_pragmas(&reader);
5005 readers.push(reader);
5006 }
5007
5008 Ok((connection, readers, report, lookaside_rcs))
5009 }
5010
5011 #[must_use]
5012 pub fn path(&self) -> &Path {
5013 &self.path
5014 }
5015
5016 pub fn write(&self, batch: &[PreparedWrite]) -> Result<WriteReceipt, EngineError> {
5017 let category = if batch_is_admin(batch) {
5018 lifecycle::EventCategory::Admin
5019 } else {
5020 lifecycle::EventCategory::Writer
5021 };
5022 self.emit_event(lifecycle::Phase::Started, category, None);
5023 let started = Instant::now();
5024 let outcome = self.write_inner(batch);
5025 self.detect_slow(started, category);
5026 match outcome {
5027 Ok(receipt) => {
5028 let rows = u64::try_from(batch.len()).unwrap_or(u64::MAX);
5029 if batch_is_admin(batch) {
5030 self.counters.record_admin();
5031 } else {
5032 self.counters.record_write(rows);
5033 }
5034 self.emit_event(lifecycle::Phase::Finished, category, None);
5035 Ok(receipt)
5036 }
5037 Err(err) => {
5038 let code = err.stable_code();
5039 self.counters.record_error(code);
5040 // AC-003d: capture-ordinal < raise-ordinal — Failed and Error
5041 // events both fire before the EngineError returns to the caller.
5042 self.emit_event(lifecycle::Phase::Failed, category, Some(code));
5043 self.emit_event(
5044 lifecycle::Phase::Failed,
5045 lifecycle::EventCategory::Error,
5046 Some(code),
5047 );
5048 Err(err)
5049 }
5050 }
5051 }
5052
5053 /// 0.8.20 Slice 20c (R-20-DR remainder) — **late enrolment**, the write-path
5054 /// half of the C4 rider.
5055 ///
5056 /// [`enqueue_declared_vector_backfill`] enrols the kinds the corpus held AT
5057 /// DECLARATION TIME. A kind first written AFTERWARDS would otherwise fall
5058 /// through [`project_canonical_node_row`]'s `kind_is_vector_indexed` gate
5059 /// straight onto a permanent `'up_to_date'` terminal and be silently,
5060 /// irrecoverably un-embedded — the identical false-ready barrier, reached by
5061 /// writing second instead of declaring second.
5062 ///
5063 /// **Gated on a LIVE embedder** (`runtime_embedder`), which is exactly why
5064 /// this lives on `Engine` and not inside the projector: with
5065 /// `EmbedderChoice::None` there is no dense arm at all, so enrolling a kind
5066 /// would only queue embeds that retry to a `failed` terminal and pollute
5067 /// `projection_failures`. The declaration still PERSISTS without an embedder
5068 /// — it simply defers, and grafts on the next idempotent apply in a session
5069 /// that has one (the shipped Q6a graceful-absent/graceful-graft contract,
5070 /// same as `rankable`). It mirrors the `run_vector_equivalence_probe` gate:
5071 /// "no live embedder ⇒ no dense arm to guard".
5072 ///
5073 /// Enrolment is an idempotent `INSERT OR IGNORE`, so running it on the writer
5074 /// connection just OUTSIDE the batch transaction is safe: if the batch then
5075 /// fails, the workspace is left having enrolled a kind for which no row
5076 /// exists — inert.
5077 ///
5078 /// fix-1 (codex §9 [P2]) — the `vector_projection_declared` probe below is
5079 /// what stops this path re-enrolling immediately after
5080 /// [`unenrol_registry_vector_node_kinds`] has run: the inverse removes the
5081 /// registry row, and with no active declaration this returns without
5082 /// re-adding it. (The kind registry DOES now have delete paths: that one, plus
5083 /// the Slice-21 fix-1 reconciliation
5084 /// [`reconcile_inert_vector_enrolments_on_boot`]. Both are gated on the SAME
5085 /// predicate this probe reads, so neither can be undone by a later write.)
5086 ///
5087 /// Cost: the registry probe is skipped entirely once the kind is enrolled, so
5088 /// a workspace with a live dense arm pays nothing; a workspace that never
5089 /// declared a vector projection pays two `prepare_cached` `EXISTS` probes per
5090 /// batch-row of an unenrolled kind. (Slice 21c / `TC-71` made that probe
5091 /// require the `searchable` ROLE, and deliberately kept the second `EXISTS`
5092 /// as a fast negative so this cost is unchanged — see
5093 /// [`vector_projection_declared`].)
5094 ///
5095 /// fix-2 (codex §9 [P2]) — a late enrolment now runs the SAME stranded-row
5096 /// treatment the declare-time door runs ([`reenqueue_stranded_vector_rows`]),
5097 /// and returns `true` iff that re-enqueued anything. Enrolling a kind while
5098 /// enqueueing ONLY the batch's own row left every earlier row of that kind
5099 /// holding its permanent `'up_to_date'` terminal with no vector, so once the
5100 /// new row drained readiness reported `ready` with pre-existing vector-eligible
5101 /// rows unembedded — a FALSE READY. Reached, for instance, by a database that
5102 /// persisted the declaration while opened WITHOUT an embedder and then reopened
5103 /// WITH one and wrote before re-applying the projection.
5104 ///
5105 /// fix-5 (codex §9 round 4 [P2]) — the registry INSERT and the un-stranding
5106 /// commit as ONE `BEGIN IMMEDIATE`…`COMMIT` (the shape
5107 /// [`rederive_projections_on_boot`] and
5108 /// [`reproject_search_index_after_tokenizer_upgrade`] already use). fix-2 ran
5109 /// them as two, and that window is not benign: a crash or a failed repair in
5110 /// between leaves the kind REGISTERED with the older rows still holding their
5111 /// `'up_to_date'` terminals and no vectors — and that state is SELF-SEALING,
5112 /// because `kind_is_vector_indexed` is then true, so every later write skips
5113 /// this path and therefore skips the repair, while readiness reads `ready` for
5114 /// rows nothing will ever embed. Only a manual re-apply of the projection
5115 /// recovers it. No marker table and no new recovery path: the two statements
5116 /// simply share a transaction.
5117 ///
5118 /// That transaction is opened on the writer connection just OUTSIDE the batch
5119 /// transaction: if the batch then fails, the workspace is left having enrolled
5120 /// a kind whose rows are correctly queued for the dense arm the registry does
5121 /// declare — inert, and self-healing on the next write or apply.
5122 fn enrol_batch_vector_kinds(
5123 &self,
5124 connection: &Connection,
5125 batch: &[PreparedWrite],
5126 ) -> Result<bool, EngineError> {
5127 if self.runtime_embedder.is_none() {
5128 return Ok(false);
5129 }
5130 // READ-ONLY pre-pass. Nothing is written here, so the overwhelmingly
5131 // common case — every kind in the batch already enrolled, or no vector
5132 // projection declared at all — still pays only the probes it paid before
5133 // and never takes a write lock.
5134 let mut to_enrol: Vec<&str> = Vec::new();
5135 for write in batch {
5136 // Only `Node` writes: edge bodies enrol `'edge_fact'` themselves in
5137 // `project_canonical_edge_row` (G11), unconditionally and already.
5138 let PreparedWrite::Node { kind, .. } = write else { continue };
5139 if to_enrol.contains(&kind.as_str()) {
5140 continue;
5141 }
5142 if self.vector_kind_needs_enrolment(connection, kind, RowKind::Leaf)? {
5143 to_enrol.push(kind);
5144 }
5145 }
5146 if to_enrol.is_empty() {
5147 return Ok(false);
5148 }
5149 self.enrol_and_unstrand(connection, &to_enrol)
5150 }
5151
5152 /// 0.8.20 Slice 20c — would enrolling `kind` be correct here? The READ-ONLY
5153 /// half of a late enrolment; [`Engine::enrol_and_unstrand`] is the write half.
5154 /// The live-embedder precondition is the CALLER's (see
5155 /// [`Engine::enrol_batch_vector_kinds`]).
5156 fn vector_kind_needs_enrolment(
5157 &self,
5158 connection: &Connection,
5159 kind: &str,
5160 row_kind: RowKind,
5161 ) -> Result<bool, EngineError> {
5162 // `graph` rows are lexically searchable but NEVER embedded
5163 // (`index_targets_for_row_kind`), so they must not drag their kind into
5164 // the vector registry — that would start embedding every other row of
5165 // that kind.
5166 if !index_targets_for_row_kind(row_kind).vector {
5167 return Ok(false);
5168 }
5169 // fix-2 (codex §9 [P1]) — the SAME restriction the declare-time door
5170 // applies, from the SAME predicate, so the two cannot drift: a kind the
5171 // vector writer cannot commit must never be enrolled, or the projection
5172 // worker wedges on it forever. See [`kind_is_vector_committable`].
5173 if !kind_is_vector_committable(kind) {
5174 return Ok(false);
5175 }
5176 if kind_is_vector_indexed(connection, kind)? {
5177 return Ok(false);
5178 }
5179 if !vector_projection_declared(connection).map_err(|_| EngineError::Storage)? {
5180 return Ok(false);
5181 }
5182 Ok(true)
5183 }
5184
5185 /// 0.8.20 Slice 20c fix-5 (codex §9 round 4 [P2]) — the WRITE half of a LATE
5186 /// enrolment: register the kinds AND repair the rows they strand, in ONE
5187 /// transaction. Returns `true` iff the repair re-enqueued anything (the caller
5188 /// must then `notify_new_work()`, since those rows are outside its batch).
5189 ///
5190 /// Split out so both write-path doors ([`Engine::enrol_batch_vector_kinds`]
5191 /// and the `#[doc(hidden)]` `write_canonical_row_with_kind_for_test`) share it
5192 /// verbatim, and so neither can register a kind without owing the repair.
5193 ///
5194 /// `register_vector_kind` is `INSERT OR IGNORE` and
5195 /// [`reenqueue_stranded_vector_rows`] is idempotent, so the read-only pre-pass
5196 /// that chose `kinds` does not need re-validating under the write lock: the
5197 /// worst a stale decision costs is one no-op `MIN` probe.
5198 fn enrol_and_unstrand(
5199 &self,
5200 connection: &Connection,
5201 kinds: &[&str],
5202 ) -> Result<bool, EngineError> {
5203 connection.execute_batch("BEGIN IMMEDIATE").map_err(|_| EngineError::Storage)?;
5204 let result = (|| -> rusqlite::Result<bool> {
5205 for kind in kinds {
5206 register_vector_kind(connection, kind)?;
5207 }
5208 reenqueue_stranded_vector_rows(connection)
5209 })();
5210 match result {
5211 Ok(enqueued) => {
5212 connection.execute_batch("COMMIT").map_err(|_| EngineError::Storage)?;
5213 Ok(enqueued)
5214 }
5215 Err(_) => {
5216 let _ = connection.execute_batch("ROLLBACK");
5217 Err(EngineError::Storage)
5218 }
5219 }
5220 }
5221
5222 fn write_inner(&self, batch: &[PreparedWrite]) -> Result<WriteReceipt, EngineError> {
5223 self.ensure_open()?;
5224
5225 if batch.is_empty() {
5226 return Err(EngineError::WriteValidation);
5227 }
5228
5229 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5230 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
5231 let plans = validate_batch(connection, batch)?;
5232 validate_nested_projection_sources_for_write(connection, batch)?;
5233 // 0.8.20 Slice 20c (R-20-DR remainder) — LATE ENROLMENT, before
5234 // `collect_projection_jobs` reads the vector-kind registry to decide
5235 // whether the dispatcher needs waking. See
5236 // `Engine::enrol_batch_vector_kinds`.
5237 //
5238 // fix-2 (codex §9 [P2]) — the flag says the enrolment ALSO un-stranded
5239 // rows outside this batch. Those rows are not in `projection_jobs` (that
5240 // only walks the batch), so it is OR-ed into `pending_projection` below:
5241 // `drain` is a passive barrier and never a trigger (C4 rider), so work
5242 // enqueued without a wake would sit until the next unrelated write.
5243 let unstranded = self.enrol_batch_vector_kinds(connection, batch)?;
5244 let projection_jobs = collect_projection_jobs(connection, batch)?;
5245 #[cfg(debug_assertions)]
5246 if self.force_next_commit_failure.swap(false, Ordering::SeqCst) {
5247 return Err(EngineError::Storage);
5248 }
5249 // One cursor per row. `base_cursor` is the last committed cursor;
5250 // row i in the batch gets cursor `base_cursor + i + 1`, and the
5251 // batch's final cursor (returned in WriteReceipt and stored as
5252 // the new `next_cursor`) is `base_cursor + batch.len()`. Sharing
5253 // one cursor across the batch previously collapsed every vec0
5254 // INSERT onto the same rowid via `INSERT OR IGNORE` — see
5255 // `dev/notes/0.7.0-engine-batch-vec0-collapse.md`.
5256 let base_cursor = self.next_cursor.load(Ordering::SeqCst);
5257 let increment = u64::try_from(batch.len()).unwrap_or(u64::MAX);
5258 let last_cursor = base_cursor.saturating_add(increment);
5259 // G11 (Slice 15) — edge bodies also need projection-runtime notification.
5260 // `collect_projection_jobs` only tracks Node items (pre-fetched for
5261 // cursor assignment); edge bodies update `_fathomdb_projection_state` in
5262 // `commit_batch` but need the scanner to wake up via `notify_new_work`.
5263 let has_edge_body_work =
5264 batch.iter().any(|w| matches!(w, PreparedWrite::Edge { body: Some(_), .. }));
5265 let pending_projection = !projection_jobs.is_empty() || has_edge_body_work || unstranded;
5266
5267 let dangling_edge_endpoints = match commit_batch(
5268 connection,
5269 batch,
5270 &plans,
5271 base_cursor,
5272 self.provenance_row_cap.load(Ordering::Relaxed),
5273 ) {
5274 Ok(count) => count,
5275 Err(err) => {
5276 self.emit_sqlite_internal_error(&err);
5277 return Err(EngineError::Storage);
5278 }
5279 };
5280 self.next_cursor.store(last_cursor, Ordering::SeqCst);
5281 if pending_projection {
5282 self.projection_runtime.notify_new_work();
5283 }
5284
5285 // G0 — surface the per-row cursors (1:1 with input order). Row i got
5286 // `base_cursor + i + 1`, matching the allocation in `commit_batch`.
5287 let row_cursors = (0..batch.len())
5288 .map(|i| base_cursor.saturating_add((i as u64).saturating_add(1)))
5289 .collect();
5290 Ok(WriteReceipt { cursor: last_cursor, row_cursors, dangling_edge_endpoints })
5291 }
5292
5293 /// G11 (Slice 15) — BYO-LLM ingest: spawn an external extraction harness
5294 /// speaking the `fathomdb.extract.v1` NDJSON-over-stdio protocol, send
5295 /// documents for extraction, and write the resulting entities
5296 /// (→ `canonical_nodes`) and fact-edges (→ `canonical_edges` with G11
5297 /// enrichment columns) to the store.
5298 ///
5299 /// `cmd` is argv (first element = program, rest = args). Documents are
5300 /// batched per the harness's `max_docs_per_request`. Entity `logical_id`
5301 /// is derived as `sha256("<type>:<name>")` (lowercase, hex-encoded) for
5302 /// stable cross-re-ingestion identity. Edge `logical_id` is derived as
5303 /// `sha256("<from_lid>:<to_lid>:<relation>")`. Both are consistent with
5304 /// G0 supersession: re-ingesting the same document yields the same ids,
5305 /// triggering tombstone-then-insert rather than accumulation.
5306 ///
5307 /// Returns [`EngineError::Extractor`] on protocol errors (bad handshake,
5308 /// subprocess spawn failure, JSON decode error). `no_facts` warnings from
5309 /// the harness are not errors and do not affect the receipt counts.
5310 pub fn ingest_with_extractor(
5311 &self,
5312 cmd: &[&str],
5313 documents: &[ExtractDocument],
5314 ) -> Result<IngestWithExtractorReceipt, EngineError> {
5315 // 0.8.6 Slice 5 (ADR-0.8.6): the spawn + hello/ready handshake +
5316 // request_id framing + error mapping now live in the reusable
5317 // `provider_session` transport seam, parameterized by `ProviderTask`.
5318 // `ingest_with_extractor` is the thin extract caller: it opens a session
5319 // for `ProviderTask::Extract` then runs the extract-specific payload
5320 // build + DB writes. The session owns child reaping via Drop.
5321 let mut session = self.provider_session(ProviderTask::Extract, cmd)?;
5322 self.run_extract_session(&mut session, documents)
5323 }
5324
5325 /// 0.8.6 Slice 5 (ADR-0.8.6) — open a provider session: spawn the caller
5326 /// subprocess, run the `hello`/`ready` handshake for `task`, and negotiate
5327 /// `supported_tasks`. The transport (NDJSON over stdio, the detached stdout
5328 /// drainer, the bounded-recv timeout, the `request_id` framing, and the
5329 /// catch-all `EngineError::Extractor` mapping) is identical across tasks;
5330 /// only the protocol string (`fathomdb.<task>.v1`) and the negotiated task
5331 /// name differ. For `ProviderTask::Extract` the wire is byte-identical to the
5332 /// pre-0.8.6 `fathomdb.extract.v1` path.
5333 fn provider_session(
5334 &self,
5335 task: ProviderTask,
5336 cmd: &[&str],
5337 ) -> Result<ProviderSession, EngineError> {
5338 let (program, args) = cmd.split_first().ok_or(EngineError::Extractor)?;
5339 let mut child = Command::new(program)
5340 .args(args)
5341 .stdin(Stdio::piped())
5342 .stdout(Stdio::piped())
5343 .stderr(Stdio::inherit())
5344 .spawn()
5345 .map_err(|_| EngineError::Extractor)?;
5346
5347 let child_stdin = match child.stdin.take() {
5348 Some(s) => s,
5349 None => {
5350 let _ = child.kill();
5351 let _ = child.wait();
5352 return Err(EngineError::Extractor);
5353 }
5354 };
5355 let child_stdout = match child.stdout.take() {
5356 Some(s) => s,
5357 None => {
5358 let _ = child.kill();
5359 let _ = child.wait();
5360 return Err(EngineError::Extractor);
5361 }
5362 };
5363
5364 // fix-35 [P1/P2]: drain stdout on a dedicated thread so (a) every read can
5365 // be bounded with a timeout — a hung harness can no longer block ingest
5366 // forever — and (b) the child's stdout pipe is drained continuously,
5367 // preventing a large-request deadlock (parent blocked writing stdin while
5368 // the child blocks writing a full stdout pipe). The handle is detached:
5369 // joining could hang if a misbehaving child holds stdout open past its
5370 // stdin EOF, so the session's `Drop` (child.kill()) is what guarantees
5371 // thread exit.
5372 let io_timeout = extractor_io_timeout();
5373 let (line_tx, line_rx) = mpsc::channel::<std::io::Result<String>>();
5374 thread::spawn(move || {
5375 let mut reader = BufReader::new(child_stdout);
5376 loop {
5377 let mut buf = String::new();
5378 match reader.read_line(&mut buf) {
5379 Ok(0) => break,
5380 Ok(_) => {
5381 if line_tx.send(Ok(buf)).is_err() {
5382 break;
5383 }
5384 }
5385 Err(e) => {
5386 let _ = line_tx.send(Err(e));
5387 break;
5388 }
5389 }
5390 }
5391 });
5392
5393 let mut session = ProviderSession {
5394 task,
5395 child,
5396 writer: std::io::BufWriter::new(child_stdin),
5397 line_rx,
5398 io_timeout,
5399 model: None,
5400 max_docs_per_request: 8,
5401 };
5402 // On any handshake/negotiation error the session is dropped here, which
5403 // reaps the child (Drop) — matching the prior outer kill/wait semantics.
5404 session.handshake()?;
5405 Ok(session)
5406 }
5407
5408 /// 0.8.6 Slice 5 — extract-specific driver over a `ProviderSession`. The
5409 /// payload build (documents → entities/edges) and DB writes are byte-identical
5410 /// to the pre-0.8.6 inner loop; only the spawn/handshake/framing moved into
5411 /// the shared session.
5412 fn run_extract_session(
5413 &self,
5414 session: &mut ProviderSession,
5415 documents: &[ExtractDocument],
5416 ) -> Result<IngestWithExtractorReceipt, EngineError> {
5417 let extractor_model_id = session.model.clone();
5418 let max_docs = session.max_docs_per_request;
5419
5420 // --- per-batch extract → write loop ---
5421 let mut nodes_written: u64 = 0;
5422 let mut edges_written: u64 = 0;
5423 let docs_processed = documents.len() as u64;
5424
5425 for (batch_idx, batch) in documents.chunks(max_docs).enumerate() {
5426 let request_id = format!("req-{batch_idx}");
5427 let docs_json: Vec<Value> = batch
5428 .iter()
5429 .map(|d| {
5430 serde_json::json!({
5431 "source_doc_id": d.source_doc_id,
5432 "body": d.body,
5433 })
5434 })
5435 .collect();
5436
5437 // Send the framed extract request and receive its matching `result`.
5438 // The session adds protocol/type/request_id and validates the
5439 // type=="result" + matching request_id envelope (fix-24 [P2]).
5440 let result = session
5441 .request(&request_id, vec![("documents".to_string(), Value::Array(docs_json))])?;
5442
5443 // R-20-E2 (0.8.20 Slice 5c, design §4 item 10) — every row this batch
5444 // produces takes its provenance from the CALLER's
5445 // `ExtractDocument.source_doc_id`, NEVER from the model's echo of that
5446 // field. The echo is attacker-/error-controlled: a harness that omits
5447 // it used to yield rows with NULL `source_id`, which no
5448 // `excise_source` call can reach — the model could make a row
5449 // permanently un-erasable simply by dropping a key.
5450 //
5451 // `resolve_provenance` therefore admits the echo only as a SELECTOR
5452 // among ids the caller already supplied in THIS batch, and never as a
5453 // value:
5454 //
5455 // * single-document batch — attribution is unambiguous, so the
5456 // caller's id is used and the echo is ignored outright;
5457 // * multi-document batch — the echo must name one of the batch's
5458 // caller-supplied ids (the caller's own copy of the string is
5459 // then stored). An absent or unrecognised echo is a protocol
5460 // violation and fails the ingest LOUDLY with
5461 // `EngineError::Extractor`, because the alternative — guessing an
5462 // attribution — would silently mis-file the row under a document
5463 // whose erasure would then not remove it.
5464 let batch_provenance = batch
5465 .iter()
5466 .map(|d| SourceId::new(d.source_doc_id.clone()))
5467 .collect::<Result<Vec<_>, _>>()?;
5468 let resolve_provenance = |echo: Option<&str>| -> Result<SourceId, EngineError> {
5469 if let [only] = batch_provenance.as_slice() {
5470 return Ok(only.clone());
5471 }
5472 let echo = echo.ok_or(EngineError::Extractor)?;
5473 batch_provenance
5474 .iter()
5475 .find(|caller_id| caller_id.as_str() == echo)
5476 .cloned()
5477 .ok_or(EngineError::Extractor)
5478 };
5479
5480 // --- map entities → PreparedWrite::Node with stable logical_id ---
5481 let entities =
5482 result.get("entities").and_then(|v| v.as_array()).cloned().unwrap_or_default();
5483 let raw_edges =
5484 result.get("edges").and_then(|v| v.as_array()).cloned().unwrap_or_default();
5485
5486 // R3 (SCHEMA-GATE-1): collect substituted_t_valid values from
5487 // temporal_fallback warnings. An edge whose t_valid matches one of
5488 // these values had its event time defaulted to created_at (not
5489 // text-grounded) and must be flagged so BFS can exclude it.
5490 //
5491 // TC-33: kept as RAW `Value`s here and normalised below, together
5492 // with the edge side, through the SAME function. See the
5493 // normalisation block for why that is load-bearing.
5494 let raw_fallback_dates: Vec<&Value> = result
5495 .get("warnings")
5496 .and_then(|v| v.as_array())
5497 .map(|ws| {
5498 ws.iter()
5499 .filter(|w| {
5500 w.get("kind").and_then(|k| k.as_str()) == Some("temporal_fallback")
5501 })
5502 .filter_map(|w| w.get("substituted_t_valid"))
5503 .collect()
5504 })
5505 .unwrap_or_default();
5506
5507 if !entities.is_empty() {
5508 let node_batch: Vec<PreparedWrite> = entities
5509 .iter()
5510 .map(|entity| -> Result<PreparedWrite, EngineError> {
5511 let name = entity.get("name").and_then(|v| v.as_str()).unwrap_or("");
5512 let kind = entity.get("type").and_then(|v| v.as_str()).unwrap_or("entity");
5513 // R-20-E2: caller-grounded, echo used only as a selector.
5514 let source_doc_id = resolve_provenance(
5515 entity.get("source_doc_id").and_then(|v| v.as_str()),
5516 )?;
5517 // fix-34 [P1]: derive_logical_id now rejects an empty name
5518 // or a ':' in kind — inputs that would collide distinct
5519 // entities onto one identity and silently drop one.
5520 let logical_id = derive_logical_id(kind, name)?;
5521 Ok(PreparedWrite::Node {
5522 kind: kind.to_string(),
5523 body: name.to_string(),
5524 source_id: source_doc_id,
5525 logical_id: Some(logical_id),
5526 state: InitialState::Active,
5527 reason: None,
5528 valid_from: None,
5529 valid_until: None,
5530 })
5531 })
5532 .collect::<Result<Vec<_>, _>>()?;
5533
5534 // fix-29/fix-34 [P2]: deduplicate within the batch by logical_id so
5535 // a harness that returns the same entity twice does not write a row
5536 // that immediately supersedes its sibling (shared with the edge arm).
5537 let node_batch = dedup_prepared_by_logical_id(node_batch);
5538
5539 // fix-23 [P2]: skip entities whose logical_id is already active
5540 // to avoid needless supersede churn on re-ingest.
5541 let ids: Vec<String> = node_batch
5542 .iter()
5543 .filter_map(|w| {
5544 if let PreparedWrite::Node { logical_id: Some(id), .. } = w {
5545 Some(id.clone())
5546 } else {
5547 None
5548 }
5549 })
5550 .collect();
5551 let existing: std::collections::HashSet<String> = self
5552 // Internal existence probe: STRICT view — this must see
5553 // exactly the rows the pre-slice code saw.
5554 .read_get_many(&ids, &ReadView::default())?
5555 .into_iter()
5556 .zip(ids)
5557 .filter_map(|(opt, id)| opt.map(|_| id))
5558 .collect();
5559 let new_nodes: Vec<PreparedWrite> = node_batch
5560 .into_iter()
5561 .filter(|w| {
5562 if let PreparedWrite::Node { logical_id: Some(id), .. } = w {
5563 !existing.contains(id)
5564 } else {
5565 true
5566 }
5567 })
5568 .collect();
5569 if !new_nodes.is_empty() {
5570 let n = new_nodes.len() as u64;
5571 self.write(&new_nodes)?;
5572 nodes_written = nodes_written.saturating_add(n);
5573 }
5574 }
5575
5576 // --- map edges → PreparedWrite::Edge with G11 columns ---
5577 if !raw_edges.is_empty() {
5578 // fix-33 [P1]: the protocol gives edges NO endpoint types —
5579 // `from_entity`/`to_entity` reference entities BY NAME (or alias).
5580 // Build a name+alias → (canonical name, type) index from the same
5581 // result's `entities[]` so each endpoint's logical_id matches the
5582 // node's. (Nodes derive id from the entity's real type; defaulting
5583 // the edge endpoint kind to "entity" orphaned every contract-faithful
5584 // edge from its nodes and tripped the G8 dangling probe.)
5585 //
5586 // Two passes so a canonical NAME always wins over a (different
5587 // entity's) ALIAS regardless of `entities[]` order: pass 1 inserts
5588 // all canonical names, pass 2 fills aliases only where no name
5589 // already claims that key. (Name↔name clashes remain first-wins —
5590 // contradictory input; no principled resolution exists.)
5591 let mut entity_index: std::collections::HashMap<String, (String, String)> =
5592 std::collections::HashMap::new();
5593 for entity in &entities {
5594 let name = entity.get("name").and_then(|v| v.as_str()).unwrap_or("");
5595 if name.is_empty() {
5596 continue;
5597 }
5598 let kind =
5599 entity.get("type").and_then(|v| v.as_str()).unwrap_or("entity").to_string();
5600 entity_index
5601 .entry(name.to_lowercase())
5602 .or_insert_with(|| (name.to_string(), kind));
5603 }
5604 for entity in &entities {
5605 let name = entity.get("name").and_then(|v| v.as_str()).unwrap_or("");
5606 if name.is_empty() {
5607 continue;
5608 }
5609 let kind =
5610 entity.get("type").and_then(|v| v.as_str()).unwrap_or("entity").to_string();
5611 if let Some(aliases) = entity.get("aliases").and_then(|v| v.as_array()) {
5612 for alias in aliases.iter().filter_map(|a| a.as_str()) {
5613 if !alias.is_empty() {
5614 entity_index
5615 .entry(alias.to_lowercase())
5616 .or_insert_with(|| (name.to_string(), kind.clone()));
5617 }
5618 }
5619 }
5620 }
5621
5622 // TC-33 — normalise EVERY extractor timestamp here, in ONE pass,
5623 // under ONE connection lock, BEFORE any edge is built. Both the
5624 // edge side (`t_valid`/`t_invalid`) and the temporal_fallback
5625 // warning side (`substituted_t_valid`) go through the SAME
5626 // function, and any value that cannot be normalised HARD-REJECTS
5627 // the whole ingest.
5628 //
5629 // **Normalising both sides is load-bearing, and nothing would
5630 // have caught it.** `temporal_fallback` is decided by comparing
5631 // the edge's t_valid against the warnings' substituted_t_valid.
5632 // That was a RAW BYTE-FOR-BYTE STRING MATCH with
5633 // `.unwrap_or(false)` on the miss path, and `substituted_t_valid`
5634 // is a FREE-FORM JSON key on the ELPS warnings envelope, not a
5635 // Rust struct field. So normalising only the edge side would
5636 // leave the set never matching, `.unwrap_or(false)` firing, and
5637 // EVERY fallback edge silently becoming a TRUSTED edge — with no
5638 // compile error anywhere. That flag is the only thing excluding
5639 // untrustworthy-time edges from graph BFS and graph seeding.
5640 //
5641 // Normalising both sides also FIXES a pre-existing brittleness:
5642 // `2025-03-20T09:30:00Z` and `2025-03-20T09:30:00+00:00` are the
5643 // same instant but MISS each other under a byte comparison. They
5644 // now compare equal as epochs.
5645 //
5646 // A malformed `substituted_t_valid` rejects rather than being
5647 // skipped: skipping it would leave the edge unflagged, i.e.
5648 // treated as TRUSTED — the same fail-open in a different place.
5649 //
5650 // The lock is taken and released HERE; `self.write(...)` below
5651 // re-acquires it, so no lock is held across the write.
5652 // (t_valid, t_invalid) epoch pair per edge, in `raw_edges` order.
5653 type EdgeTimes = Vec<(Option<i64>, Option<i64>)>;
5654 let (edge_times, fallback_epochs): (EdgeTimes, std::collections::HashSet<i64>) = {
5655 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5656 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
5657
5658 let mut times = Vec::with_capacity(raw_edges.len());
5659 for edge in &raw_edges {
5660 times.push((
5661 normalize_extractor_timestamp(
5662 connection,
5663 "t_valid",
5664 edge.get("t_valid"),
5665 )?,
5666 normalize_extractor_timestamp(
5667 connection,
5668 "t_invalid",
5669 edge.get("t_invalid"),
5670 )?,
5671 ));
5672 }
5673
5674 let mut epochs = std::collections::HashSet::new();
5675 for raw in &raw_fallback_dates {
5676 if let Some(epoch) = normalize_extractor_timestamp(
5677 connection,
5678 "substituted_t_valid",
5679 Some(raw),
5680 )? {
5681 epochs.insert(epoch);
5682 }
5683 }
5684 (times, epochs)
5685 };
5686
5687 let edge_batch: Vec<PreparedWrite> = raw_edges
5688 .iter()
5689 .zip(&edge_times)
5690 .map(|(edge, &(t_valid, t_invalid))| -> Result<PreparedWrite, EngineError> {
5691 let from_entity =
5692 edge.get("from_entity").and_then(|v| v.as_str()).unwrap_or("");
5693 let to_entity =
5694 edge.get("to_entity").and_then(|v| v.as_str()).unwrap_or("");
5695 let relation =
5696 edge.get("relation").and_then(|v| v.as_str()).unwrap_or("related_to");
5697 let body = edge.get("body").and_then(|v| v.as_str()).map(str::to_string);
5698 // TC-33: `t_valid`/`t_invalid` were normalised (and any
5699 // malformed or non-string value hard-rejected) in the
5700 // pass above; they arrive here as epoch seconds.
5701 // fix-26 [P2]: validate confidence is in [0.0, 1.0] at the
5702 // protocol boundary; reject out-of-range values.
5703 let confidence = match edge.get("confidence").and_then(|v| v.as_f64()) {
5704 Some(c) if !(0.0..=1.0).contains(&c) => {
5705 return Err(EngineError::Extractor);
5706 }
5707 c => c,
5708 };
5709 // R-20-E2: caller-grounded, echo used only as a selector.
5710 let source_doc_id =
5711 resolve_provenance(edge.get("source_doc_id").and_then(|v| v.as_str()))?;
5712
5713 // fix-33 [P1]: resolve each endpoint via the entities[]
5714 // index (by name or alias) → the entity's canonical
5715 // (name, type); fall back to kind "entity" only for a truly
5716 // unlisted name (synthesized dangling endpoints ARE listed,
5717 // so this is the defensive path). derive_logical_id (fix-34)
5718 // still rejects an empty name / ':' in kind.
5719 let (from_name, from_kind) = entity_index
5720 .get(&from_entity.to_lowercase())
5721 .cloned()
5722 .unwrap_or_else(|| (from_entity.to_string(), "entity".to_string()));
5723 let (to_name, to_kind) = entity_index
5724 .get(&to_entity.to_lowercase())
5725 .cloned()
5726 .unwrap_or_else(|| (to_entity.to_string(), "entity".to_string()));
5727 let from_lid = derive_logical_id(&from_kind, &from_name)?;
5728 let to_lid = derive_logical_id(&to_kind, &to_name)?;
5729 let edge_key = format!("{from_lid}:{to_lid}:{relation}");
5730 let edge_lid = derive_logical_id("edge", &edge_key)?;
5731
5732 // TC-33: BOTH sides are now epochs from the SAME
5733 // normalisation, so this compares instants rather than
5734 // byte strings.
5735 let is_temporal_fallback =
5736 t_valid.is_some_and(|tv| fallback_epochs.contains(&tv));
5737 Ok(PreparedWrite::Edge {
5738 kind: relation.to_string(),
5739 from: from_lid,
5740 to: to_lid,
5741 source_id: source_doc_id,
5742 logical_id: Some(edge_lid),
5743 body,
5744 t_valid,
5745 t_invalid,
5746 confidence,
5747 extractor_model_id: extractor_model_id.clone(),
5748 temporal_fallback: if is_temporal_fallback { Some(true) } else { None },
5749 })
5750 })
5751 .collect::<Result<Vec<_>, _>>()?;
5752 // fix-34 [P2]: dedup edges by logical_id, mirroring the node arm
5753 // (fix-29) — a duplicate edge in one harness response would
5754 // otherwise write a row that immediately supersedes its sibling.
5755 let edge_batch = dedup_prepared_by_logical_id(edge_batch);
5756 let n = edge_batch.len() as u64;
5757 self.write(&edge_batch)?;
5758 edges_written = edges_written.saturating_add(n);
5759 }
5760 }
5761
5762 // The `ProviderSession` (and its writer/child) is dropped by the caller
5763 // when `ingest_with_extractor` returns: Drop sends stdin EOF and reaps
5764 // the child, matching the prior explicit drop(writer)+kill/wait.
5765 Ok(IngestWithExtractorReceipt { nodes_written, edges_written, docs_processed })
5766 }
5767
5768 /// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — BYO-LLM CONSOLIDATION / RECENCY.
5769 ///
5770 /// The SECOND consumer of the one `provider_session` transport (ADR-0.8.6):
5771 /// consolidation reuses the exact NDJSON-over-stdio transport, hello/ready
5772 /// handshake, `supported_tasks` negotiation, `request_id` framing, and
5773 /// bounded-recv timeout — only the protocol string
5774 /// (`fathomdb.consolidate.v1`) and the task-specific payload differ. There is
5775 /// NO second transport and NO second handshake.
5776 ///
5777 /// For each `(subject, relation)` axis, FathomDB assembles a candidate
5778 /// cluster of competing active fact-edges DETERMINISTICALLY (CPU-only, no
5779 /// LLM), sends it to the caller-supplied harness, and applies the returned
5780 /// verdicts. **CALLER-SIDE BYO-LLM**: the harness is the caller's subprocess;
5781 /// the library never embeds or calls an LLM and makes NO network egress.
5782 ///
5783 /// **Load-bearing semantic (ADR-0.8.12 §2.1):** consolidation records
5784 /// supersession / recency METADATA only — `invalidate` sets `t_invalid`,
5785 /// `supersede`/`merge` marks the row superseded via the existing G0 tombstone
5786 /// column. Edge BODIES are NEVER rewritten and NO row is ever deleted (the
5787 /// 0.8.3 lesson: blind content-merge HURT accuracy). The original rows
5788 /// survive; the engine stays deterministic.
5789 ///
5790 /// Returns [`EngineError::Consolidator`] on any transport/handshake/protocol
5791 /// fault or a malformed / out-of-cluster verdict.
5792 pub fn consolidate_with_provider(
5793 &self,
5794 cmd: &[&str],
5795 axes: &[ConsolidateAxis],
5796 ) -> Result<ConsolidateReceipt, EngineError> {
5797 // Reuse the shared transport verbatim; remap its (Extractor-flavoured)
5798 // transport error to the task-specific Consolidator leaf.
5799 let mut session = self
5800 .provider_session(ProviderTask::Consolidate, cmd)
5801 .map_err(|_| EngineError::Consolidator)?;
5802 self.run_consolidate_session(&mut session, axes)
5803 }
5804
5805 /// 0.8.12 Slice 15 — consolidate-specific driver over a `ProviderSession`.
5806 /// Mirrors [`run_extract_session`][Engine::run_extract_session]: assemble the
5807 /// task payload, run the framed request over the shared session, apply the
5808 /// task-specific DB effect. The cluster assembly + verdict application are
5809 /// CPU-only/deterministic.
5810 fn run_consolidate_session(
5811 &self,
5812 session: &mut ProviderSession,
5813 axes: &[ConsolidateAxis],
5814 ) -> Result<ConsolidateReceipt, EngineError> {
5815 let mut receipt = ConsolidateReceipt::default();
5816
5817 for (i, axis) in axes.iter().enumerate() {
5818 // 1. Deterministically assemble the candidate cluster (CPU-only, no LLM).
5819 let cluster = self.assemble_consolidate_cluster(axis)?;
5820 if cluster.is_empty() {
5821 continue;
5822 }
5823 receipt.clusters_processed = receipt.clusters_processed.saturating_add(1);
5824 receipt.edges_examined = receipt.edges_examined.saturating_add(cluster.len() as u64);
5825
5826 // 2. Send the cluster; receive the verdict envelope. The session adds
5827 // protocol/type/request_id and validates type=="result" + matching
5828 // request_id. Any transport/protocol fault → Consolidator.
5829 let request_id = format!("req-{i}");
5830 // TC-33: storage and `ConsolidateCandidateEdge` are INTEGER epoch
5831 // seconds, but the harness WIRE is ISO-8601 — the same split as the
5832 // extractor boundary. Render on the way out; the verdict's
5833 // `t_invalid` is normalised back on the way in. Without this the
5834 // harness would receive epoch integers and (since the reference stub
5835 // echoes the winner's `t_valid` straight back as `t_invalid`) its
5836 // reply would be rejected by our own inbound normaliser.
5837 let edges_json: Vec<Value> = {
5838 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5839 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
5840 // TC-33 fix-1 backstop [DEFENSIVE — unreachable]. A stored
5841 // `Some(ts)` that fails to render must NOT become a silent
5842 // `null`: that is exactly the "still valid" resurrection vector.
5843 // With `reject_unrenderable_edge_epoch` guarding the write
5844 // boundary, no unrenderable epoch can reach storage — so this is
5845 // a hard-assert upholding that invariant STRUCTURALLY, not the
5846 // primary defence. `None` (unknown) still renders to JSON null;
5847 // only a NON-NULL stored epoch that fails to render is an error.
5848 let render = |field: &str, value: Option<i64>| -> Result<Value, EngineError> {
5849 match value {
5850 None => Ok(Value::Null),
5851 Some(ts) => match epoch_seconds_to_iso8601(connection, ts) {
5852 Some(iso) => Ok(Value::from(iso)),
5853 None => Err(EngineError::InvalidArgument {
5854 msg: format!(
5855 "INVARIANT VIOLATION (TC-33 fix-1): stored edge `{field}` = \
5856 {ts} is unrenderable to ISO-8601 and would have gone to the \
5857 consolidation wire as a silent null (\"still valid\"). The \
5858 write boundary should have made this unstorable."
5859 ),
5860 }),
5861 },
5862 }
5863 };
5864 cluster
5865 .iter()
5866 .map(|e| {
5867 Ok::<Value, EngineError>(serde_json::json!({
5868 "edge_ref": e.edge_ref,
5869 "body": e.body,
5870 "t_valid": render("t_valid", e.t_valid)?,
5871 "t_invalid": render("t_invalid", e.t_invalid)?,
5872 "confidence": e.confidence,
5873 "source_doc_id": e.source_doc_id,
5874 "extractor_model_id": e.extractor_model_id,
5875 }))
5876 })
5877 .collect::<Result<Vec<Value>, EngineError>>()?
5878 };
5879 let cluster_json = serde_json::json!({
5880 "subject": axis.subject_logical_id,
5881 "relation": axis.relation,
5882 "edges": edges_json,
5883 });
5884 let result = session
5885 .request(&request_id, vec![("cluster".to_string(), cluster_json)])
5886 .map_err(|_| EngineError::Consolidator)?;
5887
5888 // 3. Apply the verdicts (metadata-only; original rows + bodies survive).
5889 let verdicts = result
5890 .get("verdicts")
5891 .and_then(|v| v.as_array())
5892 .ok_or(EngineError::Consolidator)?
5893 .clone();
5894 self.apply_consolidate_verdicts(&cluster, &verdicts, &mut receipt)?;
5895 }
5896
5897 Ok(receipt)
5898 }
5899
5900 /// 0.8.12 Slice 15 — assemble the competing fact-edge cluster for one
5901 /// `(subject, relation)` axis, deterministically, from active `canonical_edges`
5902 /// (`from_id = subject AND kind = relation AND superseded_at IS NULL`), ordered
5903 /// by `write_cursor` (stable insertion order). CPU-only; no network, no LLM.
5904 fn assemble_consolidate_cluster(
5905 &self,
5906 axis: &ConsolidateAxis,
5907 ) -> Result<Vec<ConsolidateCandidateEdge>, EngineError> {
5908 self.ensure_open()?;
5909 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5910 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
5911 let mut stmt = connection
5912 .prepare(
5913 "SELECT logical_id, body, t_valid, t_invalid, confidence, source_id, \
5914 extractor_model_id \
5915 FROM canonical_edges \
5916 WHERE from_id = ?1 AND kind = ?2 AND superseded_at IS NULL \
5917 ORDER BY write_cursor",
5918 )
5919 .map_err(|_| EngineError::Storage)?;
5920 let rows = stmt
5921 .query_map(params![axis.subject_logical_id, axis.relation], |r| {
5922 Ok(ConsolidateCandidateEdge {
5923 edge_ref: r.get::<_, Option<String>>(0)?.unwrap_or_default(),
5924 body: r.get(1)?,
5925 t_valid: r.get(2)?,
5926 t_invalid: r.get(3)?,
5927 confidence: r.get(4)?,
5928 source_doc_id: r.get(5)?,
5929 extractor_model_id: r.get(6)?,
5930 })
5931 })
5932 .map_err(|_| EngineError::Storage)?;
5933 let out: rusqlite::Result<Vec<ConsolidateCandidateEdge>> = rows.collect();
5934 // Skip any edge with a NULL/empty logical_id (no stable ref to round-trip).
5935 Ok(out
5936 .map_err(|_| EngineError::Storage)?
5937 .into_iter()
5938 .filter(|e| !e.edge_ref.is_empty())
5939 .collect())
5940 }
5941
5942 /// 0.8.12 Slice 15 — apply the harness verdicts as METADATA-ONLY transitions
5943 /// (ADR-0.8.12 §2.1). NEVER rewrites a body, NEVER deletes a row. A verdict
5944 /// referencing an edge not in the presented cluster, or an unknown verdict
5945 /// kind, is a protocol fault → [`EngineError::Consolidator`].
5946 fn apply_consolidate_verdicts(
5947 &self,
5948 cluster: &[ConsolidateCandidateEdge],
5949 verdicts: &[Value],
5950 receipt: &mut ConsolidateReceipt,
5951 ) -> Result<(), EngineError> {
5952 let known: std::collections::HashSet<&str> =
5953 cluster.iter().map(|e| e.edge_ref.as_str()).collect();
5954 // fix-1 [P2] bijection: the verdict set must cover the presented cluster
5955 // EXACTLY — every presented edge ruled on, none ruled on twice.
5956 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
5957
5958 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5959 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
5960 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
5961
5962 for v in verdicts {
5963 let edge_ref =
5964 v.get("edge_ref").and_then(|x| x.as_str()).ok_or(EngineError::Consolidator)?;
5965 // The harness may only rule on edges FathomDB presented in the cluster.
5966 if !known.contains(edge_ref) {
5967 return Err(EngineError::Consolidator);
5968 }
5969 // fix-1 [P2]: a repeated edge_ref is a protocol fault (not a bijection).
5970 if !seen.insert(edge_ref) {
5971 return Err(EngineError::Consolidator);
5972 }
5973 let verdict =
5974 v.get("verdict").and_then(|x| x.as_str()).ok_or(EngineError::Consolidator)?;
5975 // Look up the active edge's projection cursor BEFORE any UPDATE so a
5976 // supersede (which clears `superseded_at IS NULL`) can still find it.
5977 let active_cursor = Self::active_edge_write_cursor(&tx, edge_ref)?;
5978 match verdict {
5979 "keep" => {
5980 receipt.edges_kept = receipt.edges_kept.saturating_add(1);
5981 }
5982 "invalidate" => {
5983 // Recency metadata: set t_invalid; the row and its body are
5984 // left intact (this is NOT a destructive content rewrite).
5985 //
5986 // TC-33: the CONSOLIDATION harness is the same class of
5987 // BYO-LLM boundary as the extractor, so it carries ISO-8601
5988 // on the wire and is normalised here with the SAME hard
5989 // rejection. Previously the raw string went straight into the
5990 // UPDATE with no validation whatsoever.
5991 // fix-3 [P2]: consolidation is a BYO-LLM PROVIDER boundary, so
5992 // a malformed / non-string `t_invalid` is a PROVIDER protocol
5993 // fault → `Consolidator`, NOT the extractor/user `InvalidArgument`
5994 // that `normalize_extractor_timestamp` emits. Remap it to match
5995 // the two sibling failure modes on this same value (missing key
5996 // and null/unparseable-to-None, both `Consolidator`). Consistent,
5997 // not a diagnostic loss: `Consolidator` is a unit variant and the
5998 // adjacent `.ok_or(EngineError::Consolidator)` cases already
5999 // discard any message.
6000 let ts = normalize_extractor_timestamp(
6001 &tx,
6002 "t_invalid",
6003 Some(v.get("t_invalid").ok_or(EngineError::Consolidator)?),
6004 )
6005 .map_err(|_| EngineError::Consolidator)?
6006 .ok_or(EngineError::Consolidator)?;
6007 tx.execute(
6008 "UPDATE canonical_edges SET t_invalid = ?1 \
6009 WHERE logical_id = ?2 AND superseded_at IS NULL",
6010 params![ts, edge_ref],
6011 )
6012 .map_err(|_| EngineError::Storage)?;
6013 // fix-1 [P1]: prune the STATIC projection shadow rows so the
6014 // consolidated-away edge stops surfacing in FTS/vector — but
6015 // ONLY when the edge is ended as of the engine's "now",
6016 // mirroring the graph-traversal filter `edge_validity_sql`.
6017 // A future-dated t_invalid keeps the edge valid ⇒ keep the
6018 // projection. NON-DESTRUCTIVE: the canonical_edges row + body
6019 // survive (ADR-0.8.12 §2.1).
6020 //
6021 // TC-33: this used to be `SELECT datetime(?1) <= datetime('now')`
6022 // — an inline clock, AND a misleading error class: junk made
6023 // the SELECT yield SQL NULL, so `r.get::<bool>` failed as
6024 // `EngineError::Storage`. Both timestamps are integers now, so
6025 // the comparison is plain Rust against the bound `:now` seam.
6026 if let Some(cursor) = active_cursor {
6027 let ended = ts <= current_epoch_seconds();
6028 if ended {
6029 // fix-2 [P2]: KEEP the projection terminal row. The
6030 // canonical_edges row stays NON-superseded (invalidate
6031 // is metadata-only), and `database_has_pending_projection_work`
6032 // flags any non-superseded edge that has a body but no
6033 // terminal as pending; since `next_pending_projection_jobs`
6034 // only scans cursors ABOVE the stored projection cursor, an
6035 // already-projected invalidated edge would never be requeued
6036 // and `drain()`/`wait_for_idle` would hang forever. Dropping
6037 // the FTS/vec shadows (below) hides it from active retrieval;
6038 // retaining the terminal keeps the scheduler idle.
6039 Self::prune_edge_projection_shadows(&tx, cursor, true)?;
6040 }
6041 }
6042 receipt.edges_invalidated = receipt.edges_invalidated.saturating_add(1);
6043 }
6044 // `merge` maps cleanly to supersede + metadata (ADR-0.8.12 §3):
6045 // the loser is marked superseded; the winner ("by"/"into") is the
6046 // surviving active row. No body is merged.
6047 "supersede" | "merge" => {
6048 // Mark superseded via the existing G0 tombstone column; the row
6049 // survives (invalidate-not-delete). Use a fresh monotonic cursor.
6050 let cursor = self.next_cursor.fetch_add(1, Ordering::SeqCst).saturating_add(1);
6051 tx.execute(
6052 "UPDATE canonical_edges SET superseded_at = ?1 \
6053 WHERE logical_id = ?2 AND superseded_at IS NULL",
6054 params![cursor, edge_ref],
6055 )
6056 .map_err(|_| EngineError::Storage)?;
6057 // fix-1 [P1]: a superseded edge is unconditionally out of the
6058 // active set (graph traversal filters `superseded_at IS NULL`),
6059 // so prune its FTS/vector projection shadow rows to match.
6060 if let Some(active_cursor) = active_cursor {
6061 // A superseded row is excluded from the pending-work check
6062 // (`superseded_at IS NOT NULL`), so dropping its terminal too
6063 // is safe (matches the excise pattern) and cannot phantom-pend.
6064 Self::prune_edge_projection_shadows(&tx, active_cursor, false)?;
6065 }
6066 receipt.edges_superseded = receipt.edges_superseded.saturating_add(1);
6067 }
6068 _ => return Err(EngineError::Consolidator),
6069 }
6070 }
6071
6072 // fix-1 [P2]: bijection completeness — every presented cluster edge must
6073 // have received exactly one verdict.
6074 if seen.len() != known.len() {
6075 return Err(EngineError::Consolidator);
6076 }
6077
6078 tx.commit().map_err(|_| EngineError::Storage)?;
6079 Ok(())
6080 }
6081
6082 /// fix-1 [P1] — the active (non-superseded) row's projection `write_cursor`
6083 /// for a fact-edge `logical_id`, or `None` if there is no active row. The
6084 /// cursor keys the STATIC projection shadow rows (FTS `search_index_edges`,
6085 /// vec0 `vector_default` by rowid, `_fathomdb_vector_rows`,
6086 /// `_fathomdb_projection_terminal`).
6087 fn active_edge_write_cursor(
6088 tx: &rusqlite::Transaction<'_>,
6089 edge_ref: &str,
6090 ) -> Result<Option<i64>, EngineError> {
6091 tx.query_row(
6092 "SELECT write_cursor FROM canonical_edges \
6093 WHERE logical_id = ?1 AND superseded_at IS NULL",
6094 params![edge_ref],
6095 |r| r.get::<_, i64>(0),
6096 )
6097 .optional()
6098 .map_err(|_| EngineError::Storage)
6099 }
6100
6101 /// fix-1 [P1] — prune the STATIC projection shadow rows for a canonical
6102 /// row's `write_cursor` so a consolidated-away edge stops surfacing in
6103 /// FTS/vector retrieval. Mirrors the excision pattern at
6104 /// `excise_source_inner` (invalidate-not-delete: the canonical row + body
6105 /// are NEVER touched here).
6106 ///
6107 /// `keep_terminal` retains the `_fathomdb_projection_terminal` marker — set it
6108 /// when the canonical row stays NON-superseded (an `invalidate` verdict), so the
6109 /// projection scheduler still treats the cursor as done (fix-2 [P2]); clear it
6110 /// when the row is superseded (excluded from the pending-work scan anyway).
6111 ///
6112 /// FIXED (0.8.12 Slice A, R-CON-2 named default-ON blocker; Slice-20 codex
6113 /// §9 [P2]): a full `rebuild_projections` re-projects every non-superseded
6114 /// edge with a body from `canonical_edges` — this used to re-materialise an
6115 /// invalidated edge's FTS/vec shadows even though graph traversal excludes
6116 /// it via the `t_invalid > now` filter. The FTS rebuild SELECT
6117 /// (`rebuild_shadow_state`), the vec projection queue
6118 /// (`next_pending_projection_jobs`), and the pending-work probe
6119 /// (`database_has_pending_projection_work`) now all carry the same
6120 /// `edge_validity_sql` filter as graph traversal (TC-33: INTEGER compare
6121 /// against the bound `:now`, formerly `datetime(t_invalid) > datetime('now')`),
6122 /// so a rebuild is durable across the recency exclusion.
6123 fn prune_edge_projection_shadows(
6124 tx: &rusqlite::Transaction<'_>,
6125 cursor: i64,
6126 keep_terminal: bool,
6127 ) -> Result<(), EngineError> {
6128 tx.execute("DELETE FROM search_index_edges WHERE write_cursor = ?1", [cursor])
6129 .map_err(|_| EngineError::Storage)?;
6130 // vec0 rowid is the canonical row's write_cursor. TC-76: via the one
6131 // vec0-delete primitive ([`delete_vector_partition_row`]).
6132 delete_vector_partition_row(tx, cursor).map_err(|_| EngineError::Storage)?;
6133 tx.execute("DELETE FROM _fathomdb_vector_rows WHERE write_cursor = ?1", [cursor])
6134 .map_err(|_| EngineError::Storage)?;
6135 if !keep_terminal {
6136 tx.execute(
6137 "DELETE FROM _fathomdb_projection_terminal WHERE write_cursor = ?1",
6138 [cursor],
6139 )
6140 .map_err(|_| EngineError::Storage)?;
6141 }
6142 Ok(())
6143 }
6144
6145 pub fn search(&self, query: &str) -> Result<SearchResult, EngineError> {
6146 self.search_filtered(query, None)
6147 }
6148
6149 /// 0.8.20 Slice 15b fix-2 (R-20-NV / R-20-RV) — `search` under an explicit
6150 /// [`ReadView`], the escape hatch matching the one the five read verbs got in
6151 /// Slice 10b. `search(query)` is exactly `search_view(query, &ReadView::default())`.
6152 ///
6153 /// **Scope: the VALIDITY axis only.** `include_out_of_window` and
6154 /// `valid_as_of` are honoured; the EXISTENCE flags (`include_superseded`,
6155 /// `include_inactive`) are **refused** with
6156 /// [`EngineError::InvalidArgument`] rather than silently ignored. Relaxing
6157 /// `superseded_at IS NULL` on a retrieval path would resurrect the stale-body
6158 /// leak the Slice-15 fix-1 review closed, and search hydrates from projection
6159 /// indexes (`search_index`, `vector_default`) that are not version-complete —
6160 /// so "include superseded" has no truthful answer here. Refusing says that;
6161 /// ignoring would be the dead surface this fix exists to remove.
6162 ///
6163 /// Governed surface: PROPOSED / NOT SIGNED (0.8.20 Slice 15b fix-2).
6164 pub fn search_view(&self, query: &str, view: &ReadView) -> Result<SearchResult, EngineError> {
6165 self.search_reranked_with_explain(query, None, 0, false, 0.3, 0, false, *view)
6166 }
6167
6168 /// 0.8.20 Slice 15b fix-2 (R-20-NV / R-20-RV) — the FULL-arity view entry
6169 /// point: [`search_reranked`][Engine::search_reranked] /
6170 /// [`search_explained`][Engine::search_explained] under an explicit
6171 /// [`ReadView`]. This is what the Python and TypeScript `search(..., view=)`
6172 /// bindings call, so a caller can combine a content filter, the CE knobs and
6173 /// a validity view in one query — passing `view` must not silently disable
6174 /// the filter, and passing a filter must not silently disable `view`.
6175 ///
6176 /// `search_reranked(q, f, d, g, a, p)` is exactly
6177 /// `search_reranked_view(q, f, d, g, a, p, false, &ReadView::default())`.
6178 ///
6179 /// Validity axis only; existence flags are refused. See
6180 /// [`search_view`][Engine::search_view].
6181 ///
6182 /// Governed surface: PROPOSED / NOT SIGNED (0.8.20 Slice 15b fix-2).
6183 #[allow(clippy::too_many_arguments)] // mirrors search_explained + the view
6184 pub fn search_reranked_view(
6185 &self,
6186 query: &str,
6187 filter: Option<SearchFilter>,
6188 rerank_depth: usize,
6189 use_graph_arm: bool,
6190 alpha: f64,
6191 pool_n: usize,
6192 explain: bool,
6193 view: &ReadView,
6194 ) -> Result<SearchResult, EngineError> {
6195 self.search_reranked_with_explain(
6196 query,
6197 filter,
6198 rerank_depth,
6199 use_graph_arm,
6200 alpha,
6201 pool_n,
6202 explain,
6203 *view,
6204 )
6205 }
6206
6207 /// G10 — hybrid `search` with an optional closed [`SearchFilter`]. `None`
6208 /// (or an all-`None` filter) is the unfiltered path whose phase-1 SQL is
6209 /// byte-identical to 0.7.2. The filter prunes the vector branch in the
6210 /// single phase-1 candidates statement and constrains the text branch by the
6211 /// same metadata. Ranking is the unconditional G9 RRF fusion.
6212 pub fn search_filtered(
6213 &self,
6214 query: &str,
6215 filter: Option<SearchFilter>,
6216 ) -> Result<SearchResult, EngineError> {
6217 // 0.8.11 Slice 40 (R-FIL-2): re-express the shipped G10 `SearchFilter`
6218 // sugar through the unified `Filter` type, then lower back to the vec0
6219 // backend's `SearchFilter` (D4). The round-trip is lossless +
6220 // canonical-order-preserving, so the produced phase-1 SQL stays
6221 // byte-identical to 0.7.2 on the `None`/all-`None` path. `SearchFilter`
6222 // never carries a `Json` term, so `to_search_filter` never rejects here.
6223 //
6224 // 0.8.20 Slice 15e — the unified `Filter`/`FilterTerm` grammar does not yet
6225 // carry `filterable`-attribute terms (a later slice adds that surface), so
6226 // the round-trip would drop `SearchFilter.attributes`. Carry them across
6227 // explicitly: they already route pre-KNN through `vector_filter_clause`.
6228 let lowered = filter
6229 .map(|mut sf| {
6230 let attributes = sf.attributes.clone();
6231 // Attribute predicates are intentionally absent from the unified
6232 // grammar, but this legacy/hybrid entry point owns their existing
6233 // pre-KNN lowering. Remove them only for the metadata round-trip,
6234 // then restore them on its `SearchFilter` output.
6235 sf.attributes.clear();
6236 Filter::try_from(&sf).and_then(|filter| {
6237 filter.to_search_filter().map(|mut lo| {
6238 lo.attributes = attributes;
6239 lo
6240 })
6241 })
6242 })
6243 .transpose()?;
6244 // FIX-6: delegate to search_reranked(depth=0, use_graph_arm=false) to eliminate the
6245 // ~26-line duplicate body that would otherwise drift with search_reranked.
6246 // 0.8.5: depth=0 is inert, so the α/pool_n defaults (0.3, 0) never reach the blend.
6247 self.search_reranked(query, lowered, 0, false, 0.3, 0)
6248 }
6249
6250 /// 0.8.11 Slice 40 (#17) — unified-`Filter` entry point for the vec0 search
6251 /// backend. Lowers the metadata subset to the indexed pre-KNN `WHERE` and
6252 /// **typed-rejects** a [`FilterTerm::Json`] term with
6253 /// [`EngineError::InvalidFilter`] (D3 no-demotion guarantee). This is the
6254 /// unified surface the 0.8.15 router `constraints` block reasons over; the
6255 /// shipped [`Engine::search_filtered`]`(query, Option<SearchFilter>)` stays
6256 /// as sugar over the same path.
6257 pub fn search_filter(&self, query: &str, filter: &Filter) -> Result<SearchResult, EngineError> {
6258 let sf = filter.to_search_filter()?;
6259 self.search_reranked(query, Some(sf), 0, false, 0.3, 0)
6260 }
6261
6262 /// 0.8.1 Slice 10 (R1) / Slice 30 (R3) — `search_reranked`: hybrid search
6263 /// with optional CE reranking and optional graph-BFS third arm. `rerank_depth
6264 /// = 0` is the identity (soft-fallback) path, byte-identical to
6265 /// [`search_filtered`][Engine::search_filtered]. `rerank_depth = N > 0`
6266 /// applies the cross-encoder over the top-N fused hits (when the
6267 /// `default-reranker` feature is enabled and the model is loaded); without the
6268 /// model, the call falls back to the fused order.
6269 ///
6270 /// `use_graph_arm = false` (the default) produces byte-identical results to
6271 /// the pre-Slice-30 two-arm pipeline. `use_graph_arm = true` seeds a BFS over
6272 /// temporal fact-edges from the top-10 fused hits and fuses the reachable
6273 /// nodes as a third RRF arm.
6274 ///
6275 /// Governed surface: re-exported from `fathomdb` facade.
6276 pub fn search_reranked(
6277 &self,
6278 query: &str,
6279 filter: Option<SearchFilter>,
6280 rerank_depth: usize,
6281 use_graph_arm: bool,
6282 alpha: f64,
6283 pool_n: usize,
6284 ) -> Result<SearchResult, EngineError> {
6285 // explain=false → `SearchResult.explanation == None`, byte-identical results.
6286 self.search_reranked_with_explain(
6287 query,
6288 filter,
6289 rerank_depth,
6290 use_graph_arm,
6291 alpha,
6292 pool_n,
6293 false,
6294 ReadView::default(),
6295 )
6296 }
6297
6298 /// 0.8.8 EXP-OBS (Slice 5) — `search_explained`: the opt-in `explain=true`
6299 /// surface. Identical retrieval to [`search_reranked`][Engine::search_reranked]
6300 /// (same fused/CE ranking, same `results`), additionally returning a
6301 /// [`Explanation`] sidecar on `SearchResult.explanation` with per-hit arm
6302 /// provenance + score breakdown + a query-level [`QueryTrace`]. The default
6303 /// `search`/`search_filtered`/`search_reranked` paths are unaffected and stay
6304 /// byte-identical (R-OBS-2).
6305 ///
6306 /// Governed surface: re-exported from `fathomdb` facade.
6307 pub fn search_explained(
6308 &self,
6309 query: &str,
6310 filter: Option<SearchFilter>,
6311 rerank_depth: usize,
6312 use_graph_arm: bool,
6313 alpha: f64,
6314 pool_n: usize,
6315 ) -> Result<SearchResult, EngineError> {
6316 self.search_reranked_with_explain(
6317 query,
6318 filter,
6319 rerank_depth,
6320 use_graph_arm,
6321 alpha,
6322 pool_n,
6323 true,
6324 ReadView::default(),
6325 )
6326 }
6327
6328 /// 0.8.18 Slice 5 (#5 vector-equivalence probe, R-VEQ-4) — the explicit
6329 /// **text-only / FTS-only** search path. It does NOT embed the query and does
6330 /// NOT route through the vector-dependent choke point
6331 /// [`search_inner_with_stats`][Engine::search_inner_with_stats], so it NEVER
6332 /// raises [`EngineError::VectorEquivalenceMismatch`] and stays serviceable when
6333 /// the engine opened in the degraded `dense_disabled` state (the D2 "keep FTS
6334 /// servable" contract; codex R2 U1-2). Results come from the node-body FTS
6335 /// branch only — no vector recall, no CE rerank, no graph arm. Available
6336 /// regardless of degraded state; when dense is healthy it is simply a
6337 /// text-only view of the same corpus.
6338 ///
6339 /// Governed surface: re-exported from the `fathomdb` facade + Py/TS bindings.
6340 pub fn search_text_only(&self, query: &str) -> Result<SearchResult, EngineError> {
6341 self.search_text_only_view(query, &ReadView::default())
6342 }
6343
6344 /// 0.8.20 Slice 15b fix-2 (R-20-NV / R-20-RV) — [`search_text_only`][Engine::search_text_only]
6345 /// under an explicit [`ReadView`]. Same validity-axis-only scope, and the same
6346 /// typed refusal of the existence flags, as [`search_view`][Engine::search_view].
6347 ///
6348 /// Governed surface: PROPOSED / NOT SIGNED (0.8.20 Slice 15b fix-2).
6349 pub fn search_text_only_view(
6350 &self,
6351 query: &str,
6352 view: &ReadView,
6353 ) -> Result<SearchResult, EngineError> {
6354 self.ensure_open()?;
6355 view.reject_existence_relaxation_on_search()?;
6356 if query.trim().is_empty() {
6357 return Err(EngineError::WriteValidation);
6358 }
6359 let compiled = compile_text_query(query);
6360 let search_limit = self
6361 .projection_runtime
6362 .shared
6363 .search_limit_override
6364 .load(Ordering::SeqCst)
6365 .max(SEARCH_RERANK_LIMIT);
6366 let (response_tx, response_rx) = mpsc::sync_channel::<ReaderResponse>(1);
6367 // `query_vector = None` ⇒ `read_search_in_tx` skips the vector branch
6368 // entirely (no embed, no phase-1 bit-KNN, no phase-2 L2) and returns the
6369 // text/FTS branch — exactly the un-embedded fallback the hybrid path already
6370 // takes on an embed miss.
6371 let request = ReaderRequest::Search {
6372 compiled,
6373 query_vector: None,
6374 query_vector_bin: None,
6375 search_limit,
6376 filter: None,
6377 recency_enabled: false,
6378 importance_enabled: false,
6379 vector_stage_only: false,
6380 raw_query: Box::from(query),
6381 rerank_depth: 0,
6382 use_graph_arm: false,
6383 alpha: 0.3,
6384 pool_n: 0,
6385 explain: false,
6386 view: *view,
6387 respond: response_tx,
6388 };
6389 if self.reader_pool.dispatch(request).is_err() {
6390 return Err(EngineError::Closing);
6391 }
6392 let search_result = response_rx.recv().map_err(|_| EngineError::Storage)?;
6393 let (cursor, soft_fallback, results, _graph_stats, explanation) = match search_result {
6394 Ok(result) => result,
6395 // fix-3 (codex §9 [P2]) — carry the reader-snapshot validation verdict
6396 // through: an undeclared `filterable` attribute is the EXISTING typed
6397 // `InvalidFilter`, never collapsed to `Storage`. (This path takes
6398 // `filter = None`, so it never fires here, but the match stays total.)
6399 Err(SearchReaderError::InvalidFilter(reason)) => {
6400 return Err(EngineError::InvalidFilter { reason });
6401 }
6402 Err(SearchReaderError::Sqlite(err)) => {
6403 self.emit_sqlite_internal_error(&err);
6404 return Err(EngineError::Storage);
6405 }
6406 };
6407 Ok(SearchResult { projection_cursor: cursor, soft_fallback, results, explanation })
6408 }
6409
6410 /// Search one declared `searchable→FTS` projection without invoking body
6411 /// search, vector search, score fusion, or a fallback arm. Results carry the
6412 /// ordinary text branch shape and are ordered by property-FTS bm25 ascending
6413 /// then write cursor ascending.
6414 pub fn search_projected_text(
6415 &self,
6416 query: &str,
6417 name: &str,
6418 filter: Option<SearchFilter>,
6419 view: &ReadView,
6420 ) -> Result<SearchResult, EngineError> {
6421 self.ensure_open()?;
6422 view.reject_existence_relaxation_on_search()?;
6423 if query.trim().is_empty() {
6424 return Err(EngineError::WriteValidation);
6425 }
6426
6427 let limit = self
6428 .projection_runtime
6429 .shared
6430 .search_limit_override
6431 .load(Ordering::SeqCst)
6432 .max(SEARCH_RERANK_LIMIT);
6433 let (response_tx, response_rx) = mpsc::sync_channel(1);
6434 let request = ReaderRequest::SearchProjectedText {
6435 query: query.to_string(),
6436 name: name.to_string(),
6437 filter: filter.map(Box::new),
6438 limit,
6439 view: *view,
6440 respond: response_tx,
6441 };
6442 if self.reader_pool.dispatch(request).is_err() {
6443 return Err(EngineError::Closing);
6444 }
6445 match response_rx.recv().map_err(|_| EngineError::Storage)? {
6446 Ok(result) => Ok(result),
6447 Err(SearchReaderError::InvalidFilter(reason)) => {
6448 Err(EngineError::InvalidFilter { reason })
6449 }
6450 Err(SearchReaderError::Sqlite(err)) => {
6451 self.emit_sqlite_internal_error(&err);
6452 Err(EngineError::Storage)
6453 }
6454 }
6455 }
6456
6457 /// 0.8.18 Slice 5 (R-VEQ-6) — degraded-open observability accessor. `true` iff
6458 /// the open-time #5 self-check found a vector-equivalence divergence and every
6459 /// vector-dependent arm is refusing. Mirrors `OpenReport.dense_disabled`; read
6460 /// lock-free.
6461 #[must_use]
6462 pub fn dense_disabled(&self) -> bool {
6463 self.dense_disabled.load(Ordering::Acquire)
6464 }
6465
6466 /// 0.8.18 Slice 5 (R-VEQ-6) — the human-readable reason for the degraded state
6467 /// (which representation tripped), or `None` when dense is healthy.
6468 #[must_use]
6469 pub fn dense_disabled_reason(&self) -> Option<String> {
6470 self.dense_disabled_reason.lock().ok().and_then(|g| g.clone())
6471 }
6472
6473 /// 0.8.18 Slice 5 (R-VEQ-6) — telemetry counter: number of query-time
6474 /// vector-dependent-arm refusals raised because the engine opened degraded.
6475 /// Observable pre/post-query.
6476 #[must_use]
6477 pub fn vector_equivalence_refusal_count(&self) -> u64 {
6478 self.vector_equivalence_refusals.load(Ordering::Relaxed)
6479 }
6480
6481 /// Shared event-wrapped body for [`search_reranked`][Engine::search_reranked]
6482 /// (`explain=false`) and [`search_explained`][Engine::search_explained]
6483 /// (`explain=true`). Keeps the Started/Finished/Failed lifecycle emissions +
6484 /// slow detection in one place.
6485 #[allow(clippy::too_many_arguments)] // mirrors search_reranked + the explain flag
6486 fn search_reranked_with_explain(
6487 &self,
6488 query: &str,
6489 filter: Option<SearchFilter>,
6490 rerank_depth: usize,
6491 use_graph_arm: bool,
6492 alpha: f64,
6493 pool_n: usize,
6494 explain: bool,
6495 view: ReadView,
6496 ) -> Result<SearchResult, EngineError> {
6497 // fix-2: refuse an existence-relaxing view BEFORE any work (and before the
6498 // Started event), so the refusal is a pure argument error rather than a
6499 // half-emitted query lifecycle.
6500 view.reject_existence_relaxation_on_search()?;
6501 self.emit_event(lifecycle::Phase::Started, lifecycle::EventCategory::Search, None);
6502 let started = Instant::now();
6503 let outcome = self.search_inner(
6504 query,
6505 filter,
6506 rerank_depth,
6507 use_graph_arm,
6508 alpha,
6509 pool_n,
6510 explain,
6511 view,
6512 );
6513 self.detect_slow(started, lifecycle::EventCategory::Search);
6514 match outcome {
6515 Ok(result) => {
6516 self.counters.record_query();
6517 // 0.8.8 Slice 15 (OPP-9) — opt-in telemetry capture. No-op + no
6518 // allocation when telemetry is OFF (the default).
6519 self.capture_telemetry(query, &result);
6520 self.emit_event(lifecycle::Phase::Finished, lifecycle::EventCategory::Search, None);
6521 Ok(result)
6522 }
6523 Err(err) => {
6524 let code = err.stable_code();
6525 self.counters.record_error(code);
6526 self.emit_event(
6527 lifecycle::Phase::Failed,
6528 lifecycle::EventCategory::Search,
6529 Some(code),
6530 );
6531 self.emit_event(
6532 lifecycle::Phase::Failed,
6533 lifecycle::EventCategory::Error,
6534 Some(code),
6535 );
6536 Err(err)
6537 }
6538 }
6539 }
6540
6541 /// 0.8.8 Slice 15 (OPP-9) — enable opt-in telemetry capture to a local JSONL
6542 /// `sink_path` (append-only). Off by default; once enabled, each `search`
6543 /// records a query→result event and `record_feedback` appends agent labels.
6544 /// Local file only — no network/egress. `query_id` + `ts_monotonic_ms` are
6545 /// reset deterministically on enable. Idempotent re-enable resets the seq.
6546 pub fn enable_telemetry(&self, sink_path: &str) -> Result<(), EngineError> {
6547 // Touch the sink (create + validate writable) before arming capture, so a
6548 // bad path fails loudly here rather than silently dropping events.
6549 std::fs::OpenOptions::new()
6550 .create(true)
6551 .append(true)
6552 .open(sink_path)
6553 .map_err(|_| EngineError::Storage)?;
6554 let mut guard = self.telemetry.lock().map_err(|_| EngineError::Storage)?;
6555 *guard = Some(TelemetrySink {
6556 path: PathBuf::from(sink_path),
6557 base: Instant::now(),
6558 nonce: 0,
6559 seq: 0,
6560 last_query_id: None,
6561 });
6562 // Arm the fast OFF-path guard LAST (after the sink is installed) so a
6563 // concurrent search either sees telemetry fully off or fully on.
6564 self.telemetry_enabled.store(true, Ordering::Release);
6565 Ok(())
6566 }
6567
6568 /// 0.8.8 Slice 15 — the most-recent captured `query_id` (for `record_feedback`).
6569 /// `None` when telemetry is off or no query has been captured yet.
6570 pub fn last_telemetry_query_id(&self) -> Option<String> {
6571 self.telemetry.lock().ok()?.as_ref().and_then(|s| s.last_query_id.clone())
6572 }
6573
6574 /// 0.8.8 Slice 15 — capture a query→result telemetry event. No-op (no alloc,
6575 /// no I/O) when telemetry is off (the default). Best-effort: a sink write error
6576 /// never fails the search. Captures ONLY ids, arms, and the query LENGTH —
6577 /// never the query text or `source_id` (privacy, ADR §C).
6578 ///
6579 /// ID-SPACES (Cause-A, 0.8.11.2 — honest record). `result_ids` is the interim
6580 /// `SearchHit.id` == `write_cursor`: within-session consistent but NOT
6581 /// cross-session-stable (reassigned on re-projection/re-ingest). `arm_of` is
6582 /// keyed by that same `write_cursor`. Cause-A adds a NEW PARALLEL field
6583 /// `result_stable_ids` carrying the cross-session-stable id
6584 /// ([`SearchHit::stable_id`], `logical_id` / content-hash) in the SAME order as
6585 /// `result_ids`; the existing `write_cursor` keys are RETAINED unchanged so
6586 /// pre-Cause-A gold and sink byte-output stay valid (the F-8a `id_space` flip
6587 /// is a separate, conscious step — see
6588 /// `dev/plans/runs/NOTE-0.8.8-to-steward-id-contract.md`).
6589 fn capture_telemetry(&self, query: &str, result: &SearchResult) {
6590 // Fast OFF path (codex §9 P2): a single atomic load when telemetry has
6591 // never been enabled — NO mutex acquisition, NO contention with the search
6592 // hot path.
6593 if !self.telemetry_enabled.load(Ordering::Acquire) {
6594 return;
6595 }
6596 let Ok(mut guard) = self.telemetry.lock() else { return };
6597 let Some(sink) = guard.as_mut() else { return };
6598 let query_id = format!("q{}-{}", sink.nonce, sink.seq);
6599 let ts_monotonic_ms = sink.base.elapsed().as_millis() as u64;
6600 let mut arm_of = serde_json::Map::new();
6601 for h in &result.results {
6602 // Keyed on the engine-internal positional cursor (the pre-C-2
6603 // `SearchHit.id` == `write_cursor`), byte-unchanged so `record_feedback`
6604 // + the gold pipeline keep keying on the same `result_ids` space.
6605 arm_of
6606 .insert(h.write_cursor.to_string(), serde_json::Value::from(branch_str(h.branch)));
6607 }
6608 let event = serde_json::json!({
6609 "type": "event",
6610 "schema_version": 1,
6611 "ts_monotonic_ms": ts_monotonic_ms,
6612 "query_id": query_id,
6613 "query_chars": query.chars().count() as u64,
6614 "result_ids": result.results.iter().map(|h| h.write_cursor).collect::<Vec<u64>>(),
6615 // Cause-A / C-2: parallel cross-session-stable ids, SAME order as
6616 // result_ids. Post-C-2 the stable id lives on `SearchHit.id` (its
6617 // prefixed form == the pre-swap `stable_id` value byte-for-byte), so
6618 // the emitted bytes are unchanged and the `write_cursor` result_ids
6619 // keys are retained unchanged (pre-Cause-A gold stays valid).
6620 "result_stable_ids": result
6621 .results
6622 .iter()
6623 .map(|h| h.id.to_prefixed())
6624 .collect::<Vec<String>>(),
6625 "arm_of": arm_of,
6626 });
6627 let _ = append_jsonl(&sink.path, &event);
6628 sink.seq += 1;
6629 sink.last_query_id = Some(query_id);
6630 }
6631
6632 /// 0.8.8 Slice 15 — append an agent-supplied relevance-label record for a
6633 /// previously-captured `query_id`. `label_source` is the only exogenous string
6634 /// (caller-declared, e.g. `"agent:hermes"`).
6635 ///
6636 /// ID-SPACE (Cause-A, 0.8.11.2 — honest record). `relevant_ids` /
6637 /// `irrelevant_ids` are the interim `SearchHit.id` == `write_cursor` (the same
6638 /// space as the captured event's `result_ids`), NOT `logical_id`. The
6639 /// signature is left byte-stable: the gold pipeline maps these `write_cursor`
6640 /// keys to the cross-session-stable id via the capture event's parallel
6641 /// `result_ids` ↔ `result_stable_ids` arrays (`eval/gold_capture.py`), so no
6642 /// new feedback parameter — and no binding-signature churn — is required.
6643 /// Errors if telemetry is off.
6644 pub fn record_feedback(
6645 &self,
6646 query_id: &str,
6647 relevant_ids: &[u64],
6648 irrelevant_ids: &[u64],
6649 label_source: &str,
6650 ) -> Result<(), EngineError> {
6651 let guard = self.telemetry.lock().map_err(|_| EngineError::Storage)?;
6652 let sink = guard
6653 .as_ref()
6654 .ok_or(EngineError::InvalidArgument { msg: "telemetry is not enabled".to_string() })?;
6655 // codex §9 [P1] (privacy): `query_id` is an exogenous caller string. Only a
6656 // deterministic id that `capture_telemetry` has ALREADY emitted may be
6657 // persisted — otherwise a caller could smuggle query text / a `source_id`
6658 // into the sink under the `query_id` key. Require the canonical
6659 // `q{nonce}-{seq}` form with `nonce == sink.nonce` AND `seq < sink.seq`
6660 // (a seq the capture path has issued). Reject (writing nothing) otherwise.
6661 let is_issued_id = query_id
6662 .strip_prefix('q')
6663 .and_then(|rest| rest.split_once('-'))
6664 .and_then(|(nonce, seq)| Some((nonce.parse::<u64>().ok()?, seq.parse::<u64>().ok()?)))
6665 .is_some_and(|(nonce, seq)| nonce == sink.nonce && seq < sink.seq);
6666 if !is_issued_id {
6667 return Err(EngineError::InvalidArgument { msg: "unknown query_id".to_string() });
6668 }
6669 let record = serde_json::json!({
6670 "type": "feedback",
6671 "schema_version": 1,
6672 "query_id": query_id,
6673 "relevant_ids": relevant_ids,
6674 "irrelevant_ids": irrelevant_ids,
6675 "label_source": label_source,
6676 });
6677 append_jsonl(&sink.path, &record).map_err(|_| EngineError::Storage)
6678 }
6679
6680 fn detect_slow(&self, started: Instant, category: lifecycle::EventCategory) {
6681 let elapsed = started.elapsed();
6682 let threshold = self.slow_threshold_ms.load(Ordering::Relaxed);
6683 let threshold_duration = std::time::Duration::from_millis(threshold);
6684 if elapsed > threshold_duration {
6685 // `dev/design/lifecycle.md` § Slow and heartbeat policy: a slow
6686 // operation produces TWO correlated facts. The
6687 // statement-level slow-statement signal is dispatched by the
6688 // sqlite3_profile callback (`profile_callback_trampoline`).
6689 // This site emits the lifecycle `Phase::Slow` event for the
6690 // outer operation envelope (AC-008).
6691 self.emit_event(lifecycle::Phase::Slow, category, None);
6692 }
6693 }
6694
6695 fn emit_event(
6696 &self,
6697 phase: lifecycle::Phase,
6698 category: lifecycle::EventCategory,
6699 code: Option<&'static str>,
6700 ) {
6701 let event =
6702 lifecycle::Event { phase, source: lifecycle::EventSource::Engine, category, code };
6703 self.subscribers.dispatch(&event);
6704 }
6705
6706 /// Emit a `(SqliteInternal, Error, code: <SQLITE_*>)` lifecycle
6707 /// event for a rusqlite error. Per `dev/design/lifecycle.md`
6708 /// § Diagnostic source and category, SQLite-originated diagnostics
6709 /// route through the same host subscriber as engine-originated
6710 /// events with `source` preserved. AC-021 dispatches on
6711 /// `code == "SQLITE_SCHEMA"`.
6712 fn emit_sqlite_internal_error(&self, err: &rusqlite::Error) {
6713 if let Some(code) = sqlite_extended_code_name(err) {
6714 let event = lifecycle::Event {
6715 phase: lifecycle::Phase::Failed,
6716 source: lifecycle::EventSource::SqliteInternal,
6717 category: lifecycle::EventCategory::Error,
6718 code: Some(code),
6719 };
6720 self.subscribers.dispatch(&event);
6721 }
6722 }
6723
6724 /// Thin wrapper: the production search path that discards the G0 Phase-2
6725 /// frontier meter (it never reaches `SearchResult` / the governed surface).
6726 #[allow(clippy::too_many_arguments)] // mirrors search_reranked + the explain flag
6727 fn search_inner(
6728 &self,
6729 query: &str,
6730 filter: Option<SearchFilter>,
6731 rerank_depth: usize,
6732 use_graph_arm: bool,
6733 alpha: f64,
6734 pool_n: usize,
6735 explain: bool,
6736 view: ReadView,
6737 ) -> Result<SearchResult, EngineError> {
6738 self.search_inner_with_stats(
6739 query,
6740 filter,
6741 rerank_depth,
6742 use_graph_arm,
6743 alpha,
6744 pool_n,
6745 explain,
6746 view,
6747 )
6748 .map(|(result, _stats)| result)
6749 }
6750
6751 /// G0 Phase-2: the search body, additionally returning the graph-arm frontier
6752 /// meter. Only the `_graph_frontier_stats_for_test` seam consumes the stats;
6753 /// `search_inner` (and thus `search_reranked` / `search`) drops them.
6754 #[allow(clippy::too_many_arguments)] // mirrors search_reranked + the explain flag
6755 fn search_inner_with_stats(
6756 &self,
6757 query: &str,
6758 filter: Option<SearchFilter>,
6759 rerank_depth: usize,
6760 use_graph_arm: bool,
6761 alpha: f64,
6762 pool_n: usize,
6763 explain: bool,
6764 view: ReadView,
6765 ) -> Result<(SearchResult, GraphFrontierStats), EngineError> {
6766 self.ensure_open()?;
6767 // 0.8.18 Slice 5 (#5 vector-equivalence probe, R-VEQ-4) — the SINGLE
6768 // vector-dependent choke point. If the open-time self-check found a
6769 // divergence beyond the D4 floor, refuse EVERY vector-dependent arm
6770 // (search / search_expand / explain-rerank / graph-arm all funnel here)
6771 // BEFORE any embedding / vector SQL / graph seeding / CE rerank — no
6772 // silent partial results. The text-only/FTS-only path
6773 // (`search_text_only`) does NOT route through here, so FTS stays
6774 // serviceable in degraded mode.
6775 if self.dense_disabled.load(Ordering::Acquire) {
6776 self.vector_equivalence_refusals.fetch_add(1, Ordering::Relaxed);
6777 let reason =
6778 self.dense_disabled_reason.lock().ok().and_then(|g| g.clone()).unwrap_or_else(
6779 || "open-time #5 vector-equivalence self-check failed".to_string(),
6780 );
6781 return Err(EngineError::VectorEquivalenceMismatch { reason });
6782 }
6783 if query.trim().is_empty() {
6784 return Err(EngineError::WriteValidation);
6785 }
6786
6787 // 0.8.20 Slice 15e fix-2 finding 1 [P2] — every filter attribute name is
6788 // validated against the declared `filterable` registry set BEFORE any arm
6789 // runs, so an UNDECLARED name is a typed `InvalidFilter` rejection instead
6790 // of an opaque `no such column` `Storage` crash (vector arm) or a silent
6791 // no-match (FTS arm). ADR-0.8.11 D3: every filter term has a DEFINED outcome
6792 // ("compiles" or "typed rejection") IDENTICAL across arms.
6793 //
6794 // keystone closeout fix-3 (codex §9 [P2], TOCTOU): that validation is NO
6795 // LONGER performed here on `self.connection` before dispatch. fix-2 checked
6796 // the registry on the WRITER connection and then let the reader prepare the
6797 // vec0 query on a DIFFERENT connection/snapshot — a `configure_projections`
6798 // DROP landing in the window between the check and the reader snapshot could
6799 // still make the `attr_<hex>` column vanish AFTER validation passed, i.e. the
6800 // exact untyped `Storage` failure fix-2 meant to prevent. The check now runs
6801 // INSIDE the reader's deferred transaction (see
6802 // `validate_filter_attributes_on_snapshot`, called from `read_search_in_tx`),
6803 // so the registry it reads and the vec0 columns the query compiles against are
6804 // ONE snapshot — the race is closed and BOTH arms still see the same typed
6805 // `InvalidFilter`. Moving it there also removes a per-filtered-search writer
6806 // lock and the fix-2 concurrent-ADD false-reject (the reader snapshot sees a
6807 // freshly-added declaration and accepts).
6808 let compiled = compile_text_query(query);
6809 // REQ-013 / AC-059b / REQ-055: the cursor returned with a search
6810 // MUST be derived from the same WAL snapshot the data was read
6811 // from. Loading `next_cursor` from the writer-side atomic before
6812 // the reader transaction acquires its snapshot races against
6813 // concurrent writers — see `dev/design/engine.md` § Cursor
6814 // contract. Run cursor probe + body query inside one read tx
6815 // (BEGIN DEFERRED on a `query_only=ON` connection in WAL mode is
6816 // a snapshot-stable read).
6817 // EU-5a2 mean-centering apply path (query side). `query_vector`
6818 // is ALWAYS un-centered (used by the f32 vec_distance_l2 rerank
6819 // in phase 2). `query_vector_bin` is the (possibly centered) f32
6820 // fed to `vec_quantize_binary` in phase 1. The centering decision
6821 // mirrors the write path: identity must be MC-required AND a
6822 // mean_vec must be pinned. NoopEmbedder collapses to
6823 // `query_vector_bin == query_vector` until EU-5b.
6824 let raw_query_vector =
6825 self.runtime_embedder.as_ref().and_then(|embedder| embedder.embed(query).ok());
6826 let query_vector_bin = match raw_query_vector.as_ref() {
6827 Some(vector) if identity_requires_mean_centering(&self.runtime_embedder_identity) => {
6828 let pinned = {
6829 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
6830 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
6831 read_pinned_mean_vec(connection, self.runtime_embedder_identity.dimension)?
6832 };
6833 match pinned {
6834 Some(mean) => serde_json::to_string(&subtract_mean(vector, &mean)).ok(),
6835 None => serde_json::to_string(vector).ok(),
6836 }
6837 }
6838 Some(vector) => serde_json::to_string(vector).ok(),
6839 None => None,
6840 };
6841 let query_vector = raw_query_vector.and_then(|vector| serde_json::to_string(&vector).ok());
6842 // 0.7.2 PR-2bc S1 fix-1 — phase-2 rerank LIMIT. Production default is
6843 // `SEARCH_RERANK_LIMIT` (10); the test seam may RAISE it, clamped to
6844 // the production floor so a test can never shrink search semantics.
6845 let search_limit = self
6846 .projection_runtime
6847 .shared
6848 .search_limit_override
6849 .load(Ordering::SeqCst)
6850 .max(SEARCH_RERANK_LIMIT);
6851 let recency_enabled =
6852 self.projection_runtime.shared.recency_reweight_enabled.load(Ordering::SeqCst);
6853 let importance_enabled =
6854 self.projection_runtime.shared.importance_reweight_enabled.load(Ordering::SeqCst);
6855 let vector_stage_only =
6856 self.projection_runtime.shared.vector_stage_only_for_test.load(Ordering::SeqCst);
6857 let (response_tx, response_rx) = mpsc::sync_channel::<ReaderResponse>(1);
6858 let request = ReaderRequest::Search {
6859 compiled,
6860 query_vector,
6861 query_vector_bin,
6862 search_limit,
6863 filter: filter.map(Box::new),
6864 recency_enabled,
6865 importance_enabled,
6866 vector_stage_only,
6867 raw_query: Box::from(query), // FIX-4: Box<str> (16B) not String (24B)
6868 rerank_depth,
6869 use_graph_arm,
6870 alpha,
6871 pool_n,
6872 explain,
6873 view,
6874 respond: response_tx,
6875 };
6876 if self.reader_pool.dispatch(request).is_err() {
6877 return Err(EngineError::Closing);
6878 }
6879 let search_result = response_rx.recv().map_err(|_| EngineError::Storage)?;
6880 let (cursor, soft_fallback, results, graph_stats, explanation) = match search_result {
6881 Ok(result) => result,
6882 // fix-3 (codex §9 [P2]) — an undeclared `filterable` attribute is caught
6883 // on the reader's OWN snapshot (validate + vec0 query = one transaction),
6884 // so it surfaces as the EXISTING typed `InvalidFilter` and can never be
6885 // the opaque `no such column` `Storage` error the TOCTOU race produced.
6886 Err(SearchReaderError::InvalidFilter(reason)) => {
6887 return Err(EngineError::InvalidFilter { reason });
6888 }
6889 Err(SearchReaderError::Sqlite(err)) => {
6890 self.emit_sqlite_internal_error(&err);
6891 return Err(EngineError::Storage);
6892 }
6893 };
6894
6895 // The worker (`read_search_in_tx`) has no embedder identity; fill the
6896 // trace's `embedder_id` here, where `self.runtime_embedder_identity` is in
6897 // scope. Only on the explain path (`explanation` is `Some`).
6898 let explanation = explanation.map(|mut exp| {
6899 let id = &self.runtime_embedder_identity;
6900 exp.trace.embedder_id = format!("{}@{} (dim={})", id.name, id.revision, id.dimension);
6901 exp
6902 });
6903
6904 Ok((
6905 SearchResult { projection_cursor: cursor, soft_fallback, results, explanation },
6906 graph_stats,
6907 ))
6908 }
6909
6910 /// G0 Phase-2 (BLOCK-1) test seam — runs the graph-arm retrieval path and
6911 /// returns the frontier meter (`GraphFrontierStats`) for `query`. Mirrors the
6912 /// sanctioned `set_vector_stage_only_for_test` / `_configure_vector_kind_for_test`
6913 /// pattern: kept OFF the governed surface (test/eval-only), so the meter never
6914 /// appears on `SearchResult`. Used by the recall harness to prove the
6915 /// doc-seeded frontier is empty (`resolved_seed_rate == 0.0`) and, post-C1, the
6916 /// 0→>0 flip.
6917 pub fn _graph_frontier_stats_for_test(
6918 &self,
6919 query: &str,
6920 ) -> Result<GraphFrontierStats, EngineError> {
6921 self.search_inner_with_stats(query, None, 0, true, 0.3, 0, false, ReadView::default())
6922 .map(|(_result, stats)| stats)
6923 }
6924
6925 /// Slice 30 (G2) — `read.get`: active-only point lookup by `logical_id`.
6926 /// Delegates to [`Engine::read_get_many`]; returns the single slot. A
6927 /// missing/superseded id is `None` (a normal absence, not an error). Reads
6928 /// ride the ReaderWorkerPool DEFERRED-tx path (never the writer lock).
6929 pub fn read_get(
6930 &self,
6931 logical_id: &str,
6932 view: &ReadView,
6933 ) -> Result<Option<NodeRecord>, EngineError> {
6934 let ids = [logical_id.to_string()];
6935 let rows = self.read_get_many(&ids, view)?;
6936 Ok(rows.into_iter().next().flatten())
6937 }
6938
6939 /// Slice 30 (G2) — `read.get_many`: active-only point lookup over many
6940 /// `logical_id`s. Returns one slot per requested id in REQUEST ORDER, `None`
6941 /// where no active row carries that id (partial, never all-or-nothing).
6942 pub fn read_get_many(
6943 &self,
6944 logical_ids: &[String],
6945 view: &ReadView,
6946 ) -> Result<Vec<Option<NodeRecord>>, EngineError> {
6947 self.ensure_open()?;
6948 if logical_ids.is_empty() {
6949 return Ok(Vec::new());
6950 }
6951 let (response_tx, response_rx) = mpsc::sync_channel(1);
6952 let request = ReaderRequest::GetById {
6953 logical_ids: logical_ids.to_vec(),
6954 view: *view,
6955 respond: response_tx,
6956 };
6957 if self.reader_pool.dispatch(request).is_err() {
6958 return Err(EngineError::Closing);
6959 }
6960 match response_rx.recv().map_err(|_| EngineError::Storage)? {
6961 Ok(rows) => Ok(rows),
6962 Err(err) => {
6963 self.emit_sqlite_internal_error(&err);
6964 Err(EngineError::Storage)
6965 }
6966 }
6967 }
6968
6969 /// Slice 20 (G5) — `read.neighbors`: bounded BFS from `root_logical_id`
6970 /// over `canonical_edges`. Returns nodes reachable within `depth` hops
6971 /// (`1..=3`) in the given `direction`, excluding the root itself.
6972 ///
6973 /// Hard cap: 50 results (engine-enforced `LIMIT 50`).
6974 /// Traversal filter: `superseded_at IS NULL AND (t_invalid IS NULL OR t_invalid > now)`.
6975 ///
6976 /// Returns `Err(EngineError::InvalidArgument)` for `depth > 3`.
6977 /// Returns `Ok(vec![])` for an unknown/superseded root.
6978 /// Reads ride the `ReaderWorkerPool` DEFERRED-tx path.
6979 pub fn graph_neighbors(
6980 &self,
6981 root_logical_id: &str,
6982 depth: u32,
6983 direction: TraversalDirection,
6984 view: &ReadView,
6985 ) -> Result<Vec<NodeRecord>, EngineError> {
6986 self.ensure_open()?;
6987 if depth == 0 || depth > 3 {
6988 return Err(EngineError::InvalidArgument {
6989 msg: format!("traversal depth {depth} is out of range; must be 1, 2, or 3"),
6990 });
6991 }
6992 let (response_tx, response_rx) = mpsc::sync_channel(1);
6993 let request = ReaderRequest::GraphNeighbors {
6994 root_logical_id: root_logical_id.to_string(),
6995 depth,
6996 direction,
6997 view: *view,
6998 respond: response_tx,
6999 };
7000 if self.reader_pool.dispatch(request).is_err() {
7001 return Err(EngineError::Closing);
7002 }
7003 match response_rx.recv().map_err(|_| EngineError::Storage)? {
7004 Ok(nodes) => Ok(nodes),
7005 Err(err) => {
7006 self.emit_sqlite_internal_error(&err);
7007 Err(EngineError::Storage)
7008 }
7009 }
7010 }
7011
7012 /// Slice 20 (G6) — `search_expand`: hybrid search (`G1+G9`) followed by
7013 /// bounded BFS expansion (`G5`) of each search hit. Returns the original
7014 /// search hits (with RRF scores) plus nodes reachable from any hit via
7015 /// up to `depth` hops that are NOT already in the search hit set.
7016 ///
7017 /// Returns `Err(EngineError::InvalidArgument)` for `depth > 3`.
7018 /// A `depth = 0` call returns search hits with their logical_ids resolved
7019 /// but no BFS expansion. Reads ride the `ReaderWorkerPool` DEFERRED-tx path.
7020 ///
7021 /// **Snapshot note:** the search phase (`search_inner`) and the expansion
7022 /// phase (`SearchExpand` reader request) run in separate DEFERRED reader
7023 /// transactions; a write that lands between them is visible to expansion
7024 /// but not search (or vice-versa). In practice the window is negligible for
7025 /// single-process embedded use. The expansion phase mitigates drift by
7026 /// filtering `search_hits` to only include hits whose `write_cursor` is
7027 /// still active in the expansion snapshot (superseded hits are dropped from
7028 /// the result rather than surfaced with stale data).
7029 pub fn search_expand(
7030 &self,
7031 query: &str,
7032 filter: Option<SearchFilter>,
7033 depth: u32,
7034 ) -> Result<SearchExpandResult, EngineError> {
7035 self.ensure_open()?;
7036 if depth > 3 {
7037 return Err(EngineError::InvalidArgument {
7038 msg: format!("traversal depth {depth} exceeds the SDK ceiling of 3"),
7039 });
7040 }
7041 // Step 1: run the hybrid search to get initial hits (no CE reranking in expand).
7042 // 0.8.5: depth=0 → no rerank, so α/pool_n (0.3, 0) are inert here.
7043 let search_result =
7044 self.search_inner(query, filter, 0, false, 0.3, 0, false, ReadView::default())?;
7045 if search_result.results.is_empty() {
7046 return Ok(SearchExpandResult {
7047 search_hits: Vec::new(),
7048 expanded: Vec::new(),
7049 all_logical_ids: Vec::new(),
7050 });
7051 }
7052 // Step 2: dispatch to the reader pool to resolve logical_ids and run BFS.
7053 // depth=0 is forwarded to the reader so it can populate all_logical_ids
7054 // (the union of search-hit logical_ids), even with no expansion.
7055 let (response_tx, response_rx) = mpsc::sync_channel(1);
7056 let request = ReaderRequest::SearchExpand {
7057 search_hits: search_result.results,
7058 depth,
7059 respond: response_tx,
7060 };
7061 if self.reader_pool.dispatch(request).is_err() {
7062 return Err(EngineError::Closing);
7063 }
7064 match response_rx.recv().map_err(|_| EngineError::Storage)? {
7065 Ok(result) => Ok(result),
7066 Err(err) => {
7067 self.emit_sqlite_internal_error(&err);
7068 Err(EngineError::Storage)
7069 }
7070 }
7071 }
7072
7073 /// Slice 20 test seam — run `EXPLAIN QUERY PLAN` on the BFS CTE SQL and
7074 /// return the plan detail lines. Used by `explain_plan_uses_indexes`.
7075 #[doc(hidden)]
7076 pub fn explain_graph_neighbors_for_test(
7077 &self,
7078 root_logical_id: &str,
7079 depth: u32,
7080 direction: TraversalDirection,
7081 ) -> Result<Vec<String>, EngineError> {
7082 self.ensure_open()?;
7083 let (response_tx, response_rx) = mpsc::sync_channel(1);
7084 let request = ReaderRequest::ExplainGraphNeighbors {
7085 root_logical_id: root_logical_id.to_string(),
7086 depth,
7087 direction,
7088 respond: response_tx,
7089 };
7090 if self.reader_pool.dispatch(request).is_err() {
7091 return Err(EngineError::Closing);
7092 }
7093 match response_rx.recv().map_err(|_| EngineError::Storage)? {
7094 Ok(plan) => Ok(plan),
7095 Err(err) => {
7096 self.emit_sqlite_internal_error(&err);
7097 Err(EngineError::Storage)
7098 }
7099 }
7100 }
7101
7102 /// Slice 30 (G3) — `read.collection`: paginated op-store read-back over
7103 /// `operational_mutations` for `collection`, `ORDER BY id`. `limit` is
7104 /// MANDATORY (clamped to the ~1M cap); `after_id` is the exclusive cursor.
7105 /// Reads ride the ReaderWorkerPool DEFERRED-tx path.
7106 pub fn read_collection(
7107 &self,
7108 collection: &str,
7109 after_id: Option<i64>,
7110 limit: usize,
7111 ) -> Result<Vec<OpStoreRow>, EngineError> {
7112 self.read_collection_dispatch(collection, after_id, limit)
7113 }
7114
7115 /// Slice 30 (G3) — `read.mutations`: the mutation-log-oriented alias surface
7116 /// over the SAME op-store read-back as [`Engine::read_collection`].
7117 pub fn read_mutations(
7118 &self,
7119 collection: &str,
7120 after_id: Option<i64>,
7121 limit: usize,
7122 ) -> Result<Vec<OpStoreRow>, EngineError> {
7123 self.read_collection_dispatch(collection, after_id, limit)
7124 }
7125
7126 fn read_collection_dispatch(
7127 &self,
7128 collection: &str,
7129 after_id: Option<i64>,
7130 limit: usize,
7131 ) -> Result<Vec<OpStoreRow>, EngineError> {
7132 self.ensure_open()?;
7133 let (response_tx, response_rx) = mpsc::sync_channel(1);
7134 let request = ReaderRequest::ReadCollection {
7135 collection: collection.to_string(),
7136 after_id,
7137 limit,
7138 respond: response_tx,
7139 };
7140 if self.reader_pool.dispatch(request).is_err() {
7141 return Err(EngineError::Closing);
7142 }
7143 match response_rx.recv().map_err(|_| EngineError::Storage)? {
7144 Ok(rows) => Ok(rows),
7145 Err(err) => {
7146 self.emit_sqlite_internal_error(&err);
7147 Err(EngineError::Storage)
7148 }
7149 }
7150 }
7151
7152 /// Slice 35 (G4) — `read.list`: list active `canonical_nodes` of a given
7153 /// `kind`, optionally filtered by a closed [`Predicate`] set, up to `limit`
7154 /// rows. Returns `Vec<NodeRecord>` (active only; `superseded_at IS NULL`).
7155 ///
7156 /// Multiple predicates are combined as AND (D-F5). An empty predicate slice
7157 /// returns all active nodes of the given kind up to `limit` (unfiltered path).
7158 /// Compilation target: `json_extract(body, '$.field') <op> ?` with bound
7159 /// parameters (injection-safe per D-F4). See `dev/adr/ADR-0.8.0-filter-grammar.md`.
7160 ///
7161 /// Path validation happens at [`Predicate`] construction time; `read_list`
7162 /// revalidates as defense-in-depth (enum variants are `pub`, so direct
7163 /// struct-literal construction could bypass the constructors).
7164 pub fn read_list(
7165 &self,
7166 kind: &str,
7167 predicates: &[Predicate],
7168 limit: usize,
7169 view: &ReadView,
7170 ) -> Result<Vec<NodeRecord>, EngineError> {
7171 self.ensure_open()?;
7172 // Defense-in-depth: revalidate paths even if the caller bypassed the
7173 // validated constructors by constructing enum variants directly.
7174 for pred in predicates {
7175 let path = pred.path();
7176 if !PREDICATE_PATH_ALLOWLIST.contains(&path) {
7177 return Err(EngineError::InvalidFilter {
7178 reason: format!("path '{path}' is not in the predicate path allowlist"),
7179 });
7180 }
7181 }
7182 let (response_tx, response_rx) = mpsc::sync_channel(1);
7183 let request = ReaderRequest::ReadList {
7184 kind: kind.to_string(),
7185 predicates: predicates.to_vec(),
7186 limit,
7187 view: *view,
7188 respond: response_tx,
7189 };
7190 if self.reader_pool.dispatch(request).is_err() {
7191 return Err(EngineError::Closing);
7192 }
7193 match response_rx.recv().map_err(|_| EngineError::Storage)? {
7194 Ok(rows) => Ok(rows),
7195 Err(err) => {
7196 self.emit_sqlite_internal_error(&err);
7197 Err(EngineError::Storage)
7198 }
7199 }
7200 }
7201
7202 /// 0.8.11 Slice 40 (#17) — unified-`Filter` entry point for the
7203 /// canonical_nodes `read.list` backend. Accepts the **full** [`FilterTerm`]
7204 /// set (D3): `Json` runs the shipped allowlisted `json_extract` path;
7205 /// `Status`/`CreatedAfter` lower to allowlisted json-paths; `Kind`/`SourceType`
7206 /// **constant-fold** against the partition `kind` (a guaranteed-empty fold
7207 /// returns an empty `Vec` without touching SQL). Dispatches to the same
7208 /// [`Engine::read_list`] machinery the shipped `Predicate` surface uses, so
7209 /// every inherited invariant (`superseded_at IS NULL`, `json_valid(body)`,
7210 /// the `canonical_nodes(kind)` index, parameterized binds) is preserved.
7211 pub fn read_list_filter(
7212 &self,
7213 kind: &str,
7214 filter: &Filter,
7215 limit: usize,
7216 view: &ReadView,
7217 ) -> Result<Vec<NodeRecord>, EngineError> {
7218 self.ensure_open()?;
7219 match filter.lower_for_read_list(kind)? {
7220 None => Ok(Vec::new()),
7221 Some(preds) => self.read_list(kind, &preds, limit, view),
7222 }
7223 }
7224
7225 /// 0.8.20 Slice 10b (R-20-NV) — the **validity-boundary hook**: which nodes
7226 /// crossed a `[valid_from, valid_until)` boundary in the half-open interval
7227 /// `(since, as_of]`?
7228 ///
7229 /// `since` and the resolved upper bound are INTEGER epoch SECONDS. The upper
7230 /// bound is the view's own instant (`view.valid_as_of`, defaulting to now),
7231 /// so one instant governs both the boundary interval and the view — and, as
7232 /// everywhere else on this path, it is BOUND, never a `datetime('now')`
7233 /// literal, so the answer is deterministic for a fixed `(since, as_of)`.
7234 ///
7235 /// A node appears once, carrying whichever of the two boundaries it crossed;
7236 /// a window that both opened AND closed inside the interval reports both.
7237 /// Rows with an unbounded window on a side cannot cross that side, so a
7238 /// NULL/NULL row (every row predating schema step 22) never appears.
7239 ///
7240 /// The view's EXISTENCE flags still apply (so by default only current,
7241 /// active rows are considered), but its validity predicate does NOT: the
7242 /// question is about boundary crossings, not about being valid right now.
7243 ///
7244 /// When the view relaxes validity entirely (`include_out_of_window`), the
7245 /// interval is unbounded above.
7246 ///
7247 /// This is world-time only. There is deliberately no transaction-time
7248 /// (`history_as_of`) counterpart.
7249 pub fn crossed_boundary_since(
7250 &self,
7251 since: i64,
7252 view: &ReadView,
7253 ) -> Result<Vec<BoundaryCrossing>, EngineError> {
7254 self.ensure_open()?;
7255 let (response_tx, response_rx) = mpsc::sync_channel(1);
7256 let request =
7257 ReaderRequest::CrossedBoundarySince { since, view: *view, respond: response_tx };
7258 if self.reader_pool.dispatch(request).is_err() {
7259 return Err(EngineError::Closing);
7260 }
7261 match response_rx.recv().map_err(|_| EngineError::Storage)? {
7262 Ok(rows) => Ok(rows),
7263 Err(err) => {
7264 self.emit_sqlite_internal_error(&err);
7265 Err(EngineError::Storage)
7266 }
7267 }
7268 }
7269
7270 pub fn close(&self) -> Result<(), EngineError> {
7271 self.closed.store(true, Ordering::SeqCst);
7272 self.projection_runtime.stop();
7273 // Uninstall profile callbacks before dropping the connections so
7274 // SQLite cannot fire one last callback against a profile context
7275 // whose Box is about to free. Per `dev/design/engine.md` § Close
7276 // path step 6, readers drain before the writer connection so
7277 // SQLite's last-handle checkpointer runs on the writer. Each
7278 // reader worker uninstalls its own callback inside
7279 // `reader_worker_loop` before dropping its connection, then
7280 // exits — `shutdown` joins those threads here.
7281 self.reader_pool.shutdown();
7282 if let Ok(mut connection) = self.connection.lock() {
7283 if let Some(conn) = connection.as_ref() {
7284 uninstall_profile_callback(conn);
7285 }
7286 connection.take();
7287 }
7288 if let Ok(mut contexts) = self.profile_contexts.lock() {
7289 contexts.clear();
7290 }
7291 if let Ok(mut lock) = self.lock.lock() {
7292 lock.take();
7293 }
7294 Ok(())
7295 }
7296
7297 /// Block until in-flight writes drain or `timeout_ms` elapses.
7298 ///
7299 /// Surface owned by `dev/interfaces/rust.md` § Engine-attached
7300 /// instrumentation; semantics are owned by `dev/design/lifecycle.md`.
7301 pub fn drain(&self, timeout_ms: u64) -> Result<(), EngineError> {
7302 self.ensure_open()?;
7303 if self.projection_runtime.wait_for_idle(timeout_ms) {
7304 Ok(())
7305 } else {
7306 Err(EngineError::Scheduler)
7307 }
7308 }
7309
7310 /// Snapshot of engine-internal counters.
7311 ///
7312 /// Field set owned by `dev/design/lifecycle.md`.
7313 #[must_use]
7314 pub fn counters(&self) -> CounterSnapshot {
7315 self.counters.snapshot()
7316 }
7317
7318 /// Toggle response-cycle profiling.
7319 ///
7320 /// Per `dev/design/lifecycle.md` § Per-statement profiling, profiling
7321 /// is an opt-in surface that is independently toggleable on a running
7322 /// engine without restart. AC-005a locks runtime toggleability.
7323 pub fn set_profiling(&self, enabled: bool) -> Result<(), EngineError> {
7324 self.profiling_enabled.store(enabled, Ordering::Relaxed);
7325 Ok(())
7326 }
7327
7328 /// Set the threshold above which an operation is reported as slow.
7329 ///
7330 /// Per `dev/design/lifecycle.md` § Slow and heartbeat policy, the
7331 /// threshold is runtime-configurable; mutating it changes detection
7332 /// behavior on subsequent statements without restart (AC-007b).
7333 pub fn set_slow_threshold_ms(&self, value: u64) -> Result<(), EngineError> {
7334 self.slow_threshold_ms.store(value, Ordering::Relaxed);
7335 Ok(())
7336 }
7337
7338 /// Attach a host subscriber to engine events.
7339 ///
7340 /// Dropping the returned [`Subscription`] detaches the subscriber.
7341 /// Payload shape owned by `dev/design/lifecycle.md` and
7342 /// `dev/design/migrations.md`.
7343 #[must_use]
7344 pub fn subscribe(&self, subscriber: Arc<dyn lifecycle::Subscriber>) -> Subscription {
7345 self.subscribers.attach(subscriber)
7346 }
7347
7348 #[cfg(debug_assertions)]
7349 #[doc(hidden)]
7350 pub fn reader_worker_count_for_test(&self) -> usize {
7351 self.reader_pool.worker_count()
7352 }
7353
7354 #[cfg(debug_assertions)]
7355 #[doc(hidden)]
7356 pub fn live_reader_worker_count_for_test(&self) -> usize {
7357 self.reader_pool.live_count()
7358 }
7359
7360 /// Pack 6.G G.1 — return the `sqlite3_db_config(LOOKASIDE)` rc
7361 /// captured for each reader worker at open time, in worker index
7362 /// order. SQLITE_OK (= 0) means the lookaside was configured
7363 /// before any allocation happened on the connection.
7364 #[cfg(debug_assertions)]
7365 #[doc(hidden)]
7366 pub fn reader_lookaside_config_rcs_for_test(&self) -> Vec<i32> {
7367 self.reader_lookaside_rcs.clone()
7368 }
7369
7370 /// Pack 6.G G.1 — query each reader worker's
7371 /// `SQLITE_DBSTATUS_LOOKASIDE_USED` counter. A value > 0 means at
7372 /// least one allocation was satisfied from the per-connection
7373 /// lookaside arena (proof the configuration was honored before the
7374 /// first prepare).
7375 #[cfg(debug_assertions)]
7376 #[doc(hidden)]
7377 pub fn reader_lookaside_used_per_worker_for_test(&self) -> Vec<i32> {
7378 self.reader_pool.lookaside_used_per_worker()
7379 }
7380
7381 /// Pack 6.G G.3.5 — broadcast a debug-only `CacheStatus` request to
7382 /// every reader worker and collect per-worker
7383 /// `SQLITE_DBSTATUS_CACHE_HIT` / `_CACHE_MISS` / `_CACHE_USED`
7384 /// values. Counters are monotonic (reset flag = 0); callers compute
7385 /// pre/post deltas explicitly.
7386 #[cfg(debug_assertions)]
7387 #[doc(hidden)]
7388 pub fn cache_status_per_worker_for_test(&self, label: &str) -> Vec<CacheStatusReply> {
7389 self.reader_pool.cache_status_per_worker(label)
7390 }
7391
7392 #[cfg(debug_assertions)]
7393 #[doc(hidden)]
7394 pub fn force_next_commit_failure_for_test(&self) {
7395 self.force_next_commit_failure.store(true, Ordering::SeqCst);
7396 }
7397
7398 /// Force the next background projection terminal commit to fail with a
7399 /// synthetic SQLite busy error. Test-only seam for TC-91 rollback and
7400 /// redispatch coverage; it does not affect the caller's write transaction.
7401 #[cfg(debug_assertions)]
7402 #[doc(hidden)]
7403 pub fn force_next_projection_commit_failure_for_test(&self) {
7404 self.projection_runtime.force_next_projection_commit_failure_for_test();
7405 }
7406
7407 /// Force the next background projection terminal commit to fail with a
7408 /// rusqlite-layer storage error. Test-only TC-91 diagnostic classifier seam.
7409 #[cfg(debug_assertions)]
7410 #[doc(hidden)]
7411 pub fn force_next_projection_storage_failure_for_test(&self) {
7412 self.projection_runtime.force_next_projection_storage_failure_for_test();
7413 }
7414
7415 /// Pause a worker after a forced projection-commit error was reported and
7416 /// before its state cleanup. TC-91 test-only shutdown/reopen rendezvous.
7417 #[cfg(debug_assertions)]
7418 #[doc(hidden)]
7419 pub fn pause_projection_commit_failure_cleanup_for_test(
7420 &self,
7421 reported: Arc<Barrier>,
7422 release: Arc<Barrier>,
7423 ) {
7424 self.projection_runtime.pause_projection_commit_failure_cleanup_for_test(reported, release);
7425 }
7426
7427 /// Acknowledge after `Engine::close` marks the projection runtime stopping
7428 /// and before it joins workers. TC-91 test-only shutdown rendezvous.
7429 #[cfg(debug_assertions)]
7430 #[doc(hidden)]
7431 pub fn acknowledge_projection_stop_for_test(&self, acknowledged: Arc<Barrier>) {
7432 self.projection_runtime.acknowledge_projection_stop_for_test(acknowledged);
7433 }
7434
7435 /// Execute an arbitrary SQL statement on the writer connection through
7436 /// the same wall-clock + slow-detect path as `write` / `search`.
7437 ///
7438 /// Test-only helper for the deterministic-slow-cte fixture used by
7439 /// AC-007a / AC-007b. Not part of the public 0.6.0 surface; gated on
7440 /// `debug_assertions` so release builds do not expose it.
7441 #[cfg(debug_assertions)]
7442 #[doc(hidden)]
7443 pub fn execute_for_test(&self, sql: &str) -> Result<(), EngineError> {
7444 self.ensure_open()?;
7445 let started = Instant::now();
7446 {
7447 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7448 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
7449 connection.execute_batch(sql).map_err(|_| EngineError::Storage)?;
7450 }
7451 self.detect_slow(started, lifecycle::EventCategory::Search);
7452 Ok(())
7453 }
7454
7455 /// One-thread-poison robustness fixture (AC-009).
7456 ///
7457 /// Spawns four reader threads + one writer thread that all make
7458 /// forward progress (single canonical write + repeated searches),
7459 /// plus one designated poison thread that runs an empty-batch write
7460 /// — a deterministic `EngineError::WriteValidation`. The captured
7461 /// poison failure is dispatched as a `StressFailureContext` whose
7462 /// `last_error_chain` is `[EngineError::stable_code(),
7463 /// engine_error.to_string()]` per the lifecycle § Stress-failure
7464 /// context payload contract.
7465 #[doc(hidden)]
7466 #[cfg(debug_assertions)]
7467 pub fn run_one_thread_poison_for_test(&self) -> Result<(), EngineError> {
7468 self.ensure_open()?;
7469
7470 // Forward-progress writer seeds a row so readers + the poison
7471 // thread share a non-trivial canonical state.
7472 self.write(&[PreparedWrite::Node {
7473 kind: "doc".to_string(),
7474 body: "poison-fixture-seed".to_string(),
7475 source_id: SourceId::engine_derived("poison-fixture"),
7476 logical_id: None,
7477 state: InitialState::Active,
7478 reason: None,
7479 valid_from: None,
7480 valid_until: None,
7481 }])?;
7482
7483 let poison_outcome: Mutex<Option<EngineError>> = Mutex::new(None);
7484 let poison_thread_id: AtomicU64 = AtomicU64::new(0);
7485
7486 thread::scope(|scope| {
7487 // N=4 reader threads make forward progress.
7488 for _ in 0..4 {
7489 scope.spawn(|| {
7490 for _ in 0..4 {
7491 let _ = self.search("poison-fixture-seed");
7492 }
7493 });
7494 }
7495 // One forward-progress writer thread.
7496 scope.spawn(|| {
7497 let _ = self.write(&[PreparedWrite::Node {
7498 kind: "doc".to_string(),
7499 body: "writer-progress".to_string(),
7500 source_id: SourceId::engine_derived("poison-fixture"),
7501 logical_id: None,
7502 state: InitialState::Active,
7503 reason: None,
7504 valid_from: None,
7505 valid_until: None,
7506 }]);
7507 });
7508 // One poison thread — empty batch is a deterministic
7509 // WriteValidation failure.
7510 scope.spawn(|| {
7511 // Use a non-zero, deterministic group id so subscribers
7512 // see a stable identifier across runs of the fixture.
7513 poison_thread_id.store(1, Ordering::SeqCst);
7514 if let Err(err) = self.write(&[]) {
7515 *poison_outcome.lock().expect("poison_outcome lock") = Some(err);
7516 }
7517 });
7518 });
7519
7520 let err = poison_outcome
7521 .into_inner()
7522 .expect("poison_outcome lock")
7523 .expect("poison thread must produce a deterministic error");
7524
7525 let projection_state = match self.projection_status_for_test("doc") {
7526 Ok(lifecycle::ProjectionStatus::Pending) => "Pending",
7527 Ok(lifecycle::ProjectionStatus::Failed) => "Failed",
7528 Ok(lifecycle::ProjectionStatus::UpToDate) => "UpToDate",
7529 // Default to UpToDate when projection status is unobservable
7530 // (e.g. embedder not configured for the seed kind). The
7531 // value is still one of the documented enum stringifications
7532 // per AC-010.
7533 Err(_) => "UpToDate",
7534 };
7535
7536 let context = lifecycle::StressFailureContext {
7537 thread_group_id: poison_thread_id.load(Ordering::SeqCst),
7538 op_kind: "write".to_string(),
7539 last_error_chain: vec![err.stable_code().to_string(), err.to_string()],
7540 projection_state: projection_state.to_string(),
7541 };
7542 self.subscribers.dispatch_stress_failure(&context);
7543 Ok(())
7544 }
7545
7546 #[doc(hidden)]
7547 pub fn set_projection_scheduler_frozen_for_test(&self, frozen: bool) {
7548 self.projection_runtime.set_frozen(frozen);
7549 }
7550
7551 #[doc(hidden)]
7552 pub fn set_projection_retry_delays_for_test(&self, delays_ms: &[u64]) {
7553 self.projection_runtime.set_retry_delays_for_test(delays_ms);
7554 }
7555
7556 /// PR-9 — lower the ADR-0.6.0 Invariant 5 per-`embed()` watchdog deadline
7557 /// for tests (production default is `DEFAULT_EMBED_TIMEOUT_MS` = 30s).
7558 #[doc(hidden)]
7559 pub fn set_embed_timeout_ms_for_test(&self, timeout_ms: u64) {
7560 self.projection_runtime.set_embed_timeout_ms_for_test(timeout_ms);
7561 }
7562
7563 /// PR-9 — lower the embed circuit-breaker threshold for tests (production
7564 /// default `DEFAULT_EMBED_CIRCUIT_THRESHOLD`); 0 disables the breaker.
7565 #[doc(hidden)]
7566 pub fn set_embed_circuit_threshold_for_test(&self, threshold: u64) {
7567 self.projection_runtime.set_embed_circuit_threshold_for_test(threshold);
7568 }
7569
7570 /// PR-9 — whether the embed circuit breaker has latched open.
7571 #[doc(hidden)]
7572 pub fn embed_circuit_open_for_test(&self) -> bool {
7573 self.projection_runtime.embed_circuit_open_for_test()
7574 }
7575
7576 #[doc(hidden)]
7577 pub fn projection_status_for_test(
7578 &self,
7579 kind: &str,
7580 ) -> Result<lifecycle::ProjectionStatus, EngineError> {
7581 self.ensure_open()?;
7582 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7583 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7584 projection_status(connection, kind)
7585 }
7586
7587 #[doc(hidden)]
7588 pub fn has_vector_for_cursor_for_test(&self, cursor: u64) -> Result<bool, EngineError> {
7589 self.ensure_open()?;
7590 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7591 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7592 terminal_state_for_cursor(connection, cursor)
7593 .map(|state| matches!(state.as_deref(), Some("up_to_date")))
7594 .map_err(|_| EngineError::Storage)
7595 }
7596
7597 #[doc(hidden)]
7598 pub fn projection_failure_count_for_test(&self, cursor: u64) -> Result<u64, EngineError> {
7599 self.ensure_open()?;
7600 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7601 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7602 connection
7603 .query_row(
7604 "SELECT COUNT(*) FROM operational_mutations
7605 WHERE collection_name = 'projection_failures'
7606 AND record_key = ?1",
7607 [cursor.to_string()],
7608 |row| row.get::<_, u64>(0),
7609 )
7610 .map_err(|_| EngineError::Storage)
7611 }
7612
7613 #[doc(hidden)]
7614 pub fn set_provenance_row_cap_for_test(&self, cap: Option<u64>) {
7615 self.provenance_row_cap.store(cap.unwrap_or(0), Ordering::Relaxed);
7616 }
7617
7618 #[doc(hidden)]
7619 pub fn provenance_row_count_for_test(&self) -> Result<u64, EngineError> {
7620 self.ensure_open()?;
7621 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7622 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7623 connection
7624 .query_row("SELECT COUNT(*) FROM operational_mutations", [], |row| row.get::<_, u64>(0))
7625 .map_err(|_| EngineError::Storage)
7626 }
7627
7628 #[doc(hidden)]
7629 pub fn oldest_provenance_record_key_for_test(
7630 &self,
7631 collection: &str,
7632 ) -> Result<Option<String>, EngineError> {
7633 self.ensure_open()?;
7634 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7635 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7636 connection
7637 .query_row(
7638 "SELECT record_key FROM operational_mutations
7639 WHERE collection_name = ?1
7640 ORDER BY id
7641 LIMIT 1",
7642 [collection],
7643 |row| row.get::<_, String>(0),
7644 )
7645 .map(Some)
7646 .or_else(|err| match err {
7647 rusqlite::Error::QueryReturnedNoRows => Ok(None),
7648 _ => Err(EngineError::Storage),
7649 })
7650 }
7651
7652 #[doc(hidden)]
7653 pub fn configure_vector_kind_for_test(&self, kind: &str) -> Result<(), EngineError> {
7654 self.ensure_open()?;
7655 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7656 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
7657 connection
7658 .execute(
7659 "INSERT OR REPLACE INTO _fathomdb_vector_kinds(kind, profile, created_at)
7660 VALUES(?1, ?2, 0)",
7661 params![kind, DEFAULT_VECTOR_PROFILE],
7662 )
7663 .map_err(|_| EngineError::Storage)?;
7664 Ok(())
7665 }
7666
7667 /// OPP-12 Phase-1 (0.8.19 Slice 10) — read the writer connection's
7668 /// `PRAGMA secure_delete` (design §3 gap-4). `true` iff the standing
7669 /// connection-open PRAGMA is in effect, so `purge` freelist erasure is
7670 /// complete without a per-purge `VACUUM`.
7671 #[doc(hidden)]
7672 pub fn secure_delete_enabled_for_test(&self) -> Result<bool, EngineError> {
7673 self.ensure_open()?;
7674 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7675 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7676 let value: i64 = connection
7677 .query_row("PRAGMA secure_delete", [], |r| r.get(0))
7678 .map_err(|_| EngineError::Storage)?;
7679 Ok(value != 0)
7680 }
7681
7682 /// OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — `true` iff EVERY
7683 /// reader-pool connection reports `PRAGMA secure_delete = ON`. Broadcasts a
7684 /// per-worker probe; proves the standing flag is set on the non-writer
7685 /// connections (which perform projection/vector-rewrite DELETEs), closing
7686 /// the GDPR-erasure leak codex flagged.
7687 #[cfg(debug_assertions)]
7688 #[doc(hidden)]
7689 pub fn reader_secure_delete_enabled_for_test(&self) -> Result<bool, EngineError> {
7690 self.ensure_open()?;
7691 let per_worker = self.reader_pool.secure_delete_per_worker();
7692 if per_worker.is_empty() {
7693 return Err(EngineError::Storage);
7694 }
7695 Ok(per_worker.iter().all(|&v| v == 1))
7696 }
7697
7698 /// OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — `true` iff a freshly
7699 /// opened projection/runtime connection (`open_runtime_connection`) reports
7700 /// `PRAGMA secure_delete = ON`. The runtime connection performs the
7701 /// vector-rewrite/projection DELETEs, so its freed pages must be scrubbed too.
7702 #[doc(hidden)]
7703 pub fn runtime_secure_delete_enabled_for_test(&self) -> Result<bool, EngineError> {
7704 self.ensure_open()?;
7705 let connection = open_runtime_connection(&self.path).map_err(|_| EngineError::Storage)?;
7706 let value: i64 = connection
7707 .query_row("PRAGMA secure_delete", [], |r| r.get(0))
7708 .map_err(|_| EngineError::Storage)?;
7709 Ok(value != 0)
7710 }
7711
7712 /// EXP-S (0.8.14 Slice 5, D1) — write one canonical node row carrying an
7713 /// explicit structural `row_kind` (leaf/coverage/graph), routing the index
7714 /// projection through the SAME `row_kind -> index-target` dispatch seam
7715 /// (`project_canonical_node_row`) as the production `leaf` write path.
7716 ///
7717 /// This is the internal-only writer for `coverage`/`graph` rows (there is no
7718 /// public SDK surface for `row_kind` in 0.8.14). Cursor assignment preserves
7719 /// the `rowid == write_cursor == cursor` determinism identity. When the row
7720 /// projects into an async vector index, the worker pool is notified so the
7721 /// embed is scheduled exactly as for a normal write.
7722 #[doc(hidden)]
7723 pub fn write_canonical_row_with_kind_for_test(
7724 &self,
7725 kind: &str,
7726 body: &str,
7727 row_kind: RowKind,
7728 ) -> Result<WriteReceipt, EngineError> {
7729 self.ensure_open()?;
7730 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7731 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
7732
7733 // R-20-E3 / design §4 item 6 — this writer BYPASSES `PreparedWrite`, so
7734 // the `SourceId` newtype cannot reach it; before 0.8.20 it inserted a
7735 // literal NULL `source_id` and produced a row that no `excise_source`
7736 // call could reach. Engine-derived rows instead take a reserved
7737 // `_engine:*` provenance, keyed by the structural role that produced
7738 // them, so they are both erasable and distinguishable from caller data.
7739 let engine_provenance = SourceId::engine_derived(row_kind.as_str());
7740
7741 // 0.8.20 Slice 20c — same late enrolment the governed write path takes
7742 // (`Engine::enrol_batch_vector_kinds`), so this internal writer does not
7743 // silently diverge into the false-ready barrier for `coverage` rows. The
7744 // live-embedder precondition is checked here, as that caller does; the
7745 // `row_kind` gate keeps `graph` rows out of the vector registry.
7746 //
7747 // fix-2 (codex §9 [P2]) — including the un-stranding half, so this door
7748 // cannot diverge from the other one either. fix-5 (codex §9 round 4 [P2])
7749 // — and both halves commit as ONE transaction, via the same shared
7750 // `enrol_and_unstrand`.
7751 let unstranded = if self.runtime_embedder.is_some()
7752 && self.vector_kind_needs_enrolment(connection, kind, row_kind)?
7753 {
7754 self.enrol_and_unstrand(connection, &[kind])?
7755 } else {
7756 false
7757 };
7758
7759 let cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
7760 let enqueued = {
7761 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
7762 // 0.8.20 Slice 15b (TC-34) — this writer takes NO validity window, and
7763 // that is deliberate rather than an oversight. It is a `#[doc(hidden)]`
7764 // test-only writer for the internal `coverage`/`graph` row kinds, which
7765 // have no public SDK surface at all (see the doc comment above); the
7766 // caller-facing authoring path is `PreparedWrite::Node`, handled in
7767 // `commit_batch`. Omitting the columns binds NULL — the migration
7768 // step-22 default and the UNBOUNDED reading — so engine-derived rows
7769 // stay valid at every instant, which is the only correct answer for a
7770 // structural row that no caller can address a window to.
7771 tx.execute(
7772 "INSERT INTO canonical_nodes(write_cursor, kind, body, source_id, logical_id, row_kind)
7773 VALUES(?1, ?2, ?3, ?4, NULL, ?5)",
7774 params![cursor, kind, body, engine_provenance.as_str(), row_kind.as_str()],
7775 )
7776 .map_err(|_| EngineError::Storage)?;
7777 let enqueued = project_canonical_node_row(
7778 &tx,
7779 cursor,
7780 kind,
7781 body,
7782 row_kind,
7783 ProjectionPass::Write,
7784 // This #[doc(hidden)] writer inserts with the column DEFAULT
7785 // `state = 'active'` (no state column in its INSERT), so the row
7786 // is always active and its attributes project.
7787 true,
7788 )
7789 .map_err(|_| EngineError::Storage)?;
7790 advance_projection_cursor(&tx).map_err(|_| EngineError::Storage)?;
7791 tx.commit().map_err(|_| EngineError::Storage)?;
7792 enqueued
7793 };
7794 self.next_cursor.store(cursor, Ordering::SeqCst);
7795 if enqueued || unstranded {
7796 self.projection_runtime.notify_new_work();
7797 }
7798 Ok(WriteReceipt { cursor, row_cursors: vec![cursor], dangling_edge_endpoints: 0 })
7799 }
7800
7801 /// EXP-S (0.8.14 Slice 5, D1) — select the active canonical rows carrying a
7802 /// given `row_kind`, returning their `write_cursor`s in cursor order. Proves
7803 /// the engine can query/select rows by the structural `row_kind` axis.
7804 #[doc(hidden)]
7805 pub fn canonical_rows_with_row_kind_for_test(
7806 &self,
7807 row_kind: RowKind,
7808 ) -> Result<Vec<u64>, EngineError> {
7809 self.ensure_open()?;
7810 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7811 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7812 let mut stmt = connection
7813 .prepare(
7814 "SELECT write_cursor FROM canonical_nodes
7815 WHERE row_kind = ?1 AND superseded_at IS NULL
7816 ORDER BY write_cursor",
7817 )
7818 .map_err(|_| EngineError::Storage)?;
7819 let cursors = stmt
7820 .query_map(params![row_kind.as_str()], |row| row.get::<_, u64>(0))
7821 .map_err(|_| EngineError::Storage)?
7822 .collect::<rusqlite::Result<Vec<u64>>>()
7823 .map_err(|_| EngineError::Storage)?;
7824 Ok(cursors)
7825 }
7826
7827 /// F5 (0.8.14 Slice 10) — the fielded BM25F lexical arm over
7828 /// `search_index_v2`. Recalls candidate rows through the FTS5 index
7829 /// (`search_index_v2 MATCH`) and scores them with a textbook BM25F using the
7830 /// plan's tunable per-field `weights` and tunable `b`/`k1`, returning
7831 /// `(write_cursor, score)` in descending score order (write_cursor asc as the
7832 /// deterministic tiebreak). Superseded node versions are excluded (join to
7833 /// `canonical_nodes WHERE superseded_at IS NULL`).
7834 ///
7835 /// This is the engine-internal `BM25fQueryPlan` compiler path (`ADR-0.8.1`
7836 /// §3.2); there is no public Py/TS SDK surface this release. The score is
7837 /// computed in-engine (not via SQLite's `bm25()`, which cannot express a
7838 /// tunable `b`); the FTS5 index remains load-bearing for candidate recall.
7839 #[doc(hidden)]
7840 pub fn bm25f_search(
7841 &self,
7842 query: &str,
7843 plan: &Bm25fQueryPlan,
7844 ) -> Result<Vec<(u64, f64)>, EngineError> {
7845 self.ensure_open()?;
7846 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7847 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7848 bm25f_search_inner(connection, query, plan).map_err(|_| EngineError::Storage)
7849 }
7850
7851 /// Embed arbitrary text with the engine's configured runtime embedder,
7852 /// returning the raw (un-centered) vector.
7853 ///
7854 /// This is the read-path embed primitive: it mirrors the search
7855 /// query-embedding path — a single, direct [`Embedder::embed`] call. The
7856 /// per-`embed()` watchdog/circuit-breaker guards only the bulk
7857 /// projection/write path (many embeds, fault isolation), not single
7858 /// read-side embeds, so a direct call is consistent with how a query is
7859 /// embedded. Callers get vectors under the engine's *pinned* embedder
7860 /// identity (`fathomdb-bge-small-en-v1.5` by default) rather than a
7861 /// parallel, possibly-divergent embedder.
7862 ///
7863 /// Returns [`EngineError::EmbedderNotConfigured`] if the engine was opened
7864 /// without an embedder (`use_default_embedder = false`).
7865 pub fn embed_text(&self, text: &str) -> Result<Vec<f32>, EngineError> {
7866 self.ensure_open()?;
7867 let embedder =
7868 self.runtime_embedder.as_ref().cloned().ok_or(EngineError::EmbedderNotConfigured)?;
7869 embedder.embed(text).map_err(map_runtime_embedder_error)
7870 }
7871
7872 #[doc(hidden)]
7873 pub fn write_vector_for_test(
7874 &self,
7875 kind: &str,
7876 text: &str,
7877 ) -> Result<WriteReceipt, EngineError> {
7878 self.ensure_open()?;
7879 let embedder =
7880 self.runtime_embedder.as_ref().cloned().ok_or(EngineError::EmbedderNotConfigured)?;
7881
7882 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7883 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
7884 if !kind_is_vector_indexed(connection, kind)? {
7885 return Err(EngineError::KindNotVectorIndexed);
7886 }
7887
7888 let expected = default_profile_dimension(connection)?;
7889 ensure_vector_partition(connection, expected).map_err(|_| EngineError::Storage)?;
7890 let vector = embedder.embed(text).map_err(map_runtime_embedder_error)?;
7891 let actual = u32::try_from(vector.len()).unwrap_or(u32::MAX);
7892 if actual != expected {
7893 return Err(EngineError::EmbedderDimensionMismatch { expected, actual });
7894 }
7895
7896 let cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
7897 // EU-5a2 mean-centering apply path (write side). f32 BLOB stored
7898 // is ALWAYS un-centered; the sign-quant input is the centered
7899 // vector iff the identity is MC-required AND a `mean_vec` is
7900 // pinned. NoopEmbedder identity (the only EU-5a2 live one) is
7901 // NOT MC-required, so this is a no-op until EU-5b's flip.
7902 let blob = encode_vector_blob(&vector);
7903 let bin_blob = if identity_requires_mean_centering(&self.runtime_embedder_identity) {
7904 match read_pinned_mean_vec(connection, self.runtime_embedder_identity.dimension)? {
7905 Some(mean) => encode_vector_blob(&subtract_mean(&vector, &mean)),
7906 None => blob.clone(),
7907 }
7908 } else {
7909 blob.clone()
7910 };
7911 let source_type = resolve_source_type(kind)?;
7912 let now_unix =
7913 SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
7914
7915 // EU-5b — feed the streaming mean accumulator (if live) and detect
7916 // a threshold-crossing pin. The mean materialization, pre-pin
7917 // re-quantize, and `MeanVecPinned` event emission all happen in
7918 // the SAME SQLite transaction as the row INSERT.
7919 let pin_event = {
7920 let runtime = &self.projection_runtime.shared;
7921 let mut accumulator =
7922 runtime.mean_accumulator.lock().map_err(|_| EngineError::Storage)?;
7923 if let Some(acc) = accumulator.as_mut() {
7924 acc.add(&vector);
7925 if acc.count() >= MEAN_VEC_PIN_THRESHOLD {
7926 let mean = acc.materialize();
7927 *accumulator = None;
7928 Some(mean)
7929 } else {
7930 None
7931 }
7932 } else {
7933 None
7934 }
7935 };
7936
7937 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
7938 tx.execute(
7939 "INSERT INTO _fathomdb_vector_rows(rowid, kind, write_cursor) VALUES(?1, ?2, ?3)",
7940 params![cursor, kind, cursor],
7941 )
7942 .map_err(|_| EngineError::Storage)?;
7943 // Slice 10 / G10 — `status` ships an empty-string sentinel only: vec0 TEXT
7944 // metadata columns are NOT NULL-able ("Expected text for TEXT metadata
7945 // column"), so the "no real population yet" state is `''`, not NULL.
7946 //
7947 // 0.8.20 Slice 15e — this test helper carries no JSON body, so every live
7948 // `filterable` `attr_<hex>` column binds the `''` sentinel (an empty body
7949 // extracts nothing). When the table has no attr columns the statement is
7950 // byte-identical to the shipped form.
7951 let (cols_sql, ph_sql, attr_vals) =
7952 vector_attr_insert_fragments(&tx, "", 7).map_err(|_| EngineError::Storage)?;
7953 let sql = format!(
7954 "INSERT INTO vector_default(
7955 rowid, embedding, embedding_bin, source_type, kind, created_at, status{cols_sql}
7956 ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, ''{ph_sql})"
7957 );
7958 let mut pv: Vec<rusqlite::types::Value> = vec![
7959 rusqlite::types::Value::Integer(cursor as i64),
7960 rusqlite::types::Value::Blob(blob.clone()),
7961 rusqlite::types::Value::Blob(bin_blob.clone()),
7962 rusqlite::types::Value::Text(source_type.to_string()),
7963 rusqlite::types::Value::Text(kind.to_string()),
7964 rusqlite::types::Value::Integer(now_unix),
7965 ];
7966 pv.extend(attr_vals);
7967 tx.execute(&sql, rusqlite::params_from_iter(pv.iter()))
7968 .map_err(|_| EngineError::Storage)?;
7969
7970 let mut emitted_event: Option<EmbedderEvent> = None;
7971 if let Some(mean_vec) = pin_event {
7972 let mean_bytes = encode_vector_blob(&mean_vec);
7973 tx.execute(
7974 "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
7975 params![mean_bytes],
7976 )
7977 .map_err(|_| EngineError::Storage)?;
7978 // Read all pre-pin (rowid, embedding) and re-quantize within
7979 // the same tx. The just-inserted row above is also covered.
7980 let rows: Vec<(i64, Vec<u8>)> = {
7981 let mut statement = tx
7982 .prepare("SELECT rowid, embedding FROM vector_default ORDER BY rowid")
7983 .map_err(|_| EngineError::Storage)?;
7984 let mapped = statement
7985 .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)))
7986 .map_err(|_| EngineError::Storage)?;
7987 let mut out = Vec::new();
7988 for r in mapped {
7989 out.push(r.map_err(|_| EngineError::Storage)?);
7990 }
7991 out
7992 };
7993 let (doc_count, _) = run_pin_and_requantize_pass(&tx, &rows, &mean_vec)?;
7994 emitted_event = Some(EmbedderEvent::MeanVecPinned {
7995 dim: u32::try_from(mean_vec.len()).unwrap_or(u32::MAX),
7996 doc_count,
7997 });
7998 }
7999
8000 tx.commit().map_err(|_| EngineError::Storage)?;
8001
8002 if let Some(ev) = emitted_event {
8003 if let Ok(mut events) = self.projection_runtime.shared.pending_events.lock() {
8004 events.push(ev);
8005 }
8006 }
8007
8008 self.next_cursor.store(cursor, Ordering::SeqCst);
8009 // G8 — this path (embedder-profile pin) commits no canonical edges, so
8010 // no endpoint can dangle.
8011 Ok(WriteReceipt { cursor, row_cursors: vec![cursor], dangling_edge_endpoints: 0 })
8012 }
8013
8014 /// EU-5b test seam — drain MeanVecPinned events queued by the
8015 /// projection-commit pin transaction since the last drain. Production
8016 /// callers consume these via `OpenReport.embedder_events`; this seam
8017 /// exists so the EU-5b RED test can observe the live emission.
8018 #[doc(hidden)]
8019 pub fn drain_mean_centering_events_for_test(&self) -> Result<Vec<EmbedderEvent>, EngineError> {
8020 self.ensure_open()?;
8021 let mut events = self
8022 .projection_runtime
8023 .shared
8024 .pending_events
8025 .lock()
8026 .map_err(|_| EngineError::Storage)?;
8027 let out = std::mem::take(&mut *events);
8028 Ok(out)
8029 }
8030
8031 /// 0.7.2 PR-2b — NON-test observation seam. Drains and returns every
8032 /// `EmbedderEvent` queued since the last drain (mean pin, manual mean
8033 /// recompute). Production callers use
8034 /// this to observe the synchronous recompute work; events are queued
8035 /// only AFTER the recompute transaction is durable, so a rolled-back
8036 /// recompute never surfaces. Mirrors the at-open
8037 /// `OpenReport.embedder_events` channel for the steady-state path.
8038 pub fn drain_embedder_events(&self) -> Result<Vec<EmbedderEvent>, EngineError> {
8039 self.ensure_open()?;
8040 let mut events = self
8041 .projection_runtime
8042 .shared
8043 .pending_events
8044 .lock()
8045 .map_err(|_| EngineError::Storage)?;
8046 Ok(std::mem::take(&mut *events))
8047 }
8048
8049 /// 0.7.2 PR-2b — explicit `doctor recompute-mean` path. Re-derives the
8050 /// pinned corpus mean from the current `vector_default` rows and
8051 /// re-quantizes every row, SYNCHRONOUSLY in one transaction. ALWAYS
8052 /// allowed at any corpus size — this is the ONLY mean-refresh path as of
8053 /// 0.7.2 (the automatic in-ingest drift detector was carved out / deferred
8054 /// to 0.8.x; see `dev/design/embedder.md` §0.3).
8055 ///
8056 /// Serializes against the projection workers via `commit_gate` so the
8057 /// re-quantize sees a totally-ordered history, exactly like the at-pin
8058 /// commit. Publishes a `MeanVecRecomputed { trigger: Manual }` event
8059 /// only after the transaction is durable. No-op-safe on a non-MC
8060 /// identity (returns `EmbedderNotConfigured` rather than corrupting an
8061 /// un-centered workspace).
8062 #[cfg(feature = "operator")]
8063 pub fn recompute_mean(&self) -> Result<MeanRecomputeReport, EngineError> {
8064 self.ensure_open()?;
8065 let identity = self.runtime_embedder_identity.clone();
8066 if !identity_requires_mean_centering(&identity) {
8067 return Err(EngineError::EmbedderNotConfigured);
8068 }
8069 let report = {
8070 // Hold the commit gate for the whole recompute so no projection
8071 // worker commit interleaves with the re-quantize.
8072 let _gate = self
8073 .projection_runtime
8074 .shared
8075 .commit_gate
8076 .lock()
8077 .unwrap_or_else(|p| p.into_inner());
8078 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8079 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8080 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8081 #[cfg(debug_assertions)]
8082 let fail = self
8083 .projection_runtime
8084 .shared
8085 .force_recompute_failure
8086 .swap(false, Ordering::SeqCst);
8087 #[cfg(not(debug_assertions))]
8088 let fail = false;
8089 let report = recompute_mean_in_tx_inner(&tx, &identity, fail)?;
8090 tx.commit().map_err(|_| EngineError::Storage)?;
8091 report
8092 };
8093 // Post-durable-commit publish.
8094 if let Ok(mut events) = self.projection_runtime.shared.pending_events.lock() {
8095 events.push(EmbedderEvent::MeanVecRecomputed {
8096 dim: report.dim,
8097 doc_count: report.doc_count_requantized,
8098 trigger: MeanRecomputeTrigger::Manual,
8099 });
8100 }
8101 Ok(report)
8102 }
8103
8104 /// 0.7.2 PR-2bc S1 fix-1 test seam — RAISE the phase-2 rerank `LIMIT`
8105 /// above the production `SEARCH_RERANK_LIMIT` (10) so the recall harness
8106 /// can pull top-(10+slack) and exclude the self-retrieving query-source
8107 /// doc before truncating to 10. The search path clamps the stored value
8108 /// to the production floor, so a test can never shrink search fanout
8109 /// below production semantics. Production reads the same atomic and never
8110 /// consults any env var.
8111 #[doc(hidden)]
8112 pub fn set_search_limit_for_test(&self, limit: usize) {
8113 self.projection_runtime.shared.search_limit_override.store(limit, Ordering::SeqCst);
8114 }
8115
8116 /// Slice 10 / G12-recency test seam — flip the dedicated recency-reweight
8117 /// flag (off by default). The reweight runs AFTER bit-KNN on the fused hits;
8118 /// it is never a vec0 predicate and is NOT `fusion_mode`.
8119 #[doc(hidden)]
8120 pub fn set_recency_reweight_enabled_for_test(&self, enabled: bool) {
8121 self.projection_runtime.shared.recency_reweight_enabled.store(enabled, Ordering::SeqCst);
8122 }
8123
8124 /// 0.8.16 Slice 5 / F9 test seam — flip the dedicated importance/confidence
8125 /// reweight flag (off by default). The reweight runs AFTER bit-KNN + RRF on
8126 /// the fused hits (multiplicative-on-fused, `NULL ⇒ neutral`); it is never a
8127 /// vec0 predicate and is NOT `fusion_mode`. Mirrors
8128 /// `set_recency_reweight_enabled_for_test`.
8129 #[doc(hidden)]
8130 pub fn set_importance_reweight_enabled_for_test(&self, enabled: bool) {
8131 self.projection_runtime.shared.importance_reweight_enabled.store(enabled, Ordering::SeqCst);
8132 }
8133
8134 /// 0.8.16 Slice 5 / F9 (R-F9-1) — set the caller-supplied `importance` ranking
8135 /// scalar on the `canonical_nodes` row identified by `write_cursor` (the
8136 /// interim id `SearchHit.id` carries). Validates `importance ∈ [0.0, 1.0]`,
8137 /// mirroring the existing `canonical_edges.confidence` write-path check —
8138 /// an out-of-range value is a deterministic [`EngineError::WriteValidation`].
8139 ///
8140 /// The 3-way sentinel: NOT calling this leaves the column `NULL` (never
8141 /// assigned = graceful-absent, ranks NEUTRAL); `0.0` is the explicit floor;
8142 /// `(0.0, 1.0]` is an explicit importance. Importance is a caller-supplied
8143 /// scalar — the engine does NOT compute graph-centrality importance (ADR §4
8144 /// non-goal). Engine-internal minimal surface for this keystone; SDK (Py/TS)
8145 /// exposure is a Slice-40 concern.
8146 pub fn write_node_importance(
8147 &self,
8148 write_cursor: u64,
8149 importance: f64,
8150 ) -> Result<(), EngineError> {
8151 if !importance.is_finite() || !(0.0..=1.0).contains(&importance) {
8152 return Err(EngineError::WriteValidation);
8153 }
8154 self.ensure_open()?;
8155 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8156 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8157 connection
8158 .execute(
8159 "UPDATE canonical_nodes SET importance = ?1 WHERE write_cursor = ?2",
8160 params![importance, write_cursor],
8161 )
8162 .map_err(|_| EngineError::Storage)?;
8163 Ok(())
8164 }
8165
8166 /// 0.8.16 Slice 5 / F9 (R-F9-1) — read back the `importance` scalar for the
8167 /// `canonical_nodes` row identified by `write_cursor`. `None` = SQL `NULL` =
8168 /// never assigned (graceful-absent). The reciprocal read for
8169 /// [`Engine::write_node_importance`].
8170 pub fn node_importance(&self, write_cursor: u64) -> Result<Option<f64>, EngineError> {
8171 self.ensure_open()?;
8172 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8173 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8174 connection
8175 .query_row(
8176 "SELECT importance FROM canonical_nodes WHERE write_cursor = ?1 LIMIT 1",
8177 params![write_cursor],
8178 |r| r.get::<_, Option<f64>>(0),
8179 )
8180 .map_err(|_| EngineError::Storage)
8181 }
8182
8183 /// GA-2 / Slice-40 (◆ B-1) measurement seam — make `search()` return the
8184 /// pre-fusion VECTOR-branch ranking (the ANN+ bit-KNN K=192 + f32 rerank
8185 /// signal) instead of the unconditional RRF-fused result, so the eu7 recall
8186 /// gate (AC-075) can measure ANN-quantization FIDELITY — vector top-10 vs
8187 /// the exact-f32 VECTOR top-10 ground truth — in isolation. Off by default;
8188 /// never set on any production path. This is NOT a `fusion_mode` knob:
8189 /// production RRF fusion stays unconditional and `fuse_rrf`/`rerank_fused`/
8190 /// recency are unchanged. Mirrors `set_recency_reweight_enabled_for_test`
8191 /// (release-available, since eu7 runs in `--release`).
8192 #[doc(hidden)]
8193 pub fn set_vector_stage_only_for_test(&self, enabled: bool) {
8194 self.projection_runtime.shared.vector_stage_only_for_test.store(enabled, Ordering::SeqCst);
8195 }
8196
8197 /// 0.7.2 PR-2b test seam — arm a one-shot fault inside the NEXT
8198 /// `recompute_mean` so it errors after the `mean_vec` UPDATE but before
8199 /// the re-quantize completes. Proves the recompute tx rolls back whole.
8200 #[doc(hidden)]
8201 #[cfg(debug_assertions)]
8202 pub fn force_next_recompute_failure_for_test(&self) {
8203 self.projection_runtime.shared.force_recompute_failure.store(true, Ordering::SeqCst);
8204 }
8205
8206 #[doc(hidden)]
8207 pub fn vector_row_count_for_test(&self) -> Result<u64, EngineError> {
8208 self.ensure_open()?;
8209 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8210 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8211 connection
8212 .query_row("SELECT COUNT(*) FROM vector_default", [], |row| row.get::<_, u64>(0))
8213 .map_err(|_| EngineError::Storage)
8214 }
8215
8216 #[doc(hidden)]
8217 pub fn read_vector_blob_for_test(&self, rowid: i64) -> Result<Vec<u8>, EngineError> {
8218 self.ensure_open()?;
8219 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8220 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8221 connection
8222 .query_row("SELECT embedding FROM vector_default WHERE rowid = ?1", [rowid], |row| {
8223 row.get::<_, Vec<u8>>(0)
8224 })
8225 .map_err(|_| EngineError::Storage)
8226 }
8227
8228 /// 0.8.20 Slice 15e — read a row's raw `embedding_bin` blob bytes (the
8229 /// sign-quantized vector). Used to prove the non-destructive reshape copies the
8230 /// bits VERBATIM (condition #4): the pre-reshape and post-reshape bytes must be
8231 /// byte-identical.
8232 #[doc(hidden)]
8233 pub fn read_vector_bin_for_test(&self, rowid: i64) -> Result<Vec<u8>, EngineError> {
8234 self.ensure_open()?;
8235 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8236 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8237 connection
8238 .query_row(
8239 "SELECT embedding_bin FROM vector_default WHERE rowid = ?1",
8240 [rowid],
8241 |row| row.get::<_, Vec<u8>>(0),
8242 )
8243 .map_err(|_| EngineError::Storage)
8244 }
8245
8246 /// 0.8.20 Slice 15e — run an arbitrary read-only SELECT on the ENGINE
8247 /// connection (which has the vec0 extension loaded, unlike a bare
8248 /// `Connection::open`) and collect column 0 as `i64`. Lets a test run a
8249 /// phase-1-style KNN `MATCH ... {attr clause}` and observe which `rowid`s
8250 /// survive the pre-KNN filter.
8251 #[doc(hidden)]
8252 pub fn query_i64_col_for_test(&self, sql: &str) -> Result<Vec<i64>, EngineError> {
8253 self.ensure_open()?;
8254 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8255 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8256 let mut stmt = connection.prepare(sql).map_err(|_| EngineError::Storage)?;
8257 let rows =
8258 stmt.query_map([], |row| row.get::<_, i64>(0)).map_err(|_| EngineError::Storage)?;
8259 rows.collect::<rusqlite::Result<Vec<i64>>>().map_err(|_| EngineError::Storage)
8260 }
8261
8262 /// 0.8.20 Slice 15e — as [`query_i64_col_for_test`] but collects column 0 as
8263 /// `String` (e.g. an `attr_<hex>` metadata column's stored value).
8264 #[doc(hidden)]
8265 pub fn query_text_col_for_test(&self, sql: &str) -> Result<Vec<String>, EngineError> {
8266 self.ensure_open()?;
8267 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8268 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8269 let mut stmt = connection.prepare(sql).map_err(|_| EngineError::Storage)?;
8270 let rows =
8271 stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|_| EngineError::Storage)?;
8272 rows.collect::<rusqlite::Result<Vec<String>>>().map_err(|_| EngineError::Storage)
8273 }
8274
8275 #[doc(hidden)]
8276 pub fn default_embedder_profile_for_test(&self) -> Result<EmbedderIdentity, EngineError> {
8277 self.ensure_open()?;
8278 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8279 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8280 load_default_profile(connection).map_err(|_| EngineError::Storage)
8281 }
8282
8283 /// Doctor read-only integrity report. Three-section output per
8284 /// AC-043a/b. `opts.full` adds `PRAGMA integrity_check`. `quick` and
8285 /// `round_trip` are accepted but treated as default for 0.6.0.
8286 #[cfg(feature = "operator")]
8287 pub fn check_integrity(
8288 &self,
8289 opts: CheckIntegrityOpts,
8290 ) -> Result<IntegrityReport, EngineError> {
8291 self.ensure_open()?;
8292 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8293 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8294 Ok(IntegrityReport {
8295 physical: physical_section(connection, opts.full),
8296 logical: logical_section(connection),
8297 semantic: semantic_section(connection),
8298 })
8299 }
8300
8301 /// Doctor bit-preserving export. Runs `VACUUM INTO` to produce a
8302 /// self-contained SQLite file at `out`, computes SHA-256 of the
8303 /// resulting bytes, and writes a JSON manifest at `manifest`. Per
8304 /// AC-039a/b.
8305 #[cfg(feature = "operator")]
8306 pub fn safe_export(
8307 &self,
8308 out: &Path,
8309 manifest: &Path,
8310 ) -> Result<SafeExportArtifact, EngineError> {
8311 self.ensure_open()?;
8312 {
8313 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8314 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8315 let target = out.to_string_lossy().to_string();
8316 connection
8317 .execute("VACUUM INTO ?1", params![target])
8318 .map_err(|_| EngineError::Storage)?;
8319 }
8320 let bytes = std::fs::read(out).map_err(|_| EngineError::Storage)?;
8321 let digest = sha2::Sha256::digest(&bytes);
8322 let sha256_hex = hex_encode(digest.as_slice());
8323 let export_abs = out.canonicalize().unwrap_or_else(|_| out.to_path_buf());
8324 let manifest_json = serde_json::json!({
8325 "export_path": export_abs.to_string_lossy(),
8326 "sha256": sha256_hex,
8327 "byte_count": bytes.len() as u64,
8328 });
8329 let manifest_bytes =
8330 serde_json::to_vec_pretty(&manifest_json).map_err(|_| EngineError::Storage)?;
8331 std::fs::write(manifest, &manifest_bytes).map_err(|_| EngineError::Storage)?;
8332 Ok(SafeExportArtifact {
8333 export_path: out.to_path_buf(),
8334 manifest_path: manifest.to_path_buf(),
8335 manifest_sha256: sha256_hex,
8336 })
8337 }
8338
8339 /// Operator regenerate workflow per `dev/design/projections.md`
8340 /// § Regenerate workflow. Drains in-flight projection work, then
8341 /// truncates FTS5 + vec0 shadow rows, resets the projection cursor,
8342 /// and lets the scheduler re-enqueue every canonical row. Durable
8343 /// `projection_failures` audit rows are preserved per design. AC-044
8344 /// + AC-063c.
8345 #[cfg(feature = "operator")]
8346 pub fn rebuild_projections(&self) -> Result<RebuildReport, EngineError> {
8347 self.ensure_open()?;
8348 self.run_rebuild(true, RebuildKind::Projections)
8349 }
8350
8351 /// Vec0-only variant of [`Engine::rebuild_projections`]. Leaves
8352 /// FTS5 shadow content untouched; per recovery design,
8353 /// `recover --rebuild-vec0` is the surface for vec0-only repair.
8354 #[cfg(feature = "operator")]
8355 pub fn rebuild_vec0(&self) -> Result<RebuildReport, EngineError> {
8356 self.ensure_open()?;
8357 self.run_rebuild(false, RebuildKind::Vec0)
8358 }
8359
8360 /// Phase 9 Pack B / AC-042 source trace. Returns the canonical-row
8361 /// id set produced by `source_id`, ordered by `write_cursor`. Empty
8362 /// string is not a valid `source_id`; rows with NULL `source_id`
8363 /// are excluded from every result.
8364 #[cfg(feature = "operator")]
8365 pub fn trace_source_ref(&self, source_id: &str) -> Result<TraceReport, EngineError> {
8366 self.ensure_open()?;
8367 if source_id.is_empty() {
8368 return Err(EngineError::WriteValidation);
8369 }
8370 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8371 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8372
8373 let mut events: Vec<TraceEvent> = Vec::new();
8374 let mut nodes = connection
8375 .prepare(
8376 "SELECT write_cursor, kind FROM canonical_nodes WHERE source_id = ?1
8377 ORDER BY write_cursor",
8378 )
8379 .map_err(|_| EngineError::Storage)?;
8380 let node_rows = nodes
8381 .query_map([source_id], |row| {
8382 Ok(TraceEvent {
8383 write_cursor: row.get::<_, i64>(0)? as u64,
8384 kind: row.get::<_, String>(1)?,
8385 table: "canonical_nodes",
8386 })
8387 })
8388 .map_err(|_| EngineError::Storage)?;
8389 for row in node_rows {
8390 events.push(row.map_err(|_| EngineError::Storage)?);
8391 }
8392
8393 let mut edges = connection
8394 .prepare(
8395 "SELECT write_cursor, kind FROM canonical_edges WHERE source_id = ?1
8396 ORDER BY write_cursor",
8397 )
8398 .map_err(|_| EngineError::Storage)?;
8399 let edge_rows = edges
8400 .query_map([source_id], |row| {
8401 Ok(TraceEvent {
8402 write_cursor: row.get::<_, i64>(0)? as u64,
8403 kind: row.get::<_, String>(1)?,
8404 table: "canonical_edges",
8405 })
8406 })
8407 .map_err(|_| EngineError::Storage)?;
8408 for row in edge_rows {
8409 events.push(row.map_err(|_| EngineError::Storage)?);
8410 }
8411
8412 events.sort_by_key(|e| e.write_cursor);
8413 Ok(TraceReport { source_ref: source_id.to_string(), events })
8414 }
8415
8416 /// OPP-12 Phase-1 (0.8.19 Slice 10) — resolve a lifecycle-verb id argument to
8417 /// the BARE `logical_id` it addresses, enforcing `Logical`(`l:`)-only
8418 /// addressability (design §3). An untagged string is taken as a bare
8419 /// `logical_id` (the `l:` form); an explicit `l:`-prefixed string is stripped
8420 /// to its value; a `Content`(`h:`) or `Passage`(`p:`) id is a typed
8421 /// [`EngineError::NotLifecycleAddressable`] refusal (never a panic / no-op).
8422 fn resolve_lifecycle_target(id: &str) -> Result<String, EngineError> {
8423 match IdSpace::parse(id) {
8424 Some(parsed) => match parsed.space {
8425 IdSpaceKind::Logical => Ok(parsed.value),
8426 other => Err(EngineError::NotLifecycleAddressable { id_space: other }),
8427 },
8428 // Untagged — no id-space prefix; treat as a bare logical_id (l: space).
8429 None => Ok(id.to_string()),
8430 }
8431 }
8432
8433 /// OPP-12 Phase-1 (0.8.19 Slice 10, R-TR-1/2) — move a governed node between
8434 /// existence states per the engine-enforced legal-transition table (design
8435 /// §2): promote `pending→active`, reject `pending→deleted`, soft-delete
8436 /// `active→deleted`, undelete `deleted→active`. `to_state` is a full
8437 /// [`LifecycleState`], but `Pending` (create-time only) and `Purged`
8438 /// (`purge`-only) are never legal `transition` targets, nor are self-loops or
8439 /// any move from a non-existent/`purged` row — each returns a typed
8440 /// [`EngineError::IllegalTransition`] enumerating the legal targets.
8441 ///
8442 /// `reason` semantics (design §3 gap-6): promote/undelete CLEAR `reason` to
8443 /// `NULL` (the row is admitted; no standing cause); reject/soft-delete SET
8444 /// `reason` to the supplied value (`NULL` allowed but the delete-family
8445 /// expects it). `reason` is advisory — the engine never interprets it.
8446 ///
8447 /// Keys on the BARE `logical_id` (`l:` space only); a `Content`(`h:`) or
8448 /// `Passage`(`p:`) id raises [`EngineError::NotLifecycleAddressable`].
8449 /// The state flip mutates the single active (`superseded_at IS NULL`) row; a
8450 /// `deleted` row STAYS node-FTS / vector indexed (gap-5) — only the
8451 /// `state='active'` default filter excludes those shadows, so an undelete
8452 /// needs no re-projection there.
8453 ///
8454 /// 0.8.20 Slice 15d fix-2 [P2] — the row-owned ATTRIBUTE projection
8455 /// (`canonical_attributes` / `property_search_index`) is the exception: it has
8456 /// NO read-side lifecycle filter (the property-FTS5 table cannot carry one), so
8457 /// it is maintained AT REST to track the backfill's set
8458 /// (projected ⟺ active ∧ non-superseded). Promote/undelete PROJECT the declared
8459 /// attributes; soft-delete PURGES them; reject is a no-op.
8460 pub fn transition(
8461 &self,
8462 logical_id: &str,
8463 to_state: LifecycleState,
8464 reason: Option<String>,
8465 ) -> Result<(), EngineError> {
8466 self.ensure_open()?;
8467 let lid = Self::resolve_lifecycle_target(logical_id)?;
8468
8469 // Settle in-flight projection work first: the async projection worker
8470 // commits vector/FTS shadows on its OWN connection, so a state flip issued
8471 // while a worker holds the write lock would SQLITE_BUSY. Draining
8472 // (unfrozen so any unprojected row completes) leaves the worker idle; a
8473 // bare state flip enqueues no new projection work.
8474 //
8475 // Slice 40 B3 aligns the worker with `commit_batch`:
8476 // `commit_projection_outcomes` acquires `BEGIN IMMEDIATE` before its reads.
8477 // This drain remains load-bearing because the worker owns a separate
8478 // connection while this state flip still reads before its own write; it
8479 // keeps that deferred transaction out of the worker's write window.
8480 self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS)?;
8481
8482 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8483 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8484 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8485
8486 // The lifecycle state lives on the single active (superseded_at IS NULL)
8487 // version; a `deleted` row is still that active version, just flagged.
8488 // fix-2 [P2] — also read its `write_cursor` + `body` so the row-owned
8489 // attribute projection can be maintained after the state flip.
8490 let current: Option<(String, i64, String)> = tx
8491 .query_row(
8492 "SELECT state, write_cursor, body FROM canonical_nodes \
8493 WHERE logical_id = ?1 AND superseded_at IS NULL",
8494 params![lid],
8495 |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, String>(2)?)),
8496 )
8497 .optional()
8498 .map_err(|_| EngineError::Storage)?;
8499
8500 // A missing active row is an absent/purged node — the terminal `Purged`
8501 // state for legality purposes (nothing is a legal target from there).
8502 let from_state = match ¤t {
8503 Some((s, _, _)) => LifecycleState::from_str_opt(s).ok_or(EngineError::Storage)?,
8504 None => LifecycleState::Purged,
8505 };
8506
8507 if !is_legal_transition_move(from_state, to_state) {
8508 return Err(EngineError::IllegalTransition {
8509 from_state,
8510 to_state,
8511 legal: from_state.legal_next_states(),
8512 });
8513 }
8514
8515 if matches!(to_state, LifecycleState::Active) {
8516 if let Some((_, _, body)) = ¤t {
8517 validate_nested_projection_sources_for_body(&tx, body)?;
8518 }
8519 }
8520
8521 // Admit (promote/undelete) → clear reason; exclude (reject/soft-delete) →
8522 // set the supplied reason. `to_state` is Active or Deleted here.
8523 let new_reason: Option<String> = match to_state {
8524 LifecycleState::Active => None,
8525 _ => reason,
8526 };
8527 tx.execute(
8528 "UPDATE canonical_nodes SET state = ?1, reason = ?2 \
8529 WHERE logical_id = ?3 AND superseded_at IS NULL",
8530 params![to_state.as_str(), new_reason, lid],
8531 )
8532 .map_err(|_| EngineError::Storage)?;
8533
8534 // fix-2 [P2] — maintain the row-owned attribute projection so it keeps
8535 // tracking the backfill's set (projected ⟺ active ∧ non-superseded). The
8536 // transitioned row is the single non-superseded version, so the invariant
8537 // reduces to `projected ⟺ to_state == Active`. We PURGE unconditionally
8538 // (idempotent — a no-op on the never-projected pending / already-purged
8539 // deleted arms) then RE-PROJECT when landing `Active`. This covers every
8540 // legal move: promote (pending→active) projects the withheld attributes;
8541 // soft-delete (active→deleted) purges; undelete (deleted→active)
8542 // re-projects; reject (pending→deleted) is a no-op. The property tables
8543 // (`canonical_attributes` / `property_search_index`) carry NO read-side
8544 // lifecycle filter — unlike node-FTS / vector shadows, which the canonical
8545 // read path already excludes when non-active — so they MUST be maintained
8546 // at rest, the same rationale as fix-1's purge-on-supersede. Node-FTS /
8547 // vector shadows are deliberately left intact (gap-5: a deleted row STAYS
8548 // indexed; only the `state='active'` default read filter hides it).
8549 if let Some((cursor, body)) = current.as_ref().map(|(_, c, b)| (*c, b.as_str())) {
8550 purge_row_projections_for_cursor_in(
8551 &tx,
8552 cursor,
8553 &[ProjectionClass::Attribute, ProjectionClass::PropertyFts],
8554 )
8555 .map_err(|_| EngineError::Storage)?;
8556 if matches!(to_state, LifecycleState::Active) {
8557 project_node_attributes(&tx, cursor, body).map_err(|_| EngineError::Storage)?;
8558 refresh_vector_attr_values_for_row(&tx, cursor, body)
8559 .map_err(|_| EngineError::Storage)?;
8560 }
8561 }
8562 tx.commit().map_err(|_| EngineError::Storage)?;
8563 self.counters.record_admin();
8564 Ok(())
8565 }
8566
8567 /// 0.8.20 Slice 15d (R-20-PR / C-1) — the projection registry as a
8568 /// DECLARATIVE, IDEMPOTENT apply. The engine is the SOLE projection authority
8569 /// (Q3): it diffs the supplied `specs` against the durable registry and
8570 /// backfills the difference in ONE transaction. Cheap projections
8571 /// (`filterable`, `searchable→FTS`) are built same-transaction; `rankable`
8572 /// and the `searchable→vector` sub-target are PERSISTED but deferred (F9 /
8573 /// Slice 20) — declaring them never errors (graceful-absent, Q6a).
8574 ///
8575 /// 0.8.20 Slice 23 (`R-20-SV`) — **SPEC VALIDATION.** A spec that carries an
8576 /// `fts` or `vector` sub-object WITHOUT [`ProjectionRole::Searchable`] is an
8577 /// INVALID SPEC and is refused with [`EngineError::WriteValidation`] (HITL
8578 /// 2026-07-24; see [`apply_projection_config`] for the full rationale). A
8579 /// rejected request is a TOTAL no-op. `read_projections` is unaffected — it
8580 /// is a pure read — so a LEGACY row in that shape still reports verbatim but
8581 /// can no longer be re-applied.
8582 ///
8583 /// `drop` is EXPLICIT (C3, `api-surface.md:27`): omission of a live
8584 /// projection from `specs` does NOT drop it; removal requires naming it in
8585 /// `drop`. An incompatible/destructive change to a live projection that is
8586 /// NOT in `drop` is refused with [`EngineError::ProjectionDestructive`], the
8587 /// destructive delta surfaced — never silent data loss. Re-applying an
8588 /// unchanged spec diffs to a no-op ([`ProjectionDelta::unchanged`]).
8589 ///
8590 /// Pair with [`Engine::read_projections`] to see current state before
8591 /// applying.
8592 pub fn configure_projections(
8593 &self,
8594 specs: &[ProjectionSpec],
8595 drop: &[String],
8596 ) -> Result<ProjectionDelta, EngineError> {
8597 self.ensure_open()?;
8598 // Settle in-flight async projection work first. The worker commits on its
8599 // own connection with `BEGIN IMMEDIATE`; a backfill issued in that write
8600 // window would SQLITE_BUSY.
8601 self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS)?;
8602
8603 // 0.8.20 Slice 20c (R-20-DR remainder) — the backfill is gated on a LIVE
8604 // embedder. With `EmbedderChoice::None` there is no dense arm, so the
8605 // declaration persists and DEFERS (Q6a graceful-absent, exactly like
8606 // `rankable`) rather than queueing embeds that could only retry to a
8607 // `failed` terminal. Re-applying the same spec in a session that HAS an
8608 // embedder grafts the backfill on — the shipped graceful-graft contract.
8609 let dense_arm_live = self.runtime_embedder.is_some();
8610 let (delta, enqueued_backfill) = {
8611 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8612 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8613 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8614 let applied = apply_projection_config(&tx, specs, drop, dense_arm_live)?;
8615 tx.commit().map_err(|_| EngineError::Storage)?;
8616 applied
8617 };
8618 // 0.8.20 Slice 20c (R-20-DR remainder) — the C4 rider's second half. The
8619 // enrolment + terminal-clear committed above; now WAKE the dispatcher, or
8620 // it sleeps on `pending_scan == false` and the very next `drain` burns its
8621 // whole timeout waiting for work nobody scheduled. `drain` itself stays
8622 // PASSIVE (a barrier, never a trigger) — the notify belongs here, on the
8623 // enqueue side. Deliberately after the connection guard is dropped: the
8624 // dispatcher immediately opens its own connection to scan.
8625 if enqueued_backfill {
8626 self.projection_runtime.notify_new_work();
8627 }
8628 self.counters.record_admin();
8629 Ok(delta)
8630 }
8631
8632 /// 0.8.20 Slice 15d (R-20-PR) — read the current projection registry (C5
8633 /// introspection: `read.projections`). Returns every declared
8634 /// [`ProjectionSpec`] sorted by name, so a caller can inspect current state
8635 /// (and the destructive delta a change would cause) BEFORE applying. Pure
8636 /// read; never mutates.
8637 ///
8638 /// 0.8.20 Slice 20 (R-20-DR) — this is ALSO the surface that populates the
8639 /// engine-set [`ProjectionVector::dense_readiness`] READ METADATA. It is
8640 /// derived here, on the way out (see [`derive_dense_readiness`]); the durable
8641 /// registry stores no readiness. Only a spec that declares the
8642 /// `searchable→vector` sub-object carries one — `filterable` and
8643 /// `searchable→FTS` are same-transaction and have no readiness axis.
8644 pub fn read_projections(&self) -> Result<Vec<ProjectionSpec>, EngineError> {
8645 self.ensure_open()?;
8646 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8647 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8648 let registry = load_projection_registry(connection).map_err(|_| EngineError::Storage)?;
8649 // Derived ONCE per call, so every vector projection in one read reports a
8650 // consistent readiness (they are all served by the one vector pipeline).
8651 // Skipped entirely when no vector projection is declared, keeping the
8652 // no-vector default path free of the extra probe.
8653 let mut readiness: Option<DenseReadiness> = None;
8654 let mut specs: Vec<ProjectionSpec> =
8655 registry.iter().map(|(name, stored)| stored.to_spec(name)).collect();
8656 for spec in &mut specs {
8657 if let Some(vector) = spec.vector.as_mut() {
8658 let value = match readiness {
8659 Some(value) => value,
8660 None => {
8661 let value = derive_dense_readiness(connection)?;
8662 readiness = Some(value);
8663 value
8664 }
8665 };
8666 vector.dense_readiness = Some(value);
8667 }
8668 }
8669 Ok(specs)
8670 }
8671
8672 /// OPP-12 Phase-1 (0.8.19 Slice 10, R-PG-1/2) — irreversibly hard-erase a
8673 /// governed node. A SEPARATE verb from [`Engine::transition`] (NOT on the
8674 /// `recovery_denylist`). Precondition: DELETED-FIRST — legal only from
8675 /// `deleted` (else a typed [`EngineError::IllegalTransition`] to `purged`);
8676 /// IDEMPOTENT — purging an already-absent/already-purged id is a no-op
8677 /// success. Keys on the bare `logical_id` (`l:` only); `h:`/`p:` →
8678 /// [`EngineError::NotLifecycleAddressable`].
8679 ///
8680 /// In ONE transaction, physically erases every ROW-OWNED target for the node
8681 /// (design §3 / gap-3): all `canonical_nodes` versions; its `search_index`,
8682 /// `search_index_edges`, `search_index_v2` FTS rows; its `vector_default`
8683 /// (vec0) + `_fathomdb_vector_rows` vectors; its `_fathomdb_projection_terminal`
8684 /// bookkeeping; and — CASCADE-REMOVE, no content-free stubs — every
8685 /// `canonical_edges` row touching it (`from_id`/`to_id`) plus those edges'
8686 /// projection shadows. The global/kind-level registries
8687 /// `_fathomdb_projection_state` and `_fathomdb_vector_kinds` are NOT keyed to
8688 /// a node id and are DELIBERATELY untouched.
8689 ///
8690 /// Erasure completeness relies on the standing `PRAGMA secure_delete=ON`
8691 /// (design §3 gap-4) which zeroes every freed page — so no per-purge `VACUUM`.
8692 /// (Freelist content written on a pre-20 DB before `secure_delete` was on is a
8693 /// documented residual; there is no forced migration-time `VACUUM`.)
8694 pub fn purge(&self, logical_id: &str) -> Result<(), EngineError> {
8695 self.ensure_open()?;
8696 let lid = Self::resolve_lifecycle_target(logical_id)?;
8697
8698 // Drain in-flight projection work before the erase, exactly as
8699 // `excise_source` does: SQLite-WAL would otherwise let a worker that
8700 // already dequeued a job for a purged cursor commit its vec0 /
8701 // `_fathomdb_vector_rows` INSERT after our DELETE releases the writer
8702 // lock, leaving residue that defeats the erasure sweep.
8703 // Settle every pending projection FIRST (unfrozen) so no unprojected row
8704 // is left behind that a subsequent freeze would wedge `drain` on, and so
8705 // the async worker is idle. THEN freeze the scanner (no new work is queued
8706 // while we erase), confirm idle, and erase in one writer transaction.
8707 // Freezing before the first drain would stall projection of any
8708 // just-written row → `database_has_pending_projection_work` never clears →
8709 // `drain` times out into `Scheduler`.
8710 self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS)?;
8711 self.projection_runtime.set_frozen(true);
8712 let outcome = self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS).and_then(|()| self.purge_inner(&lid));
8713 self.projection_runtime.set_frozen(false);
8714 // 0.8.20 Slice 5b (R-20-E5/E6) — the rows are gone from the tables; now
8715 // finish the erasure AT REST (telemetry sink + `-wal` bytes) before
8716 // reporting success. Runs after the connection guard inside
8717 // `purge_inner` has been dropped: `complete_erasure_at_rest` re-acquires
8718 // it for the checkpoint.
8719 outcome?;
8720 self.complete_erasure_at_rest("purge")
8721 }
8722
8723 /// The erased rows' prefixed stable ids ([`IdSpace::to_prefixed`]) are NOT
8724 /// returned: they are enqueued for redaction inside this transaction (see
8725 /// [`enqueue_pending_redaction`]), because a caller-held vector is lost on the
8726 /// retry path that codex §9 P2 found.
8727 fn purge_inner(&self, lid: &str) -> Result<(), EngineError> {
8728 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8729 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8730 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8731
8732 // Precondition on the active row's state. Absent (never-created or
8733 // already-purged) → idempotent no-op success.
8734 let current: Option<String> = tx
8735 .query_row(
8736 "SELECT state FROM canonical_nodes \
8737 WHERE logical_id = ?1 AND superseded_at IS NULL",
8738 params![lid],
8739 |r| r.get::<_, String>(0),
8740 )
8741 .optional()
8742 .map_err(|_| EngineError::Storage)?;
8743 let from_state = match current {
8744 None => {
8745 // Idempotent: nothing to erase.
8746 tx.commit().map_err(|_| EngineError::Storage)?;
8747 return Ok(());
8748 }
8749 Some(s) => LifecycleState::from_str_opt(&s).ok_or(EngineError::Storage)?,
8750 };
8751 if from_state != LifecycleState::Deleted {
8752 // Deleted-first precondition. Dropping `tx` rolls back (no-op read).
8753 return Err(EngineError::IllegalTransition {
8754 from_state,
8755 to_state: LifecycleState::Purged,
8756 legal: from_state.legal_next_states(),
8757 });
8758 }
8759
8760 // Collect every version cursor for the node, plus every cursor of an edge
8761 // that touches it (either endpoint), across ALL versions — the projection
8762 // shadow tables are keyed by these per-row `write_cursor`s.
8763 let node_cursors: Vec<i64> = {
8764 let mut stmt = tx
8765 .prepare("SELECT write_cursor FROM canonical_nodes WHERE logical_id = ?1")
8766 .map_err(|_| EngineError::Storage)?;
8767 let rows = stmt
8768 .query_map(params![lid], |row| row.get::<_, i64>(0))
8769 .map_err(|_| EngineError::Storage)?;
8770 rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
8771 };
8772 let edge_cursors: Vec<i64> = {
8773 let mut stmt = tx
8774 .prepare(
8775 "SELECT write_cursor FROM canonical_edges \
8776 WHERE from_id = ?1 OR to_id = ?1",
8777 )
8778 .map_err(|_| EngineError::Storage)?;
8779 let rows = stmt
8780 .query_map(params![lid], |row| row.get::<_, i64>(0))
8781 .map_err(|_| EngineError::Storage)?;
8782 rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
8783 };
8784
8785 // 0.8.20 Slice 5b (R-20-E6) — the stable ids the telemetry sink may have
8786 // persisted for these rows, collected BEFORE the DELETEs.
8787 let erased_stable_ids = collect_erased_stable_ids(
8788 &tx,
8789 "SELECT logical_id, body FROM canonical_nodes WHERE logical_id = ?1",
8790 "SELECT logical_id, body FROM canonical_edges WHERE from_id = ?1 OR to_id = ?1",
8791 lid,
8792 )?;
8793
8794 // Erase the row-owned projection shadows for every collected cursor.
8795 // 0.8.20 Slice 5a (R-20-E1): registry-driven — the hand-rolled delete
8796 // list is gone, so a newly registered projection table is erased here
8797 // without touching this site. vec0 rowid == the canonical row's
8798 // write_cursor (see `_fathomdb_vector_rows`).
8799 for cursor in node_cursors.iter().chain(edge_cursors.iter()) {
8800 erase_row_projections(&tx, *cursor).map_err(|_| EngineError::Storage)?;
8801 }
8802
8803 // Erase the canonical rows: all node versions + all touching edges
8804 // (gap-3 CASCADE-REMOVE — no content-free stubs in Phase-1).
8805 tx.execute("DELETE FROM canonical_nodes WHERE logical_id = ?1", params![lid])
8806 .map_err(|_| EngineError::Storage)?;
8807 tx.execute("DELETE FROM canonical_edges WHERE from_id = ?1 OR to_id = ?1", params![lid])
8808 .map_err(|_| EngineError::Storage)?;
8809
8810 // 0.8.20 Slice 5 fix-1 (codex §9 P2) — durably record the redaction this
8811 // erasure now owes, atomically with the deletes above. Only when a sink
8812 // is attached: with telemetry never enabled there is no file the ids
8813 // could have leaked into, so there is nothing to owe.
8814 let pending_cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
8815 let enqueued =
8816 self.telemetry_enabled.load(Ordering::Acquire) && !erased_stable_ids.is_empty();
8817 if enqueued {
8818 enqueue_pending_redaction(&tx, "purge", &erased_stable_ids, pending_cursor)?;
8819 }
8820
8821 tx.commit().map_err(|_| EngineError::Storage)?;
8822 if enqueued {
8823 self.next_cursor.store(pending_cursor, Ordering::SeqCst);
8824 }
8825 self.counters.record_admin();
8826 Ok(())
8827 }
8828
8829 /// 0.8.20 Slice 5d (R-20-E4, design §4 item 9b) — the **governed SDK
8830 /// erasure verb**. Deletes every canonical row attributable to `source_id`,
8831 /// plus its row-owned projections, and finishes the erasure at rest.
8832 ///
8833 /// This is NOT `operator`-gated: erasing content a consumer wrote is an
8834 /// application obligation, not a recovery workflow. Before this slice the
8835 /// only erasure path was [`Engine::excise_source`], which lives behind the
8836 /// operator feature (i.e. the CLI), so an SDK-only consumer holding a
8837 /// deletion obligation over ANONYMOUS content — content with no
8838 /// `logical_id`, therefore not reachable by [`Engine::purge`] — had no way
8839 /// to discharge it at all. That gap is what R-20-E4 closes.
8840 ///
8841 /// **One engine path.** `erase_source` and `excise_source` are the SAME
8842 /// operation: both delegate to [`Engine::erase_source_shared`]. They are
8843 /// not competing implementations, and no behaviour is duplicated.
8844 ///
8845 /// **Validation differs, deliberately.** `erase_source` admits only ids
8846 /// [`SourceId::new`] would admit, so a caller cannot aim the governed verb
8847 /// at the engine's reserved `_`-prefixed namespace (`_engine:*` substrate,
8848 /// or the `_legacy:pre-0.8.20` cohort migration step 21 back-filled — a
8849 /// single call against which would erase every pre-0.8.20 anonymous row).
8850 /// `excise_source` stays permissive precisely BECAUSE it is the recovery
8851 /// seam: R-20-E8 requires an operator to be able to excise `_legacy:`.
8852 ///
8853 /// **Not a recovery verb.** `erase_source` carries no REQ-054
8854 /// recovery-denylist name (`{recover, restore, repair, fix, rebuild}`); it
8855 /// is a lifecycle verb alongside `transition`/`purge`. AC-041 is unaffected.
8856 ///
8857 /// # Errors
8858 ///
8859 /// [`EngineError::WriteValidation`] for an empty, whitespace-only or
8860 /// reserved `source_id`; [`EngineError::ErasureIncomplete`] if the erasure
8861 /// could not be completed at rest (see [`Engine::complete_erasure_at_rest`]).
8862 pub fn erase_source(&self, source_id: &str) -> Result<ExciseReport, EngineError> {
8863 // Construct-to-validate: reuse the newtype's rule rather than restating
8864 // it, so the erasure boundary and the write boundary cannot drift.
8865 let _validated = SourceId::new(source_id)?;
8866 self.erase_source_shared("erase_source", source_id)
8867 }
8868
8869 /// Phase 9 Pack B / AC-028a/b/c source excise — the **operator/recovery**
8870 /// spelling of [`Engine::erase_source`], sharing one engine path with it.
8871 ///
8872 /// Kept `operator`-gated and kept permissive about reserved ids: this is
8873 /// the seam an operator uses to excise `_legacy:pre-0.8.20` (R-20-E8) or
8874 /// `_engine:*` substrate, which the governed SDK verb refuses.
8875 #[cfg(feature = "operator")]
8876 pub fn excise_source(&self, source_id: &str) -> Result<ExciseReport, EngineError> {
8877 if source_id.is_empty() {
8878 self.ensure_open()?;
8879 return Err(EngineError::WriteValidation);
8880 }
8881 self.erase_source_shared("excise_source", source_id)
8882 }
8883
8884 /// The single erasure implementation behind [`Engine::erase_source`] and
8885 /// [`Engine::excise_source`]. `verb` names the caller for the telemetry
8886 /// redaction record only; the deletion semantics are identical.
8887 ///
8888 /// Non-perturbation: rows from other sources (and rows with NULL
8889 /// `source_id`) are untouched; the projection cursor is NOT reset
8890 /// and no blanket projection rebuild is issued.
8891 fn erase_source_shared(
8892 &self,
8893 verb: &'static str,
8894 source_id: &str,
8895 ) -> Result<ExciseReport, EngineError> {
8896 self.ensure_open()?;
8897
8898 // Drain MUST succeed before the excise transaction. SQLite-WAL
8899 // would otherwise allow a worker that already dequeued a job
8900 // for an excised cursor to commit its INSERT into vec0 /
8901 // _fathomdb_vector_rows after our DELETE releases the writer
8902 // lock, leaving residue and breaking AC-028b. Surface the
8903 // timeout instead of swallowing it (Pack A pattern).
8904 //
8905 // ORDER IS LOAD-BEARING, exactly as in `purge`: settle every pending
8906 // projection FIRST (UNFROZEN), and only THEN freeze the scanner and
8907 // confirm idle. Freezing first parks the dispatcher, so a row written
8908 // moments ago can never be scanned and enqueued — while `drain` ->
8909 // `wait_for_idle` keeps seeing it via
8910 // `database_has_pending_projection_work`, which reads the DATABASE and
8911 // not the queue. The result is that the ordinary sequence "write a
8912 // vector-indexed row, then erase it" stalls for the whole
8913 // LIFECYCLE_DRAIN_TIMEOUT_MS and fails with `Scheduler`.
8914 // (codex §9 [P2]; `erase_source_drains_before_freezing`.)
8915 self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS)?;
8916 self.projection_runtime.set_frozen(true);
8917 let drain_result = self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS);
8918 let outcome = drain_result.and_then(|()| self.excise_source_inner(verb, source_id));
8919 self.projection_runtime.set_frozen(false);
8920 // 0.8.20 Slice 5b (R-20-E5/E6) — finish the erasure AT REST before
8921 // reporting success: redact the erased stable ids out of the telemetry
8922 // sink, then truncate the `-wal` so the erased bytes are not still
8923 // readable on disk. On persistent checkpoint BUSY this returns
8924 // `ErasureIncomplete` rather than an `ExciseReport`.
8925 let report = outcome?;
8926 self.complete_erasure_at_rest(verb)?;
8927 Ok(report)
8928 }
8929
8930 /// Doctor `verify-embedder` seam (AC-040a). Compares the
8931 /// `_fathomdb_embedder_profiles` row to the operator-supplied
8932 /// `name:revision` identity + dimension; never raises on mismatch.
8933 #[cfg(feature = "operator")]
8934 pub fn verify_embedder(
8935 &self,
8936 supplied_identity: &str,
8937 supplied_dimension: u32,
8938 ) -> Result<VerifyEmbedderReport, EngineError> {
8939 self.ensure_open()?;
8940 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8941 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8942 let stored = load_default_profile(connection).map_err(|_| EngineError::Storage)?;
8943 let stored_identity = format!("{}:{}", stored.name, stored.revision);
8944 let identity_match = stored_identity == supplied_identity;
8945 let dimension_match = stored.dimension == supplied_dimension;
8946 let status = match (identity_match, dimension_match) {
8947 (true, true) => VerifyEmbedderStatus::Match,
8948 (false, true) => VerifyEmbedderStatus::IdentityMismatch,
8949 (true, false) => VerifyEmbedderStatus::DimensionMismatch,
8950 (false, false) => VerifyEmbedderStatus::BothMismatch,
8951 };
8952 Ok(VerifyEmbedderReport {
8953 stored_identity,
8954 stored_dimension: stored.dimension,
8955 supplied_identity: supplied_identity.to_string(),
8956 supplied_dimension,
8957 status,
8958 })
8959 }
8960
8961 /// Doctor `dump-schema` seam (AC-040a). Returns the
8962 /// `PRAGMA user_version` sentinel plus the table + index inventory
8963 /// from `sqlite_schema`, excluding `sqlite_*` internal rows.
8964 /// Canonical tables appear first per [`CANONICAL_TABLES`].
8965 #[cfg(feature = "operator")]
8966 pub fn dump_schema(&self) -> Result<DumpSchemaReport, 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 user_version: u32 = connection
8971 .query_row("PRAGMA user_version", [], |row| row.get(0))
8972 .map_err(|_| EngineError::Storage)?;
8973 let tables = read_schema_objects(connection, "table")?;
8974 let indexes = read_schema_objects(connection, "index")?;
8975 Ok(DumpSchemaReport { user_version, tables: order_canonical_first(tables), indexes })
8976 }
8977
8978 /// Doctor `dump-row-counts` seam (AC-040a). Emits canonical-table
8979 /// counts only; projection / FTS / vec0 shadow tables are excluded.
8980 #[cfg(feature = "operator")]
8981 pub fn dump_row_counts(&self) -> Result<DumpRowCountsReport, EngineError> {
8982 self.ensure_open()?;
8983 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8984 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8985 let mut counts = Vec::with_capacity(CANONICAL_TABLES.len());
8986 for name in CANONICAL_TABLES {
8987 let rows: u64 = connection
8988 .query_row(&format!("SELECT COUNT(*) FROM {name}"), [], |row| row.get(0))
8989 .map_err(|_| EngineError::Storage)?;
8990 counts.push(TableRowCount { name: (*name).to_string(), rows });
8991 }
8992 Ok(DumpRowCountsReport { counts })
8993 }
8994
8995 /// 0.8.20 Slice 5d (R-20-E8, design §4 item 11) — doctor
8996 /// `orphan-provenance` seam: a **read-only** per-`source_id` census over
8997 /// `canonical_nodes` + `canonical_edges`.
8998 ///
8999 /// Answers the operator question the erasure work made askable: *"for this
9000 /// database, is every row actually reachable by some erasure verb?"* A row
9001 /// is reachable by `erase_source` / `excise_source` via `source_id`, or —
9002 /// **if it is a NODE** — by `purge` via `logical_id`. A row with neither is
9003 /// un-erasable, and is counted into
9004 /// [`OrphanProvenanceReport::unerasable_rows`].
9005 ///
9006 /// The node/edge asymmetry is load-bearing and mirrors migration step 21:
9007 /// an EDGE's `logical_id` is a supersession identity only and confers no
9008 /// purge-addressability, so a NULL-`source_id` edge is un-erasable however
9009 /// governed it looks. See the query comment below.
9010 ///
9011 /// CLI-only (no SDK parity), matching the `dump-*` diagnostic family.
9012 ///
9013 /// Read-only by construction: this method issues SELECTs exclusively and
9014 /// opens no transaction.
9015 #[cfg(feature = "operator")]
9016 pub fn orphan_provenance(&self) -> Result<OrphanProvenanceReport, EngineError> {
9017 self.ensure_open()?;
9018 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9019 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9020
9021 // One UNION ALL over both canonical tables so a source that spans nodes
9022 // AND edges reports as a single bucket.
9023 //
9024 // TWO DIFFERENT SUMS, and the difference is the whole point:
9025 //
9026 // * `governed` counts `logical_id` carriers — a reporting figure;
9027 // * `purge_addressable` counts rows that `purge` can actually reach,
9028 // and it is NODE-ONLY (the edge arm contributes a literal 0).
9029 //
9030 // This is the same node/edge asymmetry migration step 21 carries, for
9031 // the same reason, and the two must stay in step: `purge_inner`
9032 // resolves its target exclusively through `canonical_nodes` (`SELECT
9033 // state FROM canonical_nodes WHERE logical_id = ?1`) and then erases
9034 // edges by ENDPOINT (`from_id`/`to_id`). It NEVER resolves an edge by
9035 // edge `logical_id` — an edge `logical_id` is only a SUPERSESSION
9036 // identity and confers no purge-addressability whatsoever.
9037 //
9038 // Crediting an edge's `logical_id` here made the diagnostic subtract
9039 // exactly the rows it exists to find: a NULL-`source_id` edge is
9040 // reachable by no erasure verb at all, yet `orphan-provenance` would
9041 // exit CLEAN on precisely the legacy/corrupt shape step 21 closes.
9042 // False assurance from a governance verb is worse than no verb.
9043 // (codex §9 [P2]; `null_source_governed_edge_counts_as_unerasable`.)
9044 let mut stmt = connection
9045 .prepare(
9046 "SELECT source_id,
9047 COUNT(*) AS rows_total,
9048 SUM(CASE WHEN logical_id IS NOT NULL THEN 1 ELSE 0 END) AS governed,
9049 SUM(purge_addressable) AS purge_addressable
9050 FROM (SELECT source_id,
9051 logical_id,
9052 CASE WHEN logical_id IS NOT NULL THEN 1 ELSE 0 END
9053 AS purge_addressable
9054 FROM canonical_nodes
9055 UNION ALL
9056 SELECT source_id, logical_id, 0 AS purge_addressable
9057 FROM canonical_edges)
9058 GROUP BY source_id
9059 ORDER BY rows_total DESC, source_id",
9060 )
9061 .map_err(|_| EngineError::Storage)?;
9062
9063 let rows = stmt
9064 .query_map([], |row| {
9065 let source_id: Option<String> = row.get(0)?;
9066 let rows: i64 = row.get(1)?;
9067 let governed: i64 = row.get(2)?;
9068 let purge_addressable: i64 = row.get(3)?;
9069 Ok((source_id, rows, governed, purge_addressable))
9070 })
9071 .map_err(|_| EngineError::Storage)?;
9072
9073 let mut sources = Vec::new();
9074 let mut total_rows: u64 = 0;
9075 let mut unerasable_rows: u64 = 0;
9076 for row in rows {
9077 let (source_id, rows, governed, purge_addressable) =
9078 row.map_err(|_| EngineError::Storage)?;
9079 let rows = u64::try_from(rows).unwrap_or(0);
9080 let governed_rows = u64::try_from(governed).unwrap_or(0);
9081 let purge_addressable = u64::try_from(purge_addressable).unwrap_or(0);
9082 total_rows = total_rows.saturating_add(rows);
9083 if source_id.is_none() {
9084 // No provenance: only the PURGE-ADDRESSABLE subset (governed
9085 // NODES) is reachable. The remainder — including every governed
9086 // EDGE, whose `logical_id` reaches nothing — is reachable by no
9087 // erasure verb at all.
9088 unerasable_rows =
9089 unerasable_rows.saturating_add(rows - purge_addressable.min(rows));
9090 }
9091 let reserved = source_id.as_deref().is_some_and(|s| s.starts_with('_'));
9092 sources.push(OrphanProvenanceSource { source_id, rows, governed_rows, reserved });
9093 }
9094
9095 Ok(OrphanProvenanceReport { sources, total_rows, unerasable_rows })
9096 }
9097
9098 /// Doctor `dump-profile` seam (AC-040a). Returns the stored
9099 /// embedder identity + dimension plus the registered vectorized
9100 /// kinds from `_fathomdb_vector_kinds`.
9101 #[cfg(feature = "operator")]
9102 pub fn dump_profile(&self) -> Result<DumpProfileReport, EngineError> {
9103 self.ensure_open()?;
9104 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9105 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9106 let stored = load_default_profile(connection).map_err(|_| EngineError::Storage)?;
9107 let mut stmt = connection
9108 .prepare("SELECT kind FROM _fathomdb_vector_kinds ORDER BY kind")
9109 .map_err(|_| EngineError::Storage)?;
9110 let rows =
9111 stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|_| EngineError::Storage)?;
9112 let mut vectorized_kinds = Vec::new();
9113 for row in rows {
9114 vectorized_kinds.push(row.map_err(|_| EngineError::Storage)?);
9115 }
9116 Ok(DumpProfileReport {
9117 embedder_identity: format!("{}:{}", stored.name, stored.revision),
9118 embedder_dimension: stored.dimension,
9119 vectorized_kinds,
9120 })
9121 }
9122
9123 /// Recover `--truncate-wal` seam. Runs
9124 /// `PRAGMA wal_checkpoint(TRUNCATE)` and returns the three counters
9125 /// SQLite reports. `status = Busy` when SQLite signalled a blocked
9126 /// checkpoint (`busy != 0`); the WAL may still be partially
9127 /// checkpointed in that case.
9128 #[cfg(feature = "operator")]
9129 pub fn truncate_wal(&self) -> Result<TruncateWalReport, EngineError> {
9130 self.ensure_open()?;
9131 // The operator verb keeps SQLite's own busy handler: `recover
9132 // --truncate-wal` is an explicit, foreground operator act, so waiting out
9133 // a transient reader is the helpful behaviour.
9134 self.wal_checkpoint_truncate_once(true)
9135 }
9136
9137 /// One `PRAGMA wal_checkpoint(TRUNCATE)` on the writer connection.
9138 ///
9139 /// NOT operator-gated: the erasure verbs (`purge` is a default-feature verb)
9140 /// need it too, and a `#[cfg(feature = "operator")]` helper would break the
9141 /// default build. Acquires the connection mutex, so callers must NOT already
9142 /// hold it — every erasure verb calls this AFTER its transaction has
9143 /// committed and the guard has been dropped.
9144 ///
9145 /// `honor_busy_timeout = false` suppresses SQLite's busy handler for the
9146 /// duration of the checkpoint. rusqlite installs a **5 s** default
9147 /// `busy_timeout`, so a blocked checkpoint sits for 5 s before reporting
9148 /// `busy` — under the erasure verbs' bounded retry that compounds to a ~25 s
9149 /// stall on a verb that is supposed to fail fast. The erasure path therefore
9150 /// takes the immediate `busy` answer and runs its OWN short backoff; the
9151 /// prior value is restored before returning, on every path.
9152 fn wal_checkpoint_truncate_once(
9153 &self,
9154 honor_busy_timeout: bool,
9155 ) -> Result<TruncateWalReport, EngineError> {
9156 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9157 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9158
9159 let restore_timeout_ms: Option<i64> = if honor_busy_timeout {
9160 None
9161 } else {
9162 let previous: i64 = connection
9163 .query_row("PRAGMA busy_timeout", [], |row| row.get(0))
9164 .map_err(|_| EngineError::Storage)?;
9165 connection.busy_timeout(Duration::ZERO).map_err(|_| EngineError::Storage)?;
9166 Some(previous)
9167 };
9168
9169 let checkpoint: rusqlite::Result<(i64, i64, i64)> =
9170 connection.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
9171 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
9172 });
9173
9174 if let Some(previous) = restore_timeout_ms {
9175 let previous = u64::try_from(previous.max(0)).unwrap_or(0);
9176 connection
9177 .busy_timeout(Duration::from_millis(previous))
9178 .map_err(|_| EngineError::Storage)?;
9179 }
9180
9181 let (busy, log_frames, checkpointed_frames) =
9182 checkpoint.map_err(|_| EngineError::Storage)?;
9183 let status = if busy == 0 { TruncateWalStatus::Done } else { TruncateWalStatus::Busy };
9184 Ok(TruncateWalReport {
9185 status,
9186 busy: busy.max(0) as u32,
9187 log_frames: log_frames.max(0) as u32,
9188 checkpointed_frames: checkpointed_frames.max(0) as u32,
9189 })
9190 }
9191
9192 /// 0.8.20 Slice 5b (R-20-E5) — complete an erasure **at rest** after the
9193 /// erasing transaction has committed. Two obligations, in order:
9194 ///
9195 /// 1. **Telemetry redaction** — drop the erased stable ids out of the opt-in
9196 /// telemetry sink. Driven from the DURABLE pending queue
9197 /// ([`Engine::discharge_pending_redactions`]), NOT from the ids the caller
9198 /// happens to be holding, so a retry after a failed redaction still knows
9199 /// what it owes.
9200 /// 2. **WAL truncation** — `wal_checkpoint(TRUNCATE)` with a BOUNDED retry.
9201 /// `PRAGMA secure_delete=ON` zeroes pages freed inside the database file,
9202 /// but the erased content also lives in the write-ahead log as committed
9203 /// frames from the ORIGINAL insert; the erasure DELETE appends new frames
9204 /// rather than rewriting old ones, so without a truncating checkpoint the
9205 /// erased body stays `grep`-able in `<db>-wal`.
9206 ///
9207 /// A concurrent reader pinning a WAL snapshot makes the checkpoint report
9208 /// `busy`. After [`ERASURE_WAL_TRUNCATE_ATTEMPTS`] tries the verb raises
9209 /// [`EngineError::ErasureIncomplete`] — **an erasure verb must never report
9210 /// success on an incomplete erasure.** The retry budget is deliberately small
9211 /// (~100 ms total): the caller retries the verb, the verb does not block.
9212 fn complete_erasure_at_rest(&self, verb: &'static str) -> Result<(), EngineError> {
9213 // The ids are NOT passed in: they were persisted inside the erasing
9214 // transaction, and this drains that queue. The WAL truncation below then
9215 // runs AFTER the pending rows have been deleted, so the freed pages
9216 // holding them (zeroed by `secure_delete=ON`) are checkpointed out too.
9217 self.discharge_pending_redactions(verb)?;
9218
9219 let mut last: Option<TruncateWalReport> = None;
9220 for attempt in 0..ERASURE_WAL_TRUNCATE_ATTEMPTS {
9221 let report = self.wal_checkpoint_truncate_once(false)?;
9222 if report.status == TruncateWalStatus::Done {
9223 return Ok(());
9224 }
9225 last = Some(report);
9226 if attempt + 1 < ERASURE_WAL_TRUNCATE_ATTEMPTS {
9227 std::thread::sleep(Duration::from_millis(ERASURE_WAL_TRUNCATE_BACKOFF_MS));
9228 }
9229 }
9230 let frames = last.map_or(0, |r| r.log_frames);
9231 Err(EngineError::ErasureIncomplete {
9232 stage: "wal_checkpoint".to_string(),
9233 detail: format!(
9234 "`{verb}` deleted its rows, but `wal_checkpoint(TRUNCATE)` reported BUSY on all \
9235 {ERASURE_WAL_TRUNCATE_ATTEMPTS} attempts ({frames} frames still in the log) — a \
9236 concurrent reader is pinning a WAL snapshot, so the erased bytes remain readable \
9237 in the `-wal` file. Retry once the reader has finished."
9238 ),
9239 })
9240 }
9241
9242 /// 0.8.20 Slice 5 fix-1 (codex §9 P2) — perform every telemetry redaction the
9243 /// engine still OWES, from the durable pending queue.
9244 ///
9245 /// **The defect this closes.** Redaction necessarily runs after the erasing
9246 /// transaction commits (the sink is a file, not a table, so it cannot join
9247 /// the transaction). When it failed, the verb correctly raised
9248 /// `ErasureIncomplete { stage: "telemetry_redaction" }` and told the operator
9249 /// to retry — but the retry recomputed the id set by querying the canonical
9250 /// tables, whose rows the FIRST call had already deleted. It therefore got an
9251 /// EMPTY set, hit the empty-id fast path in
9252 /// [`Engine::redact_telemetry_stable_ids`], and returned success while the
9253 /// leaked `l:`/`h:` ids were still sitting in the sink. An erasure verb
9254 /// reporting success on an incomplete erasure is precisely what R-20-E5
9255 /// forbids, and it is the worst failure mode available to this slice: silent,
9256 /// and indistinguishable from a real erasure.
9257 ///
9258 /// **The mechanism — an intent log.** The ids are captured BEFORE the deletes
9259 /// (they are derived from `logical_id`/`body`, which the deletes destroy) and
9260 /// written into [`ERASURE_PENDING_REDACTION_COLLECTION`] INSIDE the same
9261 /// transaction, so "the rows are gone" and "a redaction is owed for them"
9262 /// commit atomically. There is no window in which the rows are deleted and
9263 /// the obligation is unrecorded. A pending row is deleted only once its
9264 /// redaction has actually been performed, so the obligation survives process
9265 /// death, and the empty-id fast path is unreachable while one is outstanding:
9266 /// this drains the QUEUE, never the caller's id vector.
9267 ///
9268 /// The queue is drained by EVERY erasure verb, not just a retry of the one
9269 /// that failed — an outstanding obligation is the engine's, not one call's.
9270 ///
9271 /// **Honest refusal.** If a redaction is owed but no telemetry sink is
9272 /// attached to this `Engine` (only reachable if the process restarted between
9273 /// the failure and the retry without re-enabling telemetry), the ids really
9274 /// are still in the sink file and this returns `ErasureIncomplete` rather
9275 /// than guessing. Re-enable telemetry on the same sink and retry.
9276 ///
9277 /// **Exposure tradeoff, stated plainly.** A pending row holds the stable ids
9278 /// in the database for the window between the delete and the redaction. That
9279 /// is a strict improvement: those ids are, during exactly that window,
9280 /// already readable in the telemetry sink — which is the leak being closed —
9281 /// and the pending row is deleted the moment the sink is clean, on pages
9282 /// `secure_delete=ON` zeroes and the subsequent `TRUNCATE` checkpoint clears
9283 /// from the log.
9284 fn discharge_pending_redactions(&self, verb: &'static str) -> Result<(), EngineError> {
9285 let pending = self.load_pending_redactions()?;
9286 if pending.is_empty() {
9287 return Ok(());
9288 }
9289
9290 let mut ids: Vec<String> =
9291 pending.iter().flat_map(|(_, ids)| ids.iter().cloned()).collect();
9292 ids.sort_unstable();
9293 ids.dedup();
9294
9295 // A queue entry exists ⇒ a sink was attached when the rows were deleted ⇒
9296 // the ids are in that file. Never clear the queue without redacting.
9297 if !self.telemetry_enabled.load(Ordering::Acquire) {
9298 return Err(EngineError::ErasureIncomplete {
9299 stage: "telemetry_redaction".to_string(),
9300 detail: format!(
9301 "`{verb}` has {} outstanding telemetry redaction(s) covering {} erased \
9302 stable id(s), but no telemetry sink is attached to this engine — the ids \
9303 cannot be removed from the sink file. Re-enable telemetry on the same sink \
9304 path and retry.",
9305 pending.len(),
9306 ids.len()
9307 ),
9308 });
9309 }
9310
9311 // On failure the queue rows stay put and the error propagates: the verb
9312 // does not report success, and the next call retries the same obligation.
9313 self.redact_telemetry_stable_ids(verb, &ids)?;
9314
9315 let row_ids: Vec<i64> = pending.iter().map(|(row_id, _)| *row_id).collect();
9316 self.clear_pending_redactions(&row_ids)
9317 }
9318
9319 /// Read the outstanding redaction queue: `(operational_mutations.id, ids)`.
9320 fn load_pending_redactions(&self) -> Result<Vec<(i64, Vec<String>)>, EngineError> {
9321 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9322 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9323 let mut stmt = connection
9324 .prepare(
9325 "SELECT id, payload_json FROM operational_mutations \
9326 WHERE collection_name = ?1 ORDER BY id",
9327 )
9328 .map_err(|_| EngineError::Storage)?;
9329 let rows = stmt
9330 .query_map([ERASURE_PENDING_REDACTION_COLLECTION], |row| {
9331 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
9332 })
9333 .map_err(|_| EngineError::Storage)?;
9334 let mut pending = Vec::new();
9335 for row in rows {
9336 let (row_id, payload) = row.map_err(|_| EngineError::Storage)?;
9337 // A payload we cannot parse is an obligation we cannot discharge;
9338 // keeping it (empty) is safe — it never unblocks a false success,
9339 // and `discharge_pending_redactions` still refuses.
9340 let ids = serde_json::from_str::<serde_json::Value>(&payload)
9341 .ok()
9342 .and_then(|v| v.get("erased_stable_ids").cloned())
9343 .and_then(|v| serde_json::from_value::<Vec<String>>(v).ok())
9344 .unwrap_or_default();
9345 pending.push((row_id, ids));
9346 }
9347 Ok(pending)
9348 }
9349
9350 /// Retire queue entries whose redaction has been PERFORMED. Committed before
9351 /// the caller's WAL truncation so the freed pages are checkpointed out.
9352 fn clear_pending_redactions(&self, row_ids: &[i64]) -> Result<(), EngineError> {
9353 if row_ids.is_empty() {
9354 return Ok(());
9355 }
9356 let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9357 let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9358 let mut stmt = connection
9359 .prepare("DELETE FROM operational_mutations WHERE id = ?1")
9360 .map_err(|_| EngineError::Storage)?;
9361 for row_id in row_ids {
9362 stmt.execute([row_id]).map_err(|_| EngineError::Storage)?;
9363 }
9364 Ok(())
9365 }
9366
9367 /// 0.8.20 Slice 5b (R-20-E6) — SELECTIVE redaction of erased stable ids from
9368 /// the opt-in telemetry sink.
9369 ///
9370 /// `capture_telemetry` persists `result_stable_ids` — `l:`/`h:` prefixed ids
9371 /// — into a JSONL file that outlives the erased rows, and nothing in the
9372 /// engine could previously remove them. A retained `l:` id is not inert:
9373 /// [`derive_logical_id`] is `SHA256(lowercase(kind) + ":" + lowercase(name))`,
9374 /// and the case-folding of BOTH inputs shrinks the preimage space, so a
9375 /// surviving id is dictionary-attackable back to the natural key it was
9376 /// derived from. An `h:` id is a plain `SHA256(body)`, confirmable against a
9377 /// guessed body.
9378 ///
9379 /// **This MUST NOT truncate the sink.** `sink_path` is CALLER-SUPPLIED and
9380 /// may hold unrelated operator eval history that the erasure obligation never
9381 /// covered; the v3 truncation approach was rejected as unsafe. Only the
9382 /// matching `result_stable_ids` ELEMENTS are replaced with
9383 /// [`REDACTED_STABLE_ID`], preserving record count, record order and
9384 /// positional alignment with the parallel `result_ids` array. Lines that are
9385 /// not engine-authored JSON events are copied through verbatim.
9386 ///
9387 /// **Crash safety.** The rewrite is write-temp-then-`rename`: a sibling
9388 /// `.redact.tmp` is written and fsynced, then atomically renamed over the
9389 /// sink, so a crash leaves either the old file or the new one — never a
9390 /// half-rewritten sink. The telemetry mutex is held across the whole rewrite,
9391 /// so no in-process `capture_telemetry` can append into the window; an
9392 /// out-of-process appender is handled by re-reading and folding in the tail
9393 /// delta before the rename (bounded retry).
9394 ///
9395 /// The privacy contract is unchanged: query TEXT and `source_id` are never
9396 /// captured (ADR-0.8.8 §C), so there is nothing else in the sink to redact.
9397 /// The fast-OFF atomic guard is preserved — when telemetry was never enabled
9398 /// this is a single relaxed-ordering load and no mutex acquisition.
9399 fn redact_telemetry_stable_ids(
9400 &self,
9401 verb: &'static str,
9402 erased_stable_ids: &[String],
9403 ) -> Result<(), EngineError> {
9404 // Fast OFF path — mirrors `capture_telemetry`. No mutex, no I/O.
9405 if erased_stable_ids.is_empty() || !self.telemetry_enabled.load(Ordering::Acquire) {
9406 return Ok(());
9407 }
9408 let guard = self.telemetry.lock().map_err(|_| EngineError::Storage)?;
9409 let Some(sink) = guard.as_ref() else { return Ok(()) };
9410 let erased: std::collections::HashSet<&str> =
9411 erased_stable_ids.iter().map(String::as_str).collect();
9412
9413 match redact_jsonl_stable_ids(&sink.path, &erased) {
9414 Ok(()) => Ok(()),
9415 // 0.8.20 Slice 5 fix-3 (codex §9 round-3 P2) — `NotFound` is NOT a
9416 // discharge. It previously returned `Ok(())` ("the sink is gone,
9417 // nothing to redact"), which cleared the durable pending queue and
9418 // let the verb report success. That inference does not hold: a path
9419 // cannot distinguish `rm` from `mv`, and log rotation of a
9420 // caller-supplied sink is an ordinary operational event that leaves
9421 // the erased `l:`/`h:` ids fully readable under the rotated name.
9422 //
9423 // The burden of proof is on DISCHARGING the obligation, and the
9424 // engine cannot meet it here: `TelemetrySink` holds a PATH, not an
9425 // open handle, so there is no `nlink == 0` witness that the inode was
9426 // actually unlinked — and even that would not cover a copy taken
9427 // before the deletion. So there is no narrow provable case to carve
9428 // out, and `NotFound` fails closed.
9429 //
9430 // This cannot fire spuriously for a sink that never existed:
9431 // `enable_telemetry` CREATES the file before arming capture, so for
9432 // any engine with telemetry enabled the sink demonstrably existed and
9433 // `NotFound` means it existed and then vanished.
9434 Err(err) => Err(EngineError::ErasureIncomplete {
9435 stage: "telemetry_redaction".to_string(),
9436 detail: if err.kind() == std::io::ErrorKind::NotFound {
9437 format!(
9438 "`{verb}` deleted its rows, but the telemetry sink {} no longer exists, \
9439 so the erased stable ids could not be redacted from it. A missing path \
9440 does NOT prove the sink was deleted — if it was rotated or moved aside, \
9441 the erased ids are still readable under its new name. The pending \
9442 redaction is durable: restore the sink at this path and retry (if the \
9443 sink really was destroyed, an empty file at this path discharges the \
9444 obligation).",
9445 sink.path.display()
9446 )
9447 } else {
9448 format!(
9449 "`{verb}` deleted its rows, but the erased stable ids could not be \
9450 redacted from the telemetry sink {}: {err}",
9451 sink.path.display()
9452 )
9453 },
9454 }),
9455 }
9456 }
9457
9458 /// The erased rows' prefixed stable ids ([`IdSpace::to_prefixed`]) are NOT
9459 /// returned to the caller for redaction (R-20-E6). They are enqueued INSIDE
9460 /// this transaction via [`enqueue_pending_redaction`]: a caller-held vector
9461 /// is lost on the retry path, which is exactly the false-success codex §9 P2
9462 /// found. Only the report comes back.
9463 ///
9464 /// 0.8.20 Slice 5d (R-20-E4): no longer `operator`-gated — it is the shared
9465 /// body behind BOTH `erase_source` (governed SDK) and `excise_source`
9466 /// (operator seam). Still private; the gate that matters is on the two
9467 /// public spellings.
9468 fn excise_source_inner(
9469 &self,
9470 verb: &'static str,
9471 source_id: &str,
9472 ) -> Result<ExciseReport, EngineError> {
9473 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9474 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
9475 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
9476
9477 // Collect the cursor sets up-front so we can targeted-delete
9478 // shadow rows AND emit an accurate audit row in one txn.
9479 let node_cursors: Vec<i64> = {
9480 let mut stmt = tx
9481 .prepare("SELECT write_cursor FROM canonical_nodes WHERE source_id = ?1")
9482 .map_err(|_| EngineError::Storage)?;
9483 let rows = stmt
9484 .query_map([source_id], |row| row.get::<_, i64>(0))
9485 .map_err(|_| EngineError::Storage)?;
9486 rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
9487 };
9488 let edge_cursors: Vec<i64> = {
9489 let mut stmt = tx
9490 .prepare("SELECT write_cursor FROM canonical_edges WHERE source_id = ?1")
9491 .map_err(|_| EngineError::Storage)?;
9492 let rows = stmt
9493 .query_map([source_id], |row| row.get::<_, i64>(0))
9494 .map_err(|_| EngineError::Storage)?;
9495 rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
9496 };
9497
9498 // 0.8.20 Slice 5b (R-20-E6) — stable ids the telemetry sink may hold for
9499 // these rows, collected BEFORE the DELETEs.
9500 let erased_stable_ids = collect_erased_stable_ids(
9501 &tx,
9502 "SELECT logical_id, body FROM canonical_nodes WHERE source_id = ?1",
9503 "SELECT logical_id, body FROM canonical_edges WHERE source_id = ?1",
9504 source_id,
9505 )?;
9506
9507 // 0.8.20 Slice 5a (R-20-E1) — registry-driven erasure. The previous
9508 // hand-rolled list here OMITTED `search_index_v2`, a CONTENT-STORING
9509 // FTS5 table (no `content=''`) that keeps the document body verbatim:
9510 // after `excise_source` the erased body was still on disk, invisible to
9511 // every functional test because both v2 read paths discard candidates
9512 // lacking a live `canonical_nodes` row. `erase_row_projections` covers
9513 // every registered projection, so the omission cannot recur.
9514 let mut shadow_invalidated: u64 = 0;
9515 for cursor in node_cursors.iter().chain(edge_cursors.iter()) {
9516 shadow_invalidated = shadow_invalidated.saturating_add(
9517 erase_row_projections(&tx, *cursor).map_err(|_| EngineError::Storage)?,
9518 );
9519 }
9520
9521 let nodes_excised = tx
9522 .execute("DELETE FROM canonical_nodes WHERE source_id = ?1", [source_id])
9523 .map_err(|_| EngineError::Storage)? as u64;
9524 let edges_excised = tx
9525 .execute("DELETE FROM canonical_edges WHERE source_id = ?1", [source_id])
9526 .map_err(|_| EngineError::Storage)? as u64;
9527
9528 // AC-028a audit row: a single append on the
9529 // `excise_source_audit` collection naming the excised source.
9530 //
9531 // DURABILITY (0.8.20 Slice 5b, design v5 §2 defect D-A; HITL-ruled
9532 // 2026-07-19: *"there must be an auditable record of deletion event."*).
9533 // This row lands in `operational_mutations`, the same table the retention
9534 // sweep drains — and it is written BEFORE the workload that follows it,
9535 // so an oldest-`id`-first sweep evicted it FIRST. It is now protected:
9536 // `excise_source_audit` is in `ERASURE_AUDIT_COLLECTIONS`, which
9537 // `enforce_provenance_retention` excludes. The proof of erasure is no
9538 // longer destructible by ordinary retention pressure.
9539 //
9540 // NON-PII `source_id` (rationale corrected in this slice). v4 §3.6
9541 // justified the "`source_id` must not be PII" rule by claiming the audit
9542 // row retains it *permanently, by design*. That premise was FALSE — the
9543 // row was sweepable. The rule stands on a different and simpler footing:
9544 // this row persists the caller's raw `source_id` verbatim, and an
9545 // `excise_source` that erased the payload while keeping an identifying
9546 // source label would not be an erasure. The exemption above makes the
9547 // retention now genuinely indefinite, which makes the rule MORE
9548 // load-bearing, not less.
9549 //
9550 // `next_cursor` after a prior write holds the LAST committed cursor;
9551 // mirror the vec writer pattern (load + 1, then store post-commit)
9552 // so the audit row's `write_cursor` is strictly greater than every
9553 // canonical row that preceded it.
9554 let excised_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
9555 let payload = serde_json::json!({
9556 "source_id": source_id,
9557 "excised_at": excised_at,
9558 "nodes_excised": nodes_excised,
9559 "edges_excised": edges_excised,
9560 "projections_invalidated": shadow_invalidated,
9561 })
9562 .to_string();
9563 let audit_cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
9564 tx.execute(
9565 "INSERT INTO operational_mutations(
9566 collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
9567 ) VALUES('excise_source_audit', ?1, 'append', ?2, NULL, ?3)",
9568 params![source_id, payload, audit_cursor],
9569 )
9570 .map_err(|_| EngineError::Storage)?;
9571
9572 // 0.8.20 Slice 5 fix-1 (codex §9 P2) — durably record the redaction this
9573 // erasure owes, atomically with the deletes. Shares `audit_cursor`: one
9574 // erasure event, and this row is retired as soon as the sink is clean.
9575 if self.telemetry_enabled.load(Ordering::Acquire) {
9576 enqueue_pending_redaction(&tx, verb, &erased_stable_ids, audit_cursor)?;
9577 }
9578
9579 tx.commit().map_err(|_| EngineError::Storage)?;
9580 self.next_cursor.store(audit_cursor, Ordering::SeqCst);
9581 Ok(ExciseReport {
9582 source_ref: source_id.to_string(),
9583 nodes_excised,
9584 edges_excised,
9585 projections_invalidated: shadow_invalidated,
9586 })
9587 }
9588
9589 /// 0.8.20 Slice 5b (R-20-E7) — erase ONE op-store record, by collection and
9590 /// record key, from both op-store shapes: every `operational_mutations`
9591 /// version of the key (append-only-log collections) and its
9592 /// `operational_state` row (latest-state collections).
9593 ///
9594 /// **Refuses the engine's own erasure bookkeeping** (0.8.20 Slice 5 fix-3,
9595 /// codex §9 round-3 P1): a `collection` for which
9596 /// [`is_erasure_bookkeeping_collection`] holds raises
9597 /// [`EngineError::InvalidArgument`] before anything is deleted. Aimed at the
9598 /// pending-redaction queue this verb otherwise destroys an outstanding
9599 /// erasure obligation, after which the next erasure verb reports success with
9600 /// the erased ids still in the telemetry sink; aimed at the audit trail it
9601 /// destroys the auditable record of the deletion event.
9602 ///
9603 /// Before this slice the op-store had NO record-level delete at all:
9604 /// [`enforce_provenance_retention`] is a cap sweep, not an erasure verb, so a
9605 /// caller holding an erasure obligation over an op-store record had no way to
9606 /// discharge it. Idempotent — erasing an absent key is a zero-count success.
9607 ///
9608 /// Like the other erasure verbs this finishes at rest (telemetry is not
9609 /// involved — op-store record keys never reach the telemetry sink — but the
9610 /// `-wal` is), so it can return [`EngineError::ErasureIncomplete`].
9611 ///
9612 /// AUDIT (D-A). Appends a row to the retention-exempt `excise_record_audit`
9613 /// collection. Unlike `source_id`, a `record_key` carries NO non-PII rule:
9614 /// it is arbitrary caller-supplied text and may itself be the identifier
9615 /// being erased. The audit therefore records a SHA-256 digest of
9616 /// `collection` + `record_key`, never the key — enough to prove *that* a
9617 /// specific record was erased to anyone who already knows the key, and
9618 /// useless to anyone who does not.
9619 #[cfg(feature = "operator")]
9620 pub fn excise_collection_record(
9621 &self,
9622 collection: &str,
9623 record_key: &str,
9624 ) -> Result<ExciseRecordReport, EngineError> {
9625 self.ensure_open()?;
9626 if collection.is_empty() || record_key.is_empty() {
9627 return Err(EngineError::WriteValidation);
9628 }
9629 // 0.8.20 Slice 5 fix-3 (codex §9 round-3 P1) — the engine's own erasure
9630 // bookkeeping is not caller data and is not excisable. See
9631 // `is_erasure_bookkeeping_collection` for why each member is protected.
9632 // Checked BEFORE any deletion so the refusal is total, not partial.
9633 if is_erasure_bookkeeping_collection(collection) {
9634 return Err(EngineError::InvalidArgument {
9635 msg: format!(
9636 "`{collection}` is engine-internal erasure bookkeeping and cannot be excised \
9637 by `excise_collection_record`. The pending-redaction queue records an \
9638 erasure the engine still owes (deleting it would let a later verb report \
9639 success on an incomplete erasure, R-20-E5), and the erasure-audit \
9640 collections are the auditable record of the deletion event. Pending \
9641 redactions retire themselves once performed; retry the erasure verb instead."
9642 ),
9643 });
9644 }
9645 let report = self.excise_collection_record_inner(collection, record_key)?;
9646 self.complete_erasure_at_rest("excise_collection_record")?;
9647 Ok(report)
9648 }
9649
9650 #[cfg(feature = "operator")]
9651 fn excise_collection_record_inner(
9652 &self,
9653 collection: &str,
9654 record_key: &str,
9655 ) -> Result<ExciseRecordReport, EngineError> {
9656 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9657 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
9658 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
9659
9660 let records_excised = tx
9661 .execute(
9662 "DELETE FROM operational_mutations
9663 WHERE collection_name = ?1 AND record_key = ?2",
9664 params![collection, record_key],
9665 )
9666 .map_err(|_| EngineError::Storage)? as u64;
9667 let state_rows_excised = tx
9668 .execute(
9669 "DELETE FROM operational_state
9670 WHERE collection_name = ?1 AND record_key = ?2",
9671 params![collection, record_key],
9672 )
9673 .map_err(|_| EngineError::Storage)? as u64;
9674
9675 let record_digest = digest_record_identity(collection, record_key);
9676 let excised_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
9677 let payload = serde_json::json!({
9678 "collection": collection,
9679 "record_digest": record_digest,
9680 "excised_at": excised_at,
9681 "records_excised": records_excised,
9682 "state_rows_excised": state_rows_excised,
9683 })
9684 .to_string();
9685 let audit_cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
9686 tx.execute(
9687 "INSERT INTO operational_mutations(
9688 collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
9689 ) VALUES('excise_record_audit', ?1, 'append', ?2, NULL, ?3)",
9690 params![record_digest, payload, audit_cursor],
9691 )
9692 .map_err(|_| EngineError::Storage)?;
9693
9694 tx.commit().map_err(|_| EngineError::Storage)?;
9695 self.next_cursor.store(audit_cursor, Ordering::SeqCst);
9696 self.counters.record_admin();
9697 Ok(ExciseRecordReport {
9698 collection: collection.to_string(),
9699 record_digest,
9700 records_excised,
9701 state_rows_excised,
9702 })
9703 }
9704
9705 #[cfg(feature = "operator")]
9706 fn run_rebuild(
9707 &self,
9708 include_fts: bool,
9709 kind: RebuildKind,
9710 ) -> Result<RebuildReport, EngineError> {
9711 self.projection_runtime.set_frozen(true);
9712 // Drain MUST succeed: rebuild_shadow_state truncates shadow rows,
9713 // and SQLite-WAL allows a worker that already dequeued a job to
9714 // commit its `INSERT OR IGNORE INTO _fathomdb_vector_rows / vec0`
9715 // after our truncate releases the writer lock, leaving stale
9716 // rows. Surfacing the timeout (instead of swallowing it) lets the
9717 // operator retry rather than silently corrupt the rebuild.
9718 let drain_result = self.drain(REBUILD_DRAIN_TIMEOUT_MS);
9719 let result = drain_result.and_then(|()| self.rebuild_shadow_state(include_fts, kind));
9720 self.projection_runtime.set_frozen(false);
9721 result
9722 }
9723
9724 #[cfg(feature = "operator")]
9725 fn rebuild_shadow_state(
9726 &self,
9727 include_fts: bool,
9728 kind: RebuildKind,
9729 ) -> Result<RebuildReport, EngineError> {
9730 let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9731 let connection = connection.as_mut().ok_or(EngineError::Closing)?;
9732 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
9733 // 0.8.20 Slice 5a (R-20-E1) — registry-driven invalidation. A full
9734 // rebuild truncates EVERY row-owned projection (the previous hand-rolled
9735 // list omitted `search_index_v2`, so a rebuild neither dropped stale v2
9736 // rows nor repopulated the table); a vec0-only rebuild truncates the
9737 // vector + readiness classes exactly as before. Kind-owned watermark
9738 // state (`_fathomdb_projection_state`) is deliberately NOT truncated —
9739 // readiness is reset by rewinding the projection cursor below.
9740 let rows_invalidated = if include_fts {
9741 truncate_all_row_projections(&tx).map_err(|_| EngineError::Storage)?
9742 } else {
9743 truncate_row_projections_in(&tx, &[ProjectionClass::Vector, ProjectionClass::Readiness])
9744 .map_err(|_| EngineError::Storage)?
9745 };
9746 store_projection_cursor(&tx, 0).map_err(|_| EngineError::Storage)?;
9747 // 0.8.20 Slice 5a (R-20-E1, work item 1) — the replay runs through the
9748 // SAME two projectors the write path uses, so the rebuilt projections
9749 // are identical to what a re-write would have produced. `include_fts`
9750 // selects the pass: a vec0-only rebuild must not write FTS rows.
9751 let pass = if include_fts { ProjectionPass::Write } else { ProjectionPass::VectorOnly };
9752 let mut rows_rebuilt: u64 = 0;
9753 for row in canonical_node_rows(&tx).map_err(|_| EngineError::Storage)? {
9754 project_canonical_node_row(
9755 &tx,
9756 row.cursor,
9757 &row.kind,
9758 &row.body,
9759 row.row_kind,
9760 pass,
9761 // fix-2 [P2]: the attribute half of the replay tracks the backfill's
9762 // active-and-non-superseded row set; FTS / vector shadows still
9763 // rebuild for every row (read-side lifecycle filter, unchanged).
9764 row.attr_projected,
9765 )
9766 .map_err(|_| EngineError::Storage)?;
9767 if include_fts {
9768 rows_rebuilt = rows_rebuilt.saturating_add(1);
9769 }
9770 }
9771 // fix-26 [P2]: rebuild the edge shadows from active canonical_edges
9772 // (G11 search_index_edges).
9773 // 0.8.12 Slice A (R-CON-2 named default-ON blocker; Slice-20 codex
9774 // §9 [P2]): mirror the graph-traversal recency filter
9775 // (`edge_validity_sql`) here too, so a full rebuild does not re-surface
9776 // an edge that recency consolidation already invalidated.
9777 // 0.8.20 Slice 5a: body-less structural edges are now included in the
9778 // replay. They project no FTS/vector row, but the write path DOES record
9779 // their readiness terminal — which this rebuild truncated and (before
9780 // this slice) never restored, stalling `advance_projection_cursor`.
9781 // TC-33: the filter is generated by `edge_validity_sql` and `:now` is
9782 // bound (?1) rather than inlined as `datetime('now')`.
9783 let edge_rows: Vec<(i64, String, Option<String>)> = {
9784 let edge_sql = format!(
9785 "SELECT write_cursor, kind, body FROM canonical_edges \
9786 WHERE superseded_at IS NULL{}",
9787 edge_validity_sql("canonical_edges", 1)
9788 );
9789 let mut edge_stmt = tx.prepare(&edge_sql).map_err(|_| EngineError::Storage)?;
9790 let rows = edge_stmt
9791 .query_map(params![current_epoch_seconds()], |row| {
9792 Ok((
9793 row.get::<_, i64>(0)?,
9794 row.get::<_, String>(1)?,
9795 row.get::<_, Option<String>>(2)?,
9796 ))
9797 })
9798 .map_err(|_| EngineError::Storage)?
9799 .collect::<rusqlite::Result<_>>()
9800 .map_err(|_| EngineError::Storage)?;
9801 rows
9802 };
9803 for (cursor, kind, body) in edge_rows {
9804 let has_body = body.is_some();
9805 project_canonical_edge_row(&tx, cursor as u64, &kind, body.as_deref(), pass)
9806 .map_err(|_| EngineError::Storage)?;
9807 if include_fts && has_body {
9808 rows_rebuilt = rows_rebuilt.saturating_add(1);
9809 }
9810 }
9811 let projection_cursor_after =
9812 load_projection_cursor(&tx).map_err(|_| EngineError::Storage)?;
9813 tx.commit().map_err(|_| EngineError::Storage)?;
9814 Ok(RebuildReport { kind, rows_invalidated, rows_rebuilt, projection_cursor_after })
9815 }
9816
9817 fn ensure_open(&self) -> Result<(), EngineError> {
9818 if self.closed.load(Ordering::SeqCst) {
9819 return Err(EngineError::Closing);
9820 }
9821
9822 Ok(())
9823 }
9824}
9825
9826fn batch_is_admin(batch: &[PreparedWrite]) -> bool {
9827 !batch.is_empty() && batch.iter().all(|w| matches!(w, PreparedWrite::AdminSchema { .. }))
9828}
9829
9830// 0.7.0 Pack 2 (ADR-0.7.0-vector-binary-quant § 2; handoff § 2.2):
9831// bit-KNN candidate-set size for the two-phase read path. Tuned with
9832// the recall@10 floor in tests/perf_gates.rs::ac_013b_recall_at_10_floor.
9833//
9834// Bumped from 64 → 192 in EU-5a2 per the HITL 2026-05-29 fine-grained
9835// K-sweep result (dev/notes/0.7.1-default-embedder-research.md §5.4):
9836// K=192 sits above the recall-plateau knee for the default embedder.
9837// Public-visible so the EU-5a2 machinery test can assert the value.
9838pub const TOP_K_BIT_CANDIDATES: usize = 192;
9839
9840/// EU-5a2 — number of documents required before the workspace's
9841/// `_fathomdb_embedder_profiles.mean_vec` is pinned for the default
9842/// profile. Per `dev/design/embedder.md` §0.3 (compute-once-on-first-
9843/// ingest lifecycle). Public-visible so the EU-5a2 machinery test can
9844/// assert the value.
9845pub const MEAN_VEC_PIN_THRESHOLD: u64 = 256;
9846
9847/// 0.7.2 PR-2bc S1 fix-1 — production phase-2 rerank `LIMIT` for engine
9848/// search. This is the original hardcoded `LIMIT 10`; it is the default and
9849/// the floor for `search_limit_override` (a test seam may RAISE it but never
9850/// shrink it below this). There is NO env-var override on the hot path.
9851pub const SEARCH_RERANK_LIMIT: usize = 10;
9852
9853/// EU-5a2 — streaming f64 accumulator for the mean-centering pipeline,
9854/// per `dev/design/embedder.md` §0.3 (f64 chosen to bound numerical
9855/// drift across `MEAN_VEC_PIN_THRESHOLD` adds). Owned by the projection
9856/// worker; materialized into the schema column at the threshold cross.
9857#[derive(Clone, Debug)]
9858struct MeanAccumulator {
9859 sum: Vec<f64>,
9860 count: u64,
9861}
9862
9863impl MeanAccumulator {
9864 fn new(dim: usize) -> Self {
9865 Self { sum: vec![0.0; dim], count: 0 }
9866 }
9867
9868 fn add(&mut self, v: &[f32]) {
9869 debug_assert_eq!(v.len(), self.sum.len(), "accumulator dim mismatch");
9870 for (slot, value) in self.sum.iter_mut().zip(v.iter()) {
9871 *slot += f64::from(*value);
9872 }
9873 self.count = self.count.saturating_add(1);
9874 }
9875
9876 fn materialize(&self) -> Vec<f32> {
9877 if self.count == 0 {
9878 return vec![0.0; self.sum.len()];
9879 }
9880 let denom = self.count as f64;
9881 self.sum.iter().map(|s| (s / denom) as f32).collect()
9882 }
9883
9884 fn count(&self) -> u64 {
9885 self.count
9886 }
9887}
9888
9889/// 0.7.2 PR-2b — cosine similarity between two equal-length vectors.
9890/// Returns 1.0 for a pair with a zero-norm operand (treated as "no drift
9891/// signal"), so the detector never fires on a degenerate all-zero mean.
9892fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
9893 if a.len() != b.len() {
9894 return 1.0;
9895 }
9896 let mut dot = 0.0f64;
9897 let mut na = 0.0f64;
9898 let mut nb = 0.0f64;
9899 for (x, y) in a.iter().zip(b.iter()) {
9900 dot += f64::from(*x) * f64::from(*y);
9901 na += f64::from(*x) * f64::from(*x);
9902 nb += f64::from(*y) * f64::from(*y);
9903 }
9904 if na == 0.0 || nb == 0.0 {
9905 return 1.0;
9906 }
9907 (dot / (na.sqrt() * nb.sqrt())) as f32
9908}
9909
9910/// EU-5b — at-pin pin-and-requantize pass per `dev/design/embedder.md`
9911/// §0.5. Runs INSIDE the caller's SQLite transaction so the mean_vec
9912/// INSERT/UPDATE + the per-row sign-bit UPDATEs commit atomically.
9913///
9914/// For each pre-pin row, recomputes `bits' = sign_quantize(f32 - mean)`
9915/// via the SQL extension's `vec_quantize_binary`, then UPDATEs the
9916/// row's `embedding_bin` column.
9917fn run_pin_and_requantize_pass(
9918 tx: &rusqlite::Transaction<'_>,
9919 rows: &[(i64, Vec<u8>)],
9920 mean: &[f32],
9921) -> Result<(u64, Vec<EmbedderEvent>), EngineError> {
9922 let mut updated: u64 = 0;
9923 let dim = mean.len();
9924 // sqlite-vec's vec0 xUpdate path discards SQL-function result subtypes
9925 // (see sqlite-vec.c §vec0Update_UpdateVectorColumn — "subtypes don't
9926 // appear to survive xColumn -> xUpdate, it's always 0"), so a direct
9927 // `UPDATE ... SET embedding_bin = vec_quantize_binary(?)` reads the
9928 // bound value as a float32-tagged vector and trips the column-type
9929 // check. We work around by DELETE+INSERT inside the same transaction:
9930 // INSERT preserves the BIT subtype on `vec_quantize_binary`. The
9931 // surrounding pin-commit tx keeps the rewrite atomic.
9932 for (rowid, blob) in rows {
9933 if blob.len() != dim * 4 {
9934 return Err(EngineError::Storage);
9935 }
9936 let un_centered = decode_vector_blob(blob);
9937 let centered = subtract_mean(&un_centered, mean);
9938 let centered_blob = encode_vector_blob(¢ered);
9939
9940 let (source_type, kind, created_at): (String, String, i64) = tx
9941 .query_row(
9942 "SELECT source_type, kind, created_at FROM vector_default WHERE rowid = ?1",
9943 params![rowid],
9944 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
9945 )
9946 .map_err(|_| EngineError::Storage)?;
9947
9948 // 0.8.20 Slice 15e — this DELETE+INSERT re-quantize (its BIT-subtype
9949 // workaround, condition #4's SEPARATE same-shape op) must PRESERVE every
9950 // `filterable` `attr_<hex>` value, not just re-`''` them: a projection may
9951 // have been declared before the pin. Read the live attr columns + this
9952 // row's values BEFORE the DELETE, then re-bind them. Empty ⇒ the INSERT is
9953 // byte-identical to the shipped statement.
9954 let attr_cols = actual_vector_attr_columns(tx).map_err(|_| EngineError::Storage)?;
9955 let attr_vals: Vec<String> = if attr_cols.is_empty() {
9956 Vec::new()
9957 } else {
9958 let select = attr_cols.join(", ");
9959 tx.query_row(
9960 &format!("SELECT {select} FROM vector_default WHERE rowid = ?1"),
9961 params![rowid],
9962 |row| {
9963 let mut vals = Vec::with_capacity(attr_cols.len());
9964 for i in 0..attr_cols.len() {
9965 vals.push(row.get::<_, String>(i)?);
9966 }
9967 Ok(vals)
9968 },
9969 )
9970 .map_err(|_| EngineError::Storage)?
9971 };
9972
9973 // TC-76: the attr VALUES were read above, so neutralizing them inside
9974 // [`delete_vector_partition_row`] cannot lose them; the re-INSERT below
9975 // re-binds `attr_vals` verbatim.
9976 delete_vector_partition_row(tx, *rowid).map_err(|_| EngineError::Storage)?;
9977
9978 // Slice 10 / G10 — `status` ships the empty-string sentinel (vec0 TEXT
9979 // metadata is NOT NULL-able).
9980 let mut cols_sql = String::new();
9981 let mut ph_sql = String::new();
9982 for (i, col) in attr_cols.iter().enumerate() {
9983 cols_sql.push_str(&format!(", {col}"));
9984 ph_sql.push_str(&format!(", ?{}", 7 + i));
9985 }
9986 let sql = format!(
9987 "INSERT INTO vector_default(
9988 rowid, embedding, embedding_bin, source_type, kind, created_at, status{cols_sql}
9989 ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, ''{ph_sql})"
9990 );
9991 let mut pv: Vec<rusqlite::types::Value> = vec![
9992 rusqlite::types::Value::Integer(*rowid),
9993 rusqlite::types::Value::Blob(blob.clone()),
9994 rusqlite::types::Value::Blob(centered_blob),
9995 rusqlite::types::Value::Text(source_type),
9996 rusqlite::types::Value::Text(kind),
9997 rusqlite::types::Value::Integer(created_at),
9998 ];
9999 for v in attr_vals {
10000 pv.push(rusqlite::types::Value::Text(v));
10001 }
10002 tx.execute(&sql, rusqlite::params_from_iter(pv.iter()))
10003 .map_err(|_| EngineError::Storage)?;
10004
10005 updated = updated.saturating_add(1);
10006 }
10007 let events = vec![EmbedderEvent::MeanVecPinned {
10008 dim: u32::try_from(dim).unwrap_or(u32::MAX),
10009 doc_count: updated,
10010 }];
10011 Ok((updated, events))
10012}
10013
10014/// EU-5a2 — back-compat test-only count+emit helper. Preserved so the
10015/// EU-5a2 machinery test stays green; the EU-5b production path uses
10016/// `run_pin_and_requantize_pass`.
10017fn run_requantize_pass(rows: &[(i64, Vec<u8>)], mean: &[f32]) -> (u64, Vec<EmbedderEvent>) {
10018 let mut updated: u64 = 0;
10019 let dim = mean.len();
10020 for (_rowid, blob) in rows {
10021 if blob.len() != dim * 4 {
10022 continue;
10023 }
10024 updated = updated.saturating_add(1);
10025 }
10026 let events = vec![EmbedderEvent::MeanVecPinned {
10027 dim: u32::try_from(dim).unwrap_or(u32::MAX),
10028 doc_count: updated,
10029 }];
10030 (updated, events)
10031}
10032
10033/// EU-5a2 — test-visible re-exports of the mean-centering internals.
10034/// Per the handoff RED tests; the production accumulator and re-quantize
10035/// pass are otherwise crate-private.
10036#[doc(hidden)]
10037pub mod mean_centering_internals_for_test {
10038 use super::{EmbedderEvent, MeanAccumulator};
10039
10040 pub struct AccumulatorHandle(MeanAccumulator);
10041
10042 #[must_use]
10043 pub fn new_mean_accumulator(dim: usize) -> AccumulatorHandle {
10044 AccumulatorHandle(MeanAccumulator::new(dim))
10045 }
10046
10047 pub fn accumulator_add(handle: &mut AccumulatorHandle, v: &[f32]) {
10048 handle.0.add(v);
10049 }
10050
10051 #[must_use]
10052 pub fn accumulator_materialize(handle: &AccumulatorHandle) -> Vec<f32> {
10053 handle.0.materialize()
10054 }
10055
10056 #[must_use]
10057 pub fn accumulator_count(handle: &AccumulatorHandle) -> u64 {
10058 handle.0.count()
10059 }
10060
10061 #[must_use]
10062 pub fn run_requantize_pass(rows: &[(i64, Vec<u8>)], mean: &[f32]) -> (u64, Vec<EmbedderEvent>) {
10063 super::run_requantize_pass(rows, mean)
10064 }
10065}
10066
10067/// G9 — Reciprocal Rank Fusion constant. IR-C (2026-06-10b,
10068/// `performance-output-and-compare.md`) found the standard `k≈60` slightly too
10069/// high: the recall gain is concentrated at the top of the list, where a lower
10070/// `k` sharpens rank-1/2 contributions. `k=30` is the validated operating point
10071/// (`k10 > k30 > k60 > k100` on the sweep, `30` the conservative middle).
10072/// Fusion is on **rank**, never raw score.
10073pub const RRF_K: f64 = 30.0;
10074
10075/// G9 / IR-C — per-branch RRF weights. The sweep's optimum is strongly
10076/// **text-dominant** (`text:vector ≈ 3:1`): the lexical (BM25) arm carries
10077/// exact-fact recall and the dense arm, over-weighted, is a net drag on
10078/// exploratory recall (`performance-output-and-compare.md`, 2026-06-10b/e). A
10079/// branch contributes `weight / (RRF_K + rank)`.
10080pub const RRF_WEIGHT_VECTOR: f64 = 1.0;
10081pub const RRF_WEIGHT_TEXT: f64 = 3.0;
10082/// R3 (Slice 30) — graph arm RRF weight. Conservative starting value (equal to
10083/// `RRF_WEIGHT_VECTOR`). Without R2 per-class delta data the graph arm weight
10084/// cannot be calibrated; 1.0 is the minimum non-zero contribution. The graph
10085/// arm surfaces newly-reachable nodes from BFS traversal; it is not meant to
10086/// override the primary text/vector signals. Revisable after R2 data arrives.
10087/// See `dev/design/slice-30-design.md` §Q2.
10088pub const RRF_WEIGHT_GRAPH: f64 = 1.0;
10089
10090/// G12-recency — additive recency weight. Must satisfy two constraints:
10091/// 1. Small enough to never override a clear RRF signal: a gap of > RECENCY_WEIGHT
10092/// between two hits' RRF scores means the stronger RRF hit always wins.
10093/// 2. Large enough to break exact ties: any hit with a higher `write_cursor` (more
10094/// recent) gets RECENCY_WEIGHT × 1.0 > 0 nudge and wins a tied comparison.
10095///
10096/// Value 0.002 satisfies the near-tie-nudge contract with respect to the
10097/// committed test (`recency_does_not_override_a_clear_rrf_signal`):
10098/// the test's RRF gap is 0.01, which is larger than 0.002, so recency
10099/// never overrides it. Note: this value is larger than the minimum
10100/// vector-only rank-step at deep ranks (~0.00101 for adjacent ranks near
10101/// the bottom), so recency can flip a single-rank vector difference at
10102/// deep ranks — by design, recency is a near-tie nudge, and "near-tie"
10103/// is scoped to the test gap (0.01), not to every possible rank step.
10104///
10105/// 0.8.1 Slice 10 fix: the previous value `0.5/RRF_K ≈ 0.01667` violated
10106/// the test gap constraint (it exceeded 0.01). Lowered to 0.002.
10107pub const RECENCY_WEIGHT: f64 = 0.002;
10108
10109/// 0.8.8 Slice 15 — the lowercase wire string for a retrieval arm (telemetry +
10110/// the same spelling `SearchHit.branch` crosses every binding).
10111fn branch_str(branch: SoftFallbackBranch) -> &'static str {
10112 match branch {
10113 SoftFallbackBranch::Vector => "vector",
10114 SoftFallbackBranch::Text => "text",
10115 SoftFallbackBranch::TextEdge => "text_edge",
10116 SoftFallbackBranch::GraphArm => "graph_arm",
10117 }
10118}
10119
10120/// 0.8.8 Slice 15 — append one JSON value as a line to the telemetry sink
10121/// (append-only, local file; no network). Best-effort caller handles the error.
10122fn append_jsonl(path: &Path, value: &serde_json::Value) -> std::io::Result<()> {
10123 let mut file = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
10124 writeln!(file, "{value}")?;
10125 Ok(())
10126}
10127
10128/// 0.8.20 Slice 5b (R-20-E6) — rewrite a telemetry JSONL sink with every
10129/// `result_stable_ids` element in `erased` replaced by [`REDACTED_STABLE_ID`].
10130///
10131/// **Selective, never truncating.** Every line is carried across: records that
10132/// reference no erased id are byte-identical, records that do keep their shape
10133/// and only lose the matching id VALUES, and a line that is not an
10134/// engine-authored JSON event (an operator note, a hand-appended record) is
10135/// copied through verbatim. The sink is a caller-supplied path that may hold
10136/// unrelated eval history; destroying it is not part of any erasure obligation.
10137///
10138/// **Crash safety.** Write-temp-then-`rename`: the redacted content goes to a
10139/// sibling `<sink>.redact.tmp`, is `sync_all`ed, and is then atomically renamed
10140/// over the sink. A crash at any point leaves either the intact old file or the
10141/// complete new one — never a half-rewritten sink. `rename` is atomic because
10142/// the temp file is a sibling (same directory ⇒ same filesystem).
10143///
10144/// **Concurrent appends.** The caller holds the telemetry mutex, so no
10145/// in-process `capture_telemetry` can append into the window. An out-of-process
10146/// appender is still possible (the sink is just a file), so before renaming we
10147/// re-check the source length: if it grew, the tail delta is read, redacted and
10148/// appended, and the check repeats — bounded, so a pathologically hot external
10149/// writer surfaces as an error rather than an unbounded loop.
10150fn redact_jsonl_stable_ids(
10151 path: &Path,
10152 erased: &std::collections::HashSet<&str>,
10153) -> std::io::Result<()> {
10154 /// Bound on the re-check loop for an out-of-process appender.
10155 const MAX_TAIL_FOLDS: usize = 8;
10156
10157 let mut source = std::fs::read(path)?;
10158 let mut redacted = redact_jsonl_bytes(&source, erased);
10159
10160 for _ in 0..MAX_TAIL_FOLDS {
10161 let current = std::fs::read(path)?;
10162 if current.len() == source.len() {
10163 let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
10164 tmp_name.push(".redact.tmp");
10165 let tmp = path.with_file_name(tmp_name);
10166 {
10167 let mut file = std::fs::File::create(&tmp)?;
10168 file.write_all(&redacted)?;
10169 file.sync_all()?;
10170 }
10171 std::fs::rename(&tmp, path)?;
10172 return Ok(());
10173 }
10174 // Someone appended while we were building the replacement: fold the
10175 // delta in (redacted) rather than dropping it, then re-check.
10176 if current.len() > source.len() && current.starts_with(&source) {
10177 redacted.extend_from_slice(&redact_jsonl_bytes(¤t[source.len()..], erased));
10178 } else {
10179 // The file was rewritten under us, not appended to. Start over.
10180 redacted = redact_jsonl_bytes(¤t, erased);
10181 }
10182 source = current;
10183 }
10184 Err(std::io::Error::other(format!(
10185 "telemetry sink {} is being appended to faster than it can be redacted",
10186 path.display()
10187 )))
10188}
10189
10190/// Line-wise redaction of a JSONL byte buffer. Non-JSON and non-event lines are
10191/// passed through unchanged, as is a trailing partial line (no terminating
10192/// newline) — the sink is append-only, so a partial tail is a torn write, not
10193/// ours to normalize.
10194fn redact_jsonl_bytes(bytes: &[u8], erased: &std::collections::HashSet<&str>) -> Vec<u8> {
10195 let mut out = Vec::with_capacity(bytes.len());
10196 let mut rest = bytes;
10197 while !rest.is_empty() {
10198 let (line, tail) = match rest.iter().position(|b| *b == b'\n') {
10199 Some(idx) => (&rest[..idx], &rest[idx + 1..]),
10200 // No trailing newline: a torn/partial final line. Pass it through.
10201 None => {
10202 out.extend_from_slice(rest);
10203 break;
10204 }
10205 };
10206 match redact_jsonl_line(line, erased) {
10207 Some(replacement) => out.extend_from_slice(replacement.as_bytes()),
10208 None => out.extend_from_slice(line),
10209 }
10210 out.push(b'\n');
10211 rest = tail;
10212 }
10213 out
10214}
10215
10216/// `Some(replacement)` when the line is an engine-authored telemetry event whose
10217/// `result_stable_ids` referenced an erased id; `None` to pass it through.
10218fn redact_jsonl_line(line: &[u8], erased: &std::collections::HashSet<&str>) -> Option<String> {
10219 let text = std::str::from_utf8(line).ok()?;
10220 let mut value: serde_json::Value = serde_json::from_str(text).ok()?;
10221 let ids = value.get_mut("result_stable_ids")?.as_array_mut()?;
10222 let mut touched = false;
10223 for id in ids.iter_mut() {
10224 if id.as_str().is_some_and(|s| erased.contains(s)) {
10225 *id = serde_json::Value::from(REDACTED_STABLE_ID);
10226 touched = true;
10227 }
10228 }
10229 touched.then(|| value.to_string())
10230}
10231
10232/// G9 — fuse the vector and text branches with Reciprocal Rank Fusion.
10233///
10234/// Delegates to [`fuse_three_arms`] with an empty graph arm. The two-arm
10235/// contract is preserved: `fuse_rrf(v, t)` == `fuse_three_arms(v, t, vec![])`.
10236/// All existing callers are unaffected.
10237///
10238/// See [`fuse_three_arms`] for the full RRF formula documentation.
10239#[doc(hidden)]
10240#[must_use]
10241pub fn fuse_rrf(vector_hits: Vec<SearchHit>, text_hits: Vec<SearchHit>) -> Vec<SearchHit> {
10242 fuse_three_arms(vector_hits, text_hits, vec![])
10243}
10244
10245/// R3 (Slice 30) — fuse vector, text, and graph arms with Reciprocal Rank Fusion.
10246///
10247/// Each branch contributes `weight / (RRF_K + rank)` (1-based rank within that
10248/// branch; `weight` = [`RRF_WEIGHT_VECTOR`] / [`RRF_WEIGHT_TEXT`] /
10249/// [`RRF_WEIGHT_GRAPH`], text-dominant per IR-C), accumulated **keyed on
10250/// `SearchHit.body`**, so a body surfaced by multiple branches accumulates all
10251/// terms (agreement boosts it). The fused value is written into `SearchHit.score`.
10252/// A body in multiple branches surfaces **once** with the **vector** branch's
10253/// identity (vector-first), then graph arm identity for non-vector hits, then
10254/// text. Output is sorted by score descending, then vector-first, then insertion
10255/// order — a pure, deterministic function of the three input lists.
10256///
10257/// With an empty `graph_hits` (`vec![]`), the output is byte-identical to the
10258/// pre-Slice-30 two-arm `fuse_rrf`. This is the backward-compatibility contract.
10259///
10260/// This is the **unconditional** new ranking (HITL Q3 — no `fusion_mode` knob,
10261/// no legacy path). Graph arm is opt-in via `use_graph_arm=true`.
10262#[doc(hidden)]
10263#[must_use]
10264pub fn fuse_three_arms(
10265 vector_hits: Vec<SearchHit>,
10266 text_hits: Vec<SearchHit>,
10267 graph_hits: Vec<SearchHit>,
10268) -> Vec<SearchHit> {
10269 struct Entry {
10270 hit: SearchHit,
10271 score: f64,
10272 in_vector: bool,
10273 order: usize,
10274 }
10275 let mut entries: Vec<Entry> = Vec::new();
10276 let mut accumulate = |hit: SearchHit, rank0: usize, in_vector: bool, weight: f64| {
10277 let contrib = weight / (RRF_K + (rank0 as f64 + 1.0));
10278 if let Some(existing) = entries.iter_mut().find(|e| e.hit.body == hit.body) {
10279 // Dedup on body; the representative hit (vector-first) is retained.
10280 existing.score += contrib;
10281 } else {
10282 let order = entries.len();
10283 entries.push(Entry { hit, score: contrib, in_vector, order });
10284 }
10285 };
10286 for (rank0, hit) in vector_hits.into_iter().enumerate() {
10287 accumulate(hit, rank0, true, RRF_WEIGHT_VECTOR);
10288 }
10289 for (rank0, hit) in text_hits.into_iter().enumerate() {
10290 accumulate(hit, rank0, false, RRF_WEIGHT_TEXT);
10291 }
10292 for (rank0, hit) in graph_hits.into_iter().enumerate() {
10293 // Graph arm: vector-first=false (never overrides an existing vector hit's
10294 // representative identity; only new bodies from the graph arm get GraphArm
10295 // as their branch identity). The in_vector=false ensures graph arm hits
10296 // never sort ahead of vector hits on exact score ties.
10297 accumulate(hit, rank0, false, RRF_WEIGHT_GRAPH);
10298 }
10299 entries.sort_by(|a, b| {
10300 b.score
10301 .partial_cmp(&a.score)
10302 .unwrap_or(std::cmp::Ordering::Equal)
10303 // vector-first on equal score (true sorts before false).
10304 .then_with(|| b.in_vector.cmp(&a.in_vector))
10305 .then_with(|| a.order.cmp(&b.order))
10306 });
10307 entries
10308 .into_iter()
10309 .map(|mut e| {
10310 e.hit.score = e.score;
10311 e.hit
10312 })
10313 .collect()
10314}
10315
10316/// G12-recency — reweight fused hits toward the more recent (higher
10317/// `write_cursor`/`id`) AFTER bit-KNN (never a vec0 predicate). Gated by the
10318/// caller's dedicated recency flag; `enabled=false` is a no-op (pure RRF).
10319#[doc(hidden)]
10320#[must_use]
10321pub fn apply_recency_reweight(hits: Vec<SearchHit>, enabled: bool) -> Vec<SearchHit> {
10322 if !enabled || hits.len() < 2 {
10323 return hits;
10324 }
10325 let min_id = hits.iter().map(|h| h.write_cursor).min().unwrap_or(0);
10326 let max_id = hits.iter().map(|h| h.write_cursor).max().unwrap_or(0);
10327 if max_id == min_id {
10328 return hits;
10329 }
10330 let span = (max_id - min_id) as f64;
10331 let mut reweighted: Vec<SearchHit> = hits
10332 .into_iter()
10333 .map(|mut h| {
10334 let norm = (h.write_cursor - min_id) as f64 / span;
10335 h.score += RECENCY_WEIGHT * norm;
10336 h
10337 })
10338 .collect();
10339 // Stable sort preserves the fused order on exact ties.
10340 reweighted.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
10341 reweighted
10342}
10343
10344/// 0.8.16 Slice 5 / F9 — OFF-by-default importance/confidence reweight, applied
10345/// to the fused hits AFTER bit-KNN + RRF (mirrors [`apply_recency_reweight`]).
10346///
10347/// Multiplicative-on-fused (ADR-0.8.16 §2.2, HITL-SIGNED 2026-07-08): a hit's
10348/// score is scaled by node `importance` (`importance_by_id`) × edge `confidence`
10349/// (`confidence_by_id`), each keyed by the hit's interim id (`write_cursor`). A
10350/// missing key = `NULL` = never assigned = graceful-absent ⇒ neutral (1.0), the
10351/// OPP-12 Q6a graceful-absent state. Node hits carry `importance`; graph/edge hits
10352/// carry `confidence`; the two id-spaces never collide (cursors are globally
10353/// unique), so each hit gets exactly one non-neutral factor.
10354///
10355/// **R-F9-4 graceful-neutral identity:** when `enabled` but *no* hit has a
10356/// non-neutral factor (every importance/confidence absent), the input is returned
10357/// **unchanged** — byte-identical to the `enabled == false` result (no re-sort),
10358/// so declaring the mechanism never perturbs an all-absent corpus.
10359#[must_use]
10360pub fn apply_importance_reweight(
10361 hits: Vec<SearchHit>,
10362 importance_by_id: &HashMap<u64, f64>,
10363 confidence_by_id: &HashMap<u64, f64>,
10364 enabled: bool,
10365) -> Vec<SearchHit> {
10366 if !enabled {
10367 return hits;
10368 }
10369 // Graceful-neutral fast path (R-F9-4): if nothing is weighted, do not touch
10370 // order or scores — identical to the reweight-OFF result.
10371 let any_weighted = hits.iter().any(|h| {
10372 importance_by_id.contains_key(&h.write_cursor)
10373 || confidence_by_id.contains_key(&h.write_cursor)
10374 });
10375 if !any_weighted {
10376 return hits;
10377 }
10378 let mut reweighted: Vec<SearchHit> = hits
10379 .into_iter()
10380 .map(|mut h| {
10381 let importance = importance_by_id.get(&h.write_cursor).copied().unwrap_or(1.0);
10382 let confidence = confidence_by_id.get(&h.write_cursor).copied().unwrap_or(1.0);
10383 h.score *= importance * confidence;
10384 h
10385 })
10386 .collect();
10387 // Stable sort preserves the fused order on exact ties.
10388 reweighted.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
10389 reweighted
10390}
10391
10392/// 0.8.16 Slice 5 / F9 — build the per-hit importance/confidence weight maps for
10393/// the candidate `hits` from the durable columns (`canonical_nodes.importance`,
10394/// `canonical_edges.confidence`). Only NON-NULL values are inserted; an absent
10395/// value stays out of the map (graceful-absent ⇒ neutral in
10396/// [`apply_importance_reweight`]). Prepared statements are guarded so a pre-step-18
10397/// / pre-step-14 schema (no column) yields empty maps rather than an error.
10398fn build_importance_confidence_maps(
10399 tx: &rusqlite::Connection,
10400 hits: &[SearchHit],
10401) -> rusqlite::Result<(HashMap<u64, f64>, HashMap<u64, f64>)> {
10402 let mut importance_by_id: HashMap<u64, f64> = HashMap::new();
10403 let mut confidence_by_id: HashMap<u64, f64> = HashMap::new();
10404 if let Ok(mut stmt) =
10405 tx.prepare("SELECT importance FROM canonical_nodes WHERE write_cursor = ?1 LIMIT 1")
10406 {
10407 for h in hits {
10408 if let Ok(Some(v)) = stmt.query_row([h.write_cursor], |r| r.get::<_, Option<f64>>(0)) {
10409 importance_by_id.insert(h.write_cursor, v);
10410 }
10411 }
10412 }
10413 if let Ok(mut stmt) = tx.prepare(
10414 "SELECT confidence FROM canonical_edges \
10415 WHERE write_cursor = ?1 AND superseded_at IS NULL LIMIT 1",
10416 ) {
10417 for h in hits {
10418 if let Ok(Some(v)) = stmt.query_row([h.write_cursor], |r| r.get::<_, Option<f64>>(0)) {
10419 confidence_by_id.insert(h.write_cursor, v);
10420 }
10421 }
10422 }
10423 Ok((importance_by_id, confidence_by_id))
10424}
10425
10426/// 0.8.1 Slice 10 (R1) — CE rerank seam.
10427///
10428/// `rerank_depth = 0` (or model absent / `default-reranker` feature off): returns
10429/// `hits` **unchanged** — byte-identical to the old identity stub. This is the
10430/// soft-fallback contract.
10431///
10432/// `rerank_depth > 0` with the `default-reranker` feature on and the model
10433/// loaded: scores the top-`rerank_depth` (query, passage) pairs with the
10434/// TinyBERT-L-2 cross-encoder, blends CE score with the RRF score using the
10435/// formula from the design memo (Decision 5), re-sorts the top-N, and appends
10436/// the remainder in their original RRF order.
10437///
10438/// Score-blend (Decision 5): `α × sigmoid(ce_logit) + (1−α) × rrf_score_normalized`
10439/// where both CE and RRF scores are normalized to [0,1] over the reranked pool.
10440///
10441/// 0.8.5 (EXP-0): `alpha` (clamped to `[0,1]`) and `pool_n` (the reranked-pool
10442/// size, clamped to `hits.len()`) are caller-supplied. The defaults
10443/// `alpha = 0.3, pool_n = rerank_depth` reproduce the pre-slice blend exactly.
10444/// `rerank_depth == 0` remains the identity gate regardless of `pool_n`.
10445///
10446/// This is the rerank hook, **not** the dropped `fusion_mode` knob.
10447#[doc(hidden)]
10448#[must_use]
10449pub fn rerank_fused(
10450 _query: &str,
10451 hits: Vec<SearchHit>,
10452 rerank_depth: usize,
10453 alpha: f64,
10454 pool_n: usize,
10455) -> Vec<SearchHit> {
10456 // Soft-fallback: depth=0 → identity (byte-identical to old stub). NOTE this
10457 // early gate is independent of `pool_n`: `rerank_depth == 0, pool_n = 10`
10458 // does NOT rerank (0.8.5 D4).
10459 if rerank_depth == 0 {
10460 return hits;
10461 }
10462
10463 // Feature-gated CE inference. In the default build (no feature) this block
10464 // compiles away and `hits` is returned unchanged regardless of `rerank_depth`.
10465 // FIX-1: pass `&hits` (borrow) so `hits` remains owned for the soft-fallback path.
10466 #[cfg(feature = "default-reranker")]
10467 {
10468 if let Some(reranked) = ce_rerank(_query, &hits, rerank_depth, alpha, pool_n) {
10469 return reranked;
10470 }
10471 }
10472
10473 // 0.8.5: the bindings/default callers pass `alpha = 0.3, pool_n = rerank_depth`;
10474 // referenced here so the no-feature build does not warn on unused params.
10475 #[cfg(not(feature = "default-reranker"))]
10476 let _ = (alpha, pool_n);
10477
10478 // Model absent (feature off, weights not loaded, or CE returned None) →
10479 // soft-fallback: return input unchanged.
10480 hits
10481}
10482
10483/// 0.8.2 Slice E2 — standalone CE rerank of a caller-supplied passage list.
10484///
10485/// The pure, testable core that the `fathomdb.rerank` pyo3 binding is a thin
10486/// wrapper over. Slice 5's `fused_rerank` comparator must CE-rerank its OWN
10487/// in-harness fused(bm25+dense) pool — a pool the engine's `search()` never
10488/// constructs — so the CE has to be reachable over an arbitrary passage list,
10489/// not just the engine's capped text-only pool. This adapts `(id, body, score)`
10490/// passages into `SearchHit`s (`kind = "passage"`, `branch = Vector`,
10491/// `source_id = None`; only `body` and `score` feed the blend), runs them
10492/// through [`rerank_fused`], and projects back to `(id, score, ce_score)` in the reranked
10493/// order.
10494///
10495/// Contract (inherited verbatim from `rerank_fused`): `rerank_depth == 0` OR an
10496/// empty list returns the input order WITH the input scores, byte-identical — no
10497/// model load, no network. With `--features default-reranker` and
10498/// `rerank_depth > 0` the CE blends the top-`depth` and may reorder; with the
10499/// feature off the CE path compiles away and this is always identity.
10500///
10501/// 0.8.2 Slice E2 fix-1 [P2]: returns `Err` when any passage carries a non-finite
10502/// score (NaN / ±inf), mirroring the malformed-passage loud-fail contract.
10503/// Callers (pyo3 `rerank` binding, tests) must handle `Result`.
10504/// (`#[must_use]` removed: `Result` is already `#[must_use]`.)
10505pub fn rerank_passages(
10506 query: &str,
10507 passages: Vec<(u64, String, f64)>,
10508 rerank_depth: usize,
10509 alpha: f64,
10510 pool_n: usize,
10511) -> Result<Vec<(u64, f64, Option<f64>)>, String> {
10512 // [P2] guard: reject non-finite scores before they reach normalization/sort.
10513 // A NaN or ±inf score would produce NaN blended scores and an unstable sort
10514 // order — surface the error early as the typed WriteValidationError at the
10515 // pyo3 boundary (mirroring the malformed-passage loud-fail contract).
10516 for (id, _, score) in &passages {
10517 if !score.is_finite() {
10518 return Err(format!(
10519 "rerank: non-finite score for passage id={id}: {score} \
10520 (NaN/\u{00b1}inf must not reach the normalization/sort step)"
10521 ));
10522 }
10523 }
10524 let hits: Vec<SearchHit> = passages
10525 .into_iter()
10526 .map(|(id, body, score)| SearchHit {
10527 // C-2: synthetic passages carry no canonical identity — mint the
10528 // `Passage` (`p:`) id from the caller-supplied ordinal. The ordinal
10529 // is ALSO kept as the engine-internal positional cursor so the
10530 // projection below returns it byte-unchanged.
10531 id: IdSpace::passage(id.to_string()),
10532 write_cursor: id,
10533 kind: "passage".to_string(),
10534 body,
10535 score,
10536 branch: SoftFallbackBranch::Vector,
10537 source_id: None,
10538 ce_score: None,
10539 })
10540 .collect();
10541 // 0.8.5 — project `(id, score, ce_score)` so the binding can surface the CE
10542 // score per candidate; `ce_score` is `None` for the identity / out-of-pool path.
10543 // The projected id is the caller's ordinal (the engine-internal `write_cursor`).
10544 Ok(rerank_fused(query, hits, rerank_depth, alpha, pool_n)
10545 .into_iter()
10546 .map(|h| (h.write_cursor, h.score, h.ce_score))
10547 .collect())
10548}
10549
10550/// 0.8.1 Slice 10 — score-blend reranking when CE model is loaded.
10551///
10552/// Returns `Some(reranked)` if the model is available, `None` otherwise
10553/// (caller then applies the soft-fallback).
10554///
10555/// Design memo Decision 5:
10556/// - CE normalized = sigmoid(raw_logit) ∈ [0,1]
10557/// - RRF normalized = min-max of `hit.score` over the top-K pool
10558/// - `final_score = 0.3 × ce_norm + 0.7 × rrf_norm`
10559/// - Hits beyond `rerank_depth` keep their original RRF scores and order.
10560#[cfg(feature = "default-reranker")]
10561fn ce_rerank(
10562 _query: &str,
10563 hits: &[SearchHit], // FIX-1: borrow, not move — caller retains ownership for soft-fallback
10564 _rerank_depth: usize, // 0.8.5: pool sizing moved to `pool_n`; depth gate stays in `rerank_fused`.
10565 alpha: f64,
10566 pool_n: usize,
10567) -> Option<Vec<SearchHit>> {
10568 // 0.8.5 (D3) — clamp α to [0,1] silently here so EVERY path (engine search,
10569 // `rerank_passages`, the bindings) is covered by one clamp, matching the
10570 // existing `pool_n.min(len)` clamp idiom.
10571 // codex §9 P2-1: `f64::clamp(NaN)` returns NaN (clamp does NOT map NaN into
10572 // range) — a non-finite α would then make every blended score NaN and destroy
10573 // the ranking. The high-level SDKs reject non-finite α, but the low-level
10574 // `rerank()` / direct-Rust callers don't, so fall back to the documented
10575 // default α=0.3 here for any non-finite input.
10576 let alpha = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 0.3 };
10577 // fix-1 [P2]: short-circuit before touching the singleton when there is
10578 // nothing to rerank — avoids loading/downloading the ~17 MB model for an
10579 // empty result set and prevents memoizing a transient load failure.
10580 if hits.is_empty() {
10581 return Some(vec![]);
10582 }
10583
10584 // Try to get the loaded model. Returns None when weights are absent.
10585 let model = CandleCrossEncoder::try_get_loaded()?;
10586
10587 // 0.8.5 (D4) — the reranked pool is the top `pool_n` (caller resolves the
10588 // `unwrap_or(rerank_depth)` default at the binding), clamped to the hit count.
10589 let n = pool_n.min(hits.len());
10590 let top = &hits[..n]; // no split_at_mut needed; borrow slices directly
10591 let rest = &hits[n..];
10592
10593 // --- RRF min-max normalization over the top-N pool ---
10594 let rrf_min = top.iter().map(|h| h.score).fold(f64::INFINITY, f64::min);
10595 let rrf_max = top.iter().map(|h| h.score).fold(f64::NEG_INFINITY, f64::max);
10596 let rrf_span = rrf_max - rrf_min;
10597
10598 // Batched CE scoring: ONE forward over the whole top-N pool instead of N
10599 // per-pair forwards. The ranking math below (RRF min-max norm, sigmoid,
10600 // ALPHA blend, sort) is byte-unchanged — only the scoring is batched.
10601 let bodies: Vec<&str> = top.iter().map(|h| h.body.as_str()).collect();
10602 let raw_logits = model.score_batch(_query, &bodies);
10603
10604 let mut scored: Vec<(f64, SearchHit)> = top
10605 .iter()
10606 .zip(raw_logits)
10607 .map(|(h, raw_logit)| {
10608 let rrf_norm = if rrf_span > 0.0 { (h.score - rrf_min) / rrf_span } else { 1.0 };
10609 // Sigmoid for CE normalization: 1/(1+exp(-x)).
10610 let ce_norm = 1.0 / (1.0 + (-raw_logit).exp());
10611 // 0.8.5 — α is the caller-supplied (clamped) blend weight; default 0.3
10612 // reproduces the pre-slice `const ALPHA = 0.3` blend exactly.
10613 let blended = alpha * ce_norm + (1.0 - alpha) * rrf_norm;
10614 // 0.8.5 (D1) — expose the per-candidate CE score on in-pool hits.
10615 let mut hit = h.clone();
10616 hit.ce_score = Some(ce_norm);
10617 (blended, hit)
10618 })
10619 .collect();
10620
10621 // Sort top-N by blended score descending (stable within ties by original order).
10622 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
10623
10624 let mut result: Vec<SearchHit> = scored
10625 .into_iter()
10626 .map(|(score, mut h)| {
10627 h.score = score;
10628 h
10629 })
10630 .collect();
10631
10632 // Append hits beyond rerank_depth in their original RRF order.
10633 result.extend_from_slice(rest);
10634 Some(result)
10635}
10636
10637/// 0.8.1 Slice 10 (R1) / 0.8.2 Slice E1 — CPU TinyBERT-L-2 cross-encoder.
10638///
10639/// Thin engine-side handle over the embedder crate's `CandleTinyBertReranker`
10640/// (Candle BERT stack + `tokenizers`, pinned `cross-encoder/ms-marco-TinyBERT-
10641/// L2-v2`). The model is loaded once, process-wide, the first time
10642/// `rerank_depth > 0` reaches the CE path (lazy init via the `OnceLock` below);
10643/// on cache miss that first load fetches the ~17 MB weights over the network
10644/// (sha256-verified). When the weights are absent and the network is
10645/// unavailable, the load fails and `try_get_loaded()` returns `None` so the
10646/// caller soft-falls-back to RRF order — it never panics.
10647///
10648/// Footprint: this whole type compiles ONLY under `default-reranker`. With the
10649/// feature off the CE path compiles away and `rerank_fused` is always identity.
10650/// With the feature on, `rerank_depth == 0` short-circuits in `rerank_fused`
10651/// BEFORE this is ever touched, so depth-0 stays byte-identical and no-network.
10652/// Similarly, an empty hit set short-circuits in `ce_rerank` before the singleton
10653/// is consulted (fix-1 [P2]).
10654#[cfg(feature = "default-reranker")]
10655struct CandleCrossEncoder {
10656 inner: &'static fathomdb_embedder::CandleTinyBertReranker,
10657}
10658
10659/// Process-wide lazily-initialized reranker. `None` once initialization has
10660/// been attempted and failed (no weights + no network) — memoized so a failed
10661/// load is not retried on every query.
10662#[cfg(feature = "default-reranker")]
10663fn reranker_singleton() -> Option<&'static fathomdb_embedder::CandleTinyBertReranker> {
10664 static CELL: std::sync::OnceLock<Option<fathomdb_embedder::CandleTinyBertReranker>> =
10665 std::sync::OnceLock::new();
10666 CELL.get_or_init(|| fathomdb_embedder::CandleTinyBertReranker::try_load().ok()).as_ref()
10667}
10668
10669#[cfg(feature = "default-reranker")]
10670impl CandleCrossEncoder {
10671 /// Returns a model handle if the reranker is (or can be) loaded, `None`
10672 /// otherwise. The first call drives the lazy load (cache probe → gated
10673 /// download); subsequent calls reuse the memoized result.
10674 fn try_get_loaded() -> Option<Self> {
10675 Some(Self { inner: reranker_singleton()? })
10676 }
10677
10678 /// Score a (query, passage) pair. Returns the raw cross-encoder logit, or
10679 /// `0.0` (a neutral logit → sigmoid 0.5) if the forward pass errors, so a
10680 /// single bad pair degrades to a neutral CE contribution rather than
10681 /// panicking in the reader thread.
10682 fn score(&self, query: &str, passage: &str) -> f64 {
10683 self.inner.score(query, passage).map(f64::from).unwrap_or(0.0)
10684 }
10685
10686 /// Batched [`score`](Self::score): score every `(query, passage_i)` pair in a
10687 /// single forward pass. Returns one logit per passage in input order, each
10688 /// honoring the same neutral-`0.0`-on-error contract as [`score`](Self::score).
10689 ///
10690 /// Fallback: if the batched forward errors as a whole (e.g. an OOM or a
10691 /// tokenize failure on one pair surfaces as a batch `Err`), we DO NOT
10692 /// neutralize the entire pool — we fall back to per-pair [`score`](Self::score),
10693 /// so a single bad pair degrades only its own element to a neutral logit while
10694 /// the rest keep their real scores. Empty input → empty output (no forward).
10695 fn score_batch(&self, query: &str, passages: &[&str]) -> Vec<f64> {
10696 match self.inner.score_batch(query, passages) {
10697 Ok(logits) => logits.into_iter().map(f64::from).collect(),
10698 Err(_) => passages.iter().map(|p| self.score(query, p)).collect(),
10699 }
10700 }
10701}
10702
10703/// 0.8.20 Slice 15e fix-2 finding 1 [P2] + keystone closeout fix-3 (codex §9 [P2],
10704/// TOCTOU) — reject a search filter that names an attribute with NO declared
10705/// `filterable` projection, ON THE READER'S OWN SNAPSHOT, before the vec0 SQL is
10706/// built.
10707///
10708/// The vector arm lowers each `filter.attributes` term to `AND attr_<hex>=?`
10709/// against a vec0 metadata column that exists ONLY for a declared `filterable`
10710/// projection (the reshape in [`reconcile_vector_attr_columns`] tracks exactly the
10711/// registry's `filterable` set; see [`desired_vector_attr_columns`]). A name that
10712/// is not a declared `filterable` projection therefore has no column: the vec0 KNN
10713/// would fail with `no such column` (surfacing as an opaque `Storage` error),
10714/// while the FTS arm ([`hit_attributes_pass_filter`]) would silently no-match. That
10715/// divergence violates ADR-0.8.11 D3 (every filter term has a DEFINED, arm-uniform
10716/// outcome).
10717///
10718/// fix-3 snapshot contract: this is called from [`read_search_in_tx`] INSIDE the
10719/// reader's `DEFERRED` transaction, so the `_fathomdb_projection_registry` it reads
10720/// and the `vector_default` columns [`vector_filter_clause`] compiles against are
10721/// the SAME WAL snapshot. A concurrent `configure_projections` DROP either commits
10722/// before this snapshot is pinned (then BOTH the registry and the vec0 columns show
10723/// the attribute gone ⇒ a consistent `InvalidFilter`) or after (then it is invisible
10724/// to this transaction and BOTH still show it declared ⇒ the query runs against the
10725/// column it validated). The registry's `filterable` set is the arm-INDEPENDENT
10726/// authority (correct even with no embedder / no `vector_default`, where a
10727/// declared-`filterable` term still filters legitimately via the row-owned
10728/// `canonical_attributes` EAV store). The caller re-raises
10729/// [`SearchReaderError::InvalidFilter`] as the EXISTING typed
10730/// [`EngineError::InvalidFilter`], so both arms see the SAME rejection because it is
10731/// raised before either runs.
10732fn validate_filter_attributes_on_snapshot(
10733 conn: &Connection,
10734 filter: &SearchFilter,
10735) -> Result<(), SearchReaderError> {
10736 if filter.attributes.is_empty() {
10737 return Ok(());
10738 }
10739 // `?` maps a registry-read failure to `SearchReaderError::Sqlite` (unchanged
10740 // `Storage` semantics for a genuine backend fault) via the `From` impl.
10741 let registry = load_projection_registry(conn)?;
10742 for (name, _value) in &filter.attributes {
10743 let declared_filterable =
10744 registry.get(name).is_some_and(|s| s.roles.contains(&ProjectionRole::Filterable));
10745 if !declared_filterable {
10746 return Err(SearchReaderError::InvalidFilter(format!(
10747 "filter attribute {name:?} is not a declared `filterable` projection; \
10748 declare it via configure_projections before filtering on it"
10749 )));
10750 }
10751 }
10752 Ok(())
10753}
10754
10755/// G10 — the `AND col=?n` predicate fragment appended to the phase-1 candidates
10756/// `WHERE` for the present filter fields. Placeholders are numbered from `?3`
10757/// (`?1` = sign-quant query, `?2` = f32 rerank query). Field order is canonical
10758/// (`source_type`, `kind`, `created_after`, `status`), THEN the Slice-15e
10759/// `filterable`-attribute predicates (`attr_<hex>=?n`) in `attributes` order, and
10760/// is mirrored exactly by [`vector_filter_values`]. Empty for `None`/all-`None`
10761/// (byte-identity path).
10762fn vector_filter_clause(filter: Option<&SearchFilter>) -> String {
10763 let Some(filter) = filter else {
10764 return String::new();
10765 };
10766 if filter.is_unfiltered() {
10767 return String::new();
10768 }
10769 // 0.8.20 Slice 15e — the attribute predicates encode the (arbitrary,
10770 // possibly space/unicode-bearing) registry name into the byte-safe
10771 // `attr_<hex>` column vec0 accepts (vec0 rejects quoted identifiers). Owned
10772 // strings so they live past the closure; the shipped metadata columns are
10773 // static `&str`.
10774 let mut cols: Vec<(String, &str)> = Vec::new();
10775 if filter.source_type.is_some() {
10776 cols.push(("source_type".to_string(), "="));
10777 }
10778 if filter.kind.is_some() {
10779 cols.push(("kind".to_string(), "="));
10780 }
10781 if filter.created_after.is_some() {
10782 cols.push(("created_at".to_string(), ">="));
10783 }
10784 if filter.status.is_some() {
10785 cols.push(("status".to_string(), "="));
10786 }
10787 for (name, _value) in &filter.attributes {
10788 cols.push((attr_vec0_column(name), "="));
10789 }
10790 let mut clause = String::new();
10791 for (i, (col, op)) in cols.iter().enumerate() {
10792 clause.push_str(&format!(" AND {col}{op}?{}", i + 3));
10793 }
10794 clause
10795}
10796
10797/// G10 — the bound values for the present filter fields, in the SAME canonical
10798/// order as [`vector_filter_clause`] so placeholder `?{n}` lines up with value
10799/// `n-3`.
10800fn vector_filter_values(filter: Option<&SearchFilter>) -> Vec<rusqlite::types::Value> {
10801 use rusqlite::types::Value;
10802 let mut out = Vec::new();
10803 let Some(filter) = filter else {
10804 return out;
10805 };
10806 if filter.is_unfiltered() {
10807 return out;
10808 }
10809 if let Some(s) = &filter.source_type {
10810 out.push(Value::Text(s.clone()));
10811 }
10812 if let Some(s) = &filter.kind {
10813 out.push(Value::Text(s.clone()));
10814 }
10815 if let Some(c) = filter.created_after {
10816 out.push(Value::Integer(c));
10817 }
10818 if let Some(s) = &filter.status {
10819 out.push(Value::Text(s.clone()));
10820 }
10821 // 0.8.20 Slice 15e — attribute values, in the SAME order the clause appended
10822 // the `attr_<hex>` columns (after the four metadata fields). fix-3 [P2] — the
10823 // filter value is encoded `\x01 || V` to match the encoded PRESENT column
10824 // value, so `attr_<hex> = enc("")` matches present-empty but NEVER the
10825 // `''`-absent rows.
10826 for (_name, value) in &filter.attributes {
10827 out.push(Value::Text(encode_attr_vec0_present(value)));
10828 }
10829 out
10830}
10831
10832/// G10 — build the single phase-1 candidates statement. With `filter=None` (or
10833/// all-`None`) the `{filter_clause}` is empty and the SQL is **byte-identical to
10834/// 0.7.2** (the documented behavior-compat invariant; pinned by
10835/// `pr_g10_filtered_knn.rs`). The KNN form (`ORDER BY distance LIMIT top_k`, no
10836/// `k=`) is preserved.
10837fn build_vector_phase1_sql(filter: Option<&SearchFilter>, final_limit: usize) -> String {
10838 let filter_clause = vector_filter_clause(filter);
10839 format!(
10840 "WITH candidates AS (
10841 SELECT rowid
10842 FROM vector_default
10843 WHERE embedding_bin MATCH vec_quantize_binary(vec_f32(?1)){filter_clause}
10844 ORDER BY distance
10845 LIMIT {top_k}
10846 )
10847 SELECT c.rowid, vec_distance_l2(v.embedding, vec_f32(?2)) AS l2
10848 FROM candidates c
10849 JOIN vector_default v ON v.rowid = c.rowid
10850 ORDER BY l2
10851 LIMIT {final_limit}",
10852 top_k = TOP_K_BIT_CANDIDATES,
10853 )
10854}
10855
10856/// Test seam — exposes [`build_vector_phase1_sql`] at the production
10857/// `SEARCH_RERANK_LIMIT` so `pr_g10_filtered_knn.rs` can pin the `filter=None`
10858/// byte-identity and the appended predicates.
10859#[doc(hidden)]
10860#[must_use]
10861pub fn vector_phase1_sql_for_test(filter: Option<&SearchFilter>) -> String {
10862 build_vector_phase1_sql(filter, SEARCH_RERANK_LIMIT)
10863}
10864
10865/// G10 — does a text-branch hit satisfy the filter? The vector branch is
10866/// pruned in-SQL; the text branch is constrained here against the same metadata:
10867/// `kind` directly, `source_type` via [`resolve_source_type`], and
10868/// `created_after`/`status` from `vector_default` by `rowid == write_cursor`. A
10869/// text-only row absent from the vector partition cannot satisfy a
10870/// `created_after`/`status` predicate, so it is excluded — filtered semantic
10871/// search is a vector-metadata capability.
10872fn text_hit_passes_filter(
10873 tx: &rusqlite::Transaction<'_>,
10874 id: u64,
10875 kind: &str,
10876 filter: Option<&SearchFilter>,
10877) -> rusqlite::Result<bool> {
10878 let Some(filter) = filter else {
10879 return Ok(true);
10880 };
10881 if filter.is_unfiltered() {
10882 return Ok(true);
10883 }
10884 if let Some(k) = &filter.kind {
10885 if kind != k {
10886 return Ok(false);
10887 }
10888 }
10889 if let Some(st) = &filter.source_type {
10890 match resolve_source_type(kind) {
10891 Ok(resolved) if resolved == st.as_str() => {}
10892 _ => return Ok(false),
10893 }
10894 }
10895 if filter.created_after.is_some() || filter.status.is_some() {
10896 let meta: Option<(i64, Option<String>)> = tx
10897 .query_row(
10898 "SELECT created_at, status FROM vector_default WHERE rowid = ?1 LIMIT 1",
10899 [id as i64],
10900 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?)),
10901 )
10902 .optional()?;
10903 let Some((created_at, status)) = meta else {
10904 // No vector-partition row: cannot satisfy a vec-metadata predicate.
10905 return Ok(false);
10906 };
10907 if let Some(bound) = filter.created_after {
10908 if created_at < bound {
10909 return Ok(false);
10910 }
10911 }
10912 if let Some(want) = &filter.status {
10913 if status.as_deref() != Some(want.as_str()) {
10914 return Ok(false);
10915 }
10916 }
10917 }
10918 // 0.8.20 Slice 15e fix-1 finding 1 [P2] — enforce the declared-`filterable`
10919 // attribute-equality predicates on the TEXT/FTS arm too (D3 total dispatch).
10920 if !hit_attributes_pass_filter(tx, id, filter)? {
10921 return Ok(false);
10922 }
10923 Ok(true)
10924}
10925
10926/// 0.8.20 Slice 15e fix-1 finding 1 [P2] — do a TEXT/FTS hit's `filterable`
10927/// attributes satisfy `filter.attributes`?
10928///
10929/// The vector arm enforces each `(attr_name, value)` pre-KNN as `attr_<hex>=?`
10930/// against the vec0 metadata column. The FTS/text arm must enforce the SAME
10931/// equality so a hybrid RRF fusion is coherent (ADR-0.8.11 D3 — every filter term
10932/// has a defined outcome on EVERY surface; no arm silently ignores it). Without
10933/// this, a doc returned by the FTS arm that FAILS the attribute filter still
10934/// surfaces in a hybrid search — a false positive.
10935///
10936/// The value is read from the row-owned `canonical_attributes` EAV table (keyed
10937/// by the hit's `write_cursor` + `attr_name`), which Slice 15d keeps active-only
10938/// and populates via the SAME [`extract_scalar_attribute`] that fills the vec0
10939/// `attr_<hex>` column — so the two arms see IDENTICAL values by construction.
10940///
10941/// # fix-3 [P2]: ABSENT vs PRESENT-EMPTY
10942///
10943/// A real empty-string value (`{"status":""}`) is DISTINCT from an absent
10944/// attribute. On this (FTS/text) arm the distinction is ROW EXISTENCE: a present
10945/// attribute (even value `''`) has a `canonical_attributes` row; an absent one has
10946/// NONE. So a filter `("status","")` matches present-empty (row exists, RAW value
10947/// `''`) but NOT absent (no row), and `("status","open")` matches only the
10948/// `open` row. The vec0 arm reaches the SAME verdict via its `\x01`-marker
10949/// encoding (see [`ATTR_VEC0_PRESENT_MARKER`]): present-empty is the bare marker,
10950/// absent is `''`, so `attr_<hex> = enc("")` matches present-empty but never
10951/// absent. `canonical_attributes.attr_value` and `property_search_index` stay RAW.
10952///
10953/// # 0.8.20 semantics (Finding 1 → HITL ruling (A)): attribute filters are NODE-scoped
10954///
10955/// Attribute projection is `PreparedWrite::Node`-gated (see `collect_projection_jobs`
10956/// / [`project_one_attribute`]): an EDGE is never projected into
10957/// `canonical_attributes`, and its `vector_default` row (kind `edge_fact`) carries
10958/// the `''` sentinel in every `attr_<hex>` column (the async worker reads the body
10959/// from `canonical_nodes`, which has no row for an edge cursor). Therefore an
10960/// attribute filter **excludes every edge hit** — on BOTH the edge-FTS arm (this
10961/// helper, keyed by the edge's write_cursor, reads `''`) and the edge-vector arm
10962/// (the pre-KNN `attr_<hex>='…'` predicate prunes the `''`-sentinel edge row) —
10963/// even when the edge body itself names the attribute. This is the intended
10964/// 0.8.20 behaviour, pinned by `attribute_filter_excludes_edge_hits_on_both_arms`.
10965///
10966/// The reserved widening is **(D) endpoint-node filtering** (an edge passes iff its
10967/// endpoint node(s) satisfy the attribute predicate): **(A) is (D) with an empty
10968/// endpoint rule.** (B) edges-pass-through and (C) project-edge-attributes are the
10969/// other reserved options. None are implemented in 0.8.20 — do not add a per-query
10970/// flag; a widening is a deliberate, separately-governed later slice.
10971fn hit_attributes_pass_filter(
10972 tx: &rusqlite::Transaction<'_>,
10973 id: u64,
10974 filter: &SearchFilter,
10975) -> rusqlite::Result<bool> {
10976 for (name, want) in &filter.attributes {
10977 // fix-3 [P2] — distinguish ABSENT from PRESENT-EMPTY by ROW EXISTENCE: a
10978 // present attribute (including one whose value is a real empty string `''`)
10979 // has a `canonical_attributes` row; an absent one has NONE. The outer
10980 // `Option` is row existence; the RAW `attr_value` is compared verbatim.
10981 // This mirrors the vec0 arm exactly (present-empty matches `("k","")`;
10982 // absent matches nothing, including `""`), so a fused hybrid search is
10983 // coherent. `canonical_attributes.attr_value` stays RAW (unencoded).
10984 let stored: Option<Option<String>> = tx
10985 .query_row(
10986 "SELECT attr_value FROM canonical_attributes \
10987 WHERE write_cursor = ?1 AND attr_name = ?2 LIMIT 1",
10988 params![id as i64, name],
10989 |row| row.get::<_, Option<String>>(0),
10990 )
10991 .optional()?;
10992 match stored {
10993 // Present (row exists) and the RAW value equals the filter value.
10994 Some(Some(v)) if v.as_str() == want.as_str() => {}
10995 // Absent (no row), present-but-NULL, or present-but-different ⇒ fail.
10996 _ => return Ok(false),
10997 }
10998 }
10999 Ok(true)
11000}
11001
11002/// G11 (Slice 15) — does an edge FTS hit satisfy the filter?
11003///
11004/// Edge FTS hits always have `source_type = "edge_fact"` (the partition
11005/// discriminant). Their `row.kind` is the **relation** kind (e.g. `"owns"`,
11006/// `"works_for"`), not a node kind, so [`text_hit_passes_filter`] MUST NOT be
11007/// used for edge hits: `resolve_source_type(relation_kind)` returns `Err` for
11008/// unknown kinds, causing every edge hit to be silently rejected when a
11009/// `source_type` filter is set — the exact inverse of correct behaviour.
11010///
11011/// Edge bodies ARE projected into `vector_default` (rowid = `write_cursor`),
11012/// so `created_after` / `status` are satisfied by querying `vector_default`
11013/// exactly as [`text_hit_passes_filter`] does for node hits.
11014///
11015/// Rules:
11016/// - `source_type`: pass iff `None` **or** `== "edge_fact"`.
11017/// - `kind`: filter on the relation kind (`row.kind`) if specified.
11018/// - `created_after` / `status`: query `vector_default WHERE rowid = write_cursor`;
11019/// if absent from the vector partition the hit cannot satisfy a vec-metadata
11020/// predicate and is excluded.
11021fn edge_fts_hit_passes_filter(
11022 tx: &rusqlite::Transaction<'_>,
11023 write_cursor: u64,
11024 row_kind: &str,
11025 filter: Option<&SearchFilter>,
11026) -> rusqlite::Result<bool> {
11027 let Some(filter) = filter else {
11028 return Ok(true);
11029 };
11030 if filter.is_unfiltered() {
11031 return Ok(true);
11032 }
11033 if let Some(ref st) = filter.source_type {
11034 if st != "edge_fact" {
11035 return Ok(false); // filter targets a specific non-edge source_type
11036 }
11037 }
11038 if let Some(ref k) = filter.kind {
11039 if k != row_kind {
11040 return Ok(false); // kind filter applies to the relation kind
11041 }
11042 }
11043 // Edge bodies are projected into vector_default; check created_after/status
11044 // there, the same way text_hit_passes_filter does for node hits.
11045 if filter.created_after.is_some() || filter.status.is_some() {
11046 let meta: Option<(i64, Option<String>)> = tx
11047 .query_row(
11048 "SELECT created_at, status FROM vector_default WHERE rowid = ?1 LIMIT 1",
11049 [write_cursor as i64],
11050 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?)),
11051 )
11052 .optional()?;
11053 let Some((created_at, status)) = meta else {
11054 // No vector-partition row: cannot satisfy a vec-metadata predicate.
11055 return Ok(false);
11056 };
11057 if let Some(bound) = filter.created_after {
11058 if created_at < bound {
11059 return Ok(false);
11060 }
11061 }
11062 if let Some(want) = &filter.status {
11063 if status.as_deref() != Some(want.as_str()) {
11064 return Ok(false);
11065 }
11066 }
11067 }
11068 // 0.8.20 Slice 15e fix-1 finding 1 [P2] — an edge body is projected into
11069 // vector_default (rowid = write_cursor) with the same `attr_<hex>` pre-KNN
11070 // columns as a node, and Slice 15d projects an edge's `filterable` attributes
11071 // into `canonical_attributes` keyed by its write_cursor. Enforce the same
11072 // attribute equality here so the edge-FTS arm matches the vector arm (D3 total
11073 // dispatch). An edge that carries no such attribute reads the `''` sentinel and
11074 // fails a non-empty equality, exactly as on the vector arm.
11075 if !hit_attributes_pass_filter(tx, write_cursor, filter)? {
11076 return Ok(false);
11077 }
11078 Ok(true)
11079}
11080
11081/// Apply every edge filter except declared projection attributes. This isolates
11082/// the explanatory count from edge candidates rejected for an independent
11083/// source-type, relation-kind, or vec-metadata predicate.
11084fn edge_fts_hit_passes_non_attribute_filter(
11085 tx: &rusqlite::Transaction<'_>,
11086 write_cursor: u64,
11087 row_kind: &str,
11088 filter: Option<&SearchFilter>,
11089) -> rusqlite::Result<bool> {
11090 let Some(filter) = filter else {
11091 return Ok(true);
11092 };
11093 let mut non_attribute_filter = filter.clone();
11094 non_attribute_filter.attributes.clear();
11095 edge_fts_hit_passes_filter(tx, write_cursor, row_kind, Some(&non_attribute_filter))
11096}
11097
11098/// Read projection cursor and matching body rows inside one read tx.
11099fn read_projected_text_in_tx(
11100 reader: &mut Connection,
11101 query: &str,
11102 name: &str,
11103 filter: Option<&SearchFilter>,
11104 limit: usize,
11105 view: ReadView,
11106) -> ProjectedTextReaderResponse {
11107 let compiled = compile_text_query(query);
11108 let frozen = view.freeze();
11109 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
11110 let registry = load_projection_registry(&tx)?;
11111 let declared = registry.get(name).ok_or_else(|| {
11112 SearchReaderError::InvalidFilter(format!("projected text field {name:?} is not declared"))
11113 })?;
11114 if !declared.wants_property_fts() {
11115 return Err(SearchReaderError::InvalidFilter(format!(
11116 "projected text field {name:?} is not a declared `searchable` projection with property FTS"
11117 )));
11118 }
11119 if let Some(filter) = filter {
11120 validate_filter_attributes_on_snapshot(&tx, filter)?;
11121 }
11122 let validity = frozen.validity_sql("n", 3);
11123 let sql = format!(
11124 "SELECT p.write_cursor, bm25(property_search_index), n.kind, n.body, n.logical_id, n.source_id
11125 FROM property_search_index p JOIN canonical_nodes n ON n.write_cursor = p.write_cursor
11126 WHERE p.attr_name = ?1 AND property_search_index MATCH ?2
11127 AND n.superseded_at IS NULL AND n.state = 'active'{validity}
11128 ORDER BY bm25(property_search_index) ASC, p.write_cursor ASC"
11129 );
11130 let mut params = vec![
11131 rusqlite::types::Value::Text(name.to_string()),
11132 rusqlite::types::Value::Text(compiled.match_expression),
11133 ];
11134 if let Some(now) = frozen.now_param() {
11135 params.push(rusqlite::types::Value::Integer(now));
11136 }
11137 let mut stmt = tx.prepare(&sql)?;
11138 let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| {
11139 Ok((
11140 row.get::<_, i64>(0)?,
11141 row.get::<_, f64>(1)?,
11142 row.get::<_, String>(2)?,
11143 row.get::<_, String>(3)?,
11144 row.get::<_, Option<String>>(4)?,
11145 row.get::<_, Option<String>>(5)?,
11146 ))
11147 })?;
11148 let mut results = Vec::new();
11149 for row in rows {
11150 let (cursor, bm25, kind, body, logical_id, source_id) = row?;
11151 if !text_hit_passes_filter(&tx, cursor as u64, &kind, filter)? {
11152 continue;
11153 }
11154 results.push(SearchHit {
11155 id: derive_stable_id(logical_id.as_deref(), &body),
11156 write_cursor: cursor as u64,
11157 kind,
11158 body,
11159 score: -bm25,
11160 branch: SoftFallbackBranch::Text,
11161 source_id,
11162 ce_score: None,
11163 });
11164 if results.len() >= limit {
11165 break;
11166 }
11167 }
11168 let projection_cursor = load_projection_cursor(&tx)?;
11169 Ok(SearchResult { projection_cursor, soft_fallback: None, results, explanation: None })
11170}
11171
11172// The 8th parameter (`vector_stage_only`) is the additive GA-2 / ◆ B-1
11173// measurement seam; the reader-worker call site threads each field through
11174// explicitly (mirroring the existing `recency_enabled` plumbing), so a wrapper
11175// struct would only obscure that 1:1 mapping for a test-only flag.
11176#[allow(clippy::too_many_arguments)]
11177fn read_search_in_tx(
11178 reader: &mut Connection,
11179 compiled: &fathomdb_query::CompiledQuery,
11180 query_vector: Option<&str>,
11181 query_vector_bin: Option<&str>,
11182 final_limit: usize,
11183 filter: Option<&SearchFilter>,
11184 recency_enabled: bool,
11185 importance_enabled: bool,
11186 vector_stage_only: bool,
11187 raw_query: &str,
11188 rerank_depth: usize,
11189 use_graph_arm: bool,
11190 alpha: f64,
11191 pool_n: usize,
11192 explain: bool,
11193 view: ReadView,
11194) -> ReaderResponse {
11195 // 0.8.20 Slice 15b fix-2 (R-20-NV) — the `:now` instant is read HERE, in
11196 // Rust, ONCE per query, and bound positionally into every node-hydration
11197 // SELECT. Never `datetime('now')` / `strftime('%s','now')`: an inline clock
11198 // would make the query non-deterministic, untestable, and re-evaluated per
11199 // row. `None` ⇒ the view relaxes validity ⇒ no conjunct is emitted and
11200 // nothing is bound (`validity_sql` returns the empty string).
11201 //
11202 // fix-3 (F2): FREEZE the view here, at the single point every arm flows
11203 // through. `freeze()` is the only place on this path that reads the clock;
11204 // downstream arms hold a `FrozenView` and have no way to resolve a second,
11205 // different instant. Previously the graph arm re-derived it from the raw
11206 // `ReadView`, so a boundary-straddling query could have its arms disagree.
11207 let view = view.freeze();
11208 let now_param = view.now_param();
11209 // fix-3 (codex §9 [P2], TOCTOU) — test-only rendezvous: parks the worker here,
11210 // BEFORE the deferred snapshot is pinned, so a test can commit a concurrent
11211 // `configure_projections` DROP in the exact race window. Disarmed (no-op) in
11212 // production and on every non-race test.
11213 reader_search_hook::fire();
11214 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
11215 let cursor = load_projection_cursor(&tx)?;
11216 // fix-3 (codex §9 [P2], TOCTOU) — validate every filter attribute name on THIS
11217 // reader transaction's snapshot, before `build_vector_phase1_sql` emits
11218 // `AND attr_<hex>=?` and before the FTS arm probes `canonical_attributes`. The
11219 // registry read and the vec0 query now share ONE snapshot (see
11220 // `validate_filter_attributes_on_snapshot`), so a `configure_projections` DROP
11221 // racing this search yields a consistent typed `InvalidFilter` — never the
11222 // opaque `no such column` `Storage` error fix-2's writer-connection check could
11223 // still leak. Skipped when the filter carries no attribute terms (common path).
11224 if let Some(filter) = filter {
11225 validate_filter_attributes_on_snapshot(&tx, filter)?;
11226 }
11227 let vector_results = if let Some(query_vector) = query_vector {
11228 let mut rowids = Vec::new();
11229 let bin_vector = query_vector_bin.unwrap_or(query_vector);
11230 {
11231 // Phase 1: bit-KNN over `embedding_bin` to a top-K candidate
11232 // set; Phase 2: f32 rerank on the candidate set via
11233 // vec_distance_l2 against the retained `embedding` column.
11234 // EU-5a2: ?1 is the (possibly centered) sign-quant input,
11235 // ?2 is the un-centered f32 for vec_distance_l2 — both sides
11236 // of the f32 cosine use un-centered vectors.
11237 // PR-2bc S1 fix-1: the phase-2 rerank LIMIT is `SEARCH_RERANK_LIMIT`
11238 // (10) in production. `final_limit` is supplied by the caller from
11239 // `ProjectionRuntimeShared::search_limit_override` (default 10,
11240 // clamped >=10) — there is NO env-var read on this hot path. A test
11241 // seam (`set_search_limit_for_test`) may RAISE it so the recall
11242 // harness can pull top-(10+slack) and exclude the self-retrieving
11243 // query-source doc BEFORE truncating to 10 (standard ANN-recall
11244 // practice); it can never shrink below production semantics.
11245 // G10: the metadata filter is appended to this single phase-1
11246 // statement (`AND col=?n` from ?3); `filter=None` keeps the SQL
11247 // byte-identical to 0.7.2. `?1`/`?2` are the sign-quant + f32 query
11248 // vectors; filter values bind at ?3.. in `vector_filter_clause`
11249 // order.
11250 // fix-3 (F1, codex §9 [P2]) — OVERFETCH the phase-2 rerank so the
11251 // validity/existence filter applied at hydration cannot starve the
11252 // result set.
11253 //
11254 // The defect: hydration drops rows that are expired, superseded or
11255 // inactive, but it ran on candidates ALREADY truncated to
11256 // `final_limit`. If the nearest `final_limit` neighbours were all
11257 // out-of-window they consumed every slot and were then dropped, so
11258 // valid rows just below the cutoff were never considered — a
11259 // default search silently returned too few hits, or none.
11260 //
11261 // Why overfetch rather than filtering in SQL: the natural fix is to
11262 // join `canonical_nodes` into the candidate query, but (i) phase 1
11263 // is a `vec0` KNN and ADR-0.8.11 D3 forbids demoting it with
11264 // non-metadata predicates, and (ii) there is NO index on
11265 // `canonical_nodes(write_cursor)`, so an `EXISTS` per candidate
11266 // would be a full scan × the whole pool on EVERY query — a
11267 // guaranteed cost to fix a degenerate case.
11268 //
11269 // Overfetching is free by comparison: phase 2 already computes
11270 // `vec_distance_l2` for all `TOP_K_BIT_CANDIDATES` in order to sort
11271 // them, so raising the LIMIT only returns more of a result set that
11272 // was already materialized. No extra vec0 work, no schema change,
11273 // no new index, no second query. The hydration loop below then
11274 // stops at `final_limit` SURVIVING hits, so the common case does
11275 // exactly as many hydration probes as before.
11276 //
11277 // `max` (not a bare constant) because `set_search_limit_for_test`
11278 // may raise `final_limit` above the pool for the recall harness;
11279 // this must never request FEWER candidates than the caller wants.
11280 let candidate_limit = final_limit.max(TOP_K_BIT_CANDIDATES);
11281 let sql = build_vector_phase1_sql(filter, candidate_limit);
11282 let mut params: Vec<rusqlite::types::Value> = vec![
11283 rusqlite::types::Value::Text(bin_vector.to_string()),
11284 rusqlite::types::Value::Text(query_vector.to_string()),
11285 ];
11286 params.extend(vector_filter_values(filter));
11287 let mut statement = tx.prepare(&sql)?;
11288 let rows = statement.query_map(rusqlite::params_from_iter(params.iter()), |row| {
11289 Ok((row.get::<_, i64>(0)?, row.get::<_, f64>(1)?))
11290 })?;
11291 for row in rows.flatten() {
11292 rowids.push(row);
11293 }
11294 }
11295 // G1: carry the canonical row's `write_cursor` (interim id), `kind`,
11296 // `body`, and the `vec_distance_l2` rerank score per hit. The
11297 // `_fathomdb_vector_rows.rowid` equals the canonical `write_cursor`,
11298 // so the candidate rowid IS the hit id.
11299 //
11300 // G11 (Slice 15) fix: edge bodies are projected into vector_default under
11301 // kind = "edge_fact"; their write_cursor is in canonical_edges, not
11302 // canonical_nodes. Try canonical_nodes first; fall back to canonical_edges
11303 // for edge-fact hits so they are not silently dropped.
11304 let mut results = Vec::new();
11305 // Cause-A: the two node/edge SELECTs additively fetch `logical_id` so the
11306 // hit can carry a stable cross-session id (derive_stable_id). Read-only
11307 // additive column — ordering/scores are untouched.
11308 // fix-1 (codex §9): co-locate BOTH existence guards. Node supersession
11309 // is tombstone-then-insert (`commit_batch`) — the prior `canonical_nodes`
11310 // row is kept (same `write_cursor`, `state = 'active'`, `superseded_at`
11311 // set) and, unlike the edge path (fix-30), its stale `vector_default` row
11312 // is NOT pruned, so the phase-1 bit-KNN can still surface the OLD cursor.
11313 // Without `superseded_at IS NULL` here that superseded version would
11314 // hydrate and leak stale content through vector search. This matches the
11315 // edge branch below and every other retrieval site (design §2: enforce
11316 // the exclusion at EVERY retrieval site). It only drops already-superseded
11317 // rows → a no-op on the all-active / non-superseded corpus.
11318 // TC-31 (0.8.20 Slice 10a): both hydration SELECTs additively fetch the
11319 // canonical row's OWN `source_id` so a vector hit carries the provenance
11320 // `erase_source` consumes. These statements already read the canonical
11321 // row by `write_cursor`, so this is one extra COLUMN on an existing
11322 // lookup — NOT an extra query. (A per-hit `WHERE write_cursor = ?`
11323 // probe would be a full scan: there is no index on
11324 // `canonical_nodes(write_cursor)`. This site already pays that cost by
11325 // construction; TC-31 must not add a second one.) Read-only additive
11326 // column — row-set, ordering and scores are untouched.
11327 // fix-2 (codex §9 [P2]): the validity conjunct comes from
11328 // `ReadView::validity_sql` — the SAME generator the five read verbs use.
11329 // It is NOT hand-rolled here: Slice 10's whole design is that the
11330 // predicate exists in exactly ONE place, so no retrieval site can drift
11331 // from another. `?1` is the candidate rowid, so `:now` binds at `?2`.
11332 // On a corpus that never authored a window every row is NULL/NULL and
11333 // the conjunct matches everything ⇒ default behaviour is unchanged.
11334 let node_validity = view.validity_sql("canonical_nodes", 2);
11335 let mut node_stmt = tx.prepare(&format!(
11336 "SELECT kind, body, logical_id, source_id FROM canonical_nodes \
11337 WHERE write_cursor = ?1 AND superseded_at IS NULL AND state = 'active'\
11338 {node_validity} LIMIT 1"
11339 ))?;
11340 // fix-2 (codex §9 [P2]): an edge body projected into `vector_default`
11341 // (kind = "edge_fact") is hydrated HERE by write_cursor. Gating on
11342 // `superseded_at` alone let an EXPIRED edge (`t_invalid <= :now`) surface
11343 // its body through the VECTOR arm — the same "validity enforced on
11344 // traversal, not on search" gap Slice 15b closed for nodes, now on the
11345 // edge-vector read path. Apply the shared `edge_validity_sql` predicate
11346 // (the ONE generator every edge read site uses) so no arm can drift.
11347 // `?1` is the rowid, so the edge `:now` binds at `?2`; the instant is the
11348 // frozen `view.edge_now()` — a bound value, never `datetime('now')`
11349 // (the :9161 no-inline-clock rule). edge_now is ALWAYS present, so unlike
11350 // node validity this conjunct is unconditional (an edge invalidated in the
11351 // past stays excluded even when node existence is relaxed).
11352 let edge_validity = edge_validity_sql("canonical_edges", 2);
11353 let mut edge_stmt = tx.prepare(&format!(
11354 "SELECT body, logical_id, source_id FROM canonical_edges \
11355 WHERE write_cursor = ?1 AND superseded_at IS NULL AND body IS NOT NULL\
11356 {edge_validity} LIMIT 1"
11357 ))?;
11358 // The bound parameter list for the node lookup: the candidate rowid,
11359 // plus `:now` when (and only when) the view emitted a validity conjunct.
11360 // One instant for the whole query — resolved once, above, not per row.
11361 let node_params = |rowid: i64| -> Vec<rusqlite::types::Value> {
11362 let mut p = vec![rusqlite::types::Value::Integer(rowid)];
11363 if let Some(now) = now_param {
11364 p.push(rusqlite::types::Value::Integer(now));
11365 }
11366 p
11367 };
11368 for (rowid, score) in rowids {
11369 // fix-3 (F1): the candidate list is now the OVERFETCHED pool in
11370 // exact-L2 order, so the caller's cutoff is applied HERE — after
11371 // the validity/existence filter, not before it. Bounded worst case:
11372 // at most `TOP_K_BIT_CANDIDATES` hydration probes when nearly every
11373 // candidate is filtered out; exactly `final_limit` (i.e. unchanged)
11374 // when nothing is. Ordering is unchanged — the surviving rows are
11375 // still emitted nearest-first — so on a corpus with no windows this
11376 // loop yields byte-identical results to the pre-fix code.
11377 if results.len() >= final_limit {
11378 break;
11379 }
11380 if let Ok((kind, body, logical_id, source_id)) =
11381 node_stmt.query_row(rusqlite::params_from_iter(node_params(rowid)), |row| {
11382 Ok((
11383 row.get::<_, String>(0)?,
11384 row.get::<_, String>(1)?,
11385 row.get::<_, Option<String>>(2)?,
11386 row.get::<_, Option<String>>(3)?,
11387 ))
11388 })
11389 {
11390 let id = derive_stable_id(logical_id.as_deref(), &body);
11391 results.push(SearchHit {
11392 id,
11393 write_cursor: rowid as u64,
11394 kind,
11395 body,
11396 score,
11397 branch: SoftFallbackBranch::Vector,
11398 // TC-31: the NODE's own provenance (a node hit is erased by
11399 // the document it was written from).
11400 source_id,
11401 ce_score: None,
11402 });
11403 } else if let Ok((body, logical_id, source_id)) =
11404 edge_stmt.query_row(rusqlite::params![rowid, view.edge_now()], |row| {
11405 Ok((
11406 row.get::<_, String>(0)?,
11407 row.get::<_, Option<String>>(1)?,
11408 row.get::<_, Option<String>>(2)?,
11409 ))
11410 })
11411 {
11412 let id = derive_stable_id(logical_id.as_deref(), &body);
11413 results.push(SearchHit {
11414 id,
11415 write_cursor: rowid as u64,
11416 kind: "edge_fact".to_string(),
11417 body,
11418 score,
11419 branch: SoftFallbackBranch::TextEdge,
11420 // TC-31: the EDGE's own provenance — consistent with the
11421 // graph arm's existing edge-source semantics.
11422 source_id,
11423 ce_score: None,
11424 });
11425 }
11426 }
11427 results
11428 } else {
11429 Vec::new()
11430 };
11431 let vector_rows_visible = !vector_results.is_empty();
11432 let soft_fallback = if query_vector.is_some() && !vector_rows_visible {
11433 tx.query_row(
11434 "SELECT 1
11435 FROM search_index
11436 JOIN _fathomdb_vector_kinds ON _fathomdb_vector_kinds.kind = search_index.kind
11437 LEFT JOIN _fathomdb_projection_terminal
11438 ON _fathomdb_projection_terminal.write_cursor = search_index.write_cursor
11439 WHERE search_index MATCH ?1
11440 AND _fathomdb_projection_terminal.write_cursor IS NULL
11441 LIMIT 1",
11442 [compiled.match_expression.as_str()],
11443 |_row| Ok(SoftFallback { branch: SoftFallbackBranch::Vector }),
11444 )
11445 .ok()
11446 } else {
11447 None
11448 };
11449 // Collect the text branch (ranked by `write_cursor`, as 0.7.2), then
11450 // post-filter it against the same metadata the vector branch was pruned by
11451 // in SQL (the vector branch is filtered in phase 1; the text branch has no
11452 // metadata columns of its own).
11453 let text_candidates: Vec<SearchHit> = {
11454 // 0.7.0 perf-experiments: optional FTS5 LIMIT cap. Gated on
11455 // FATHOMDB_PERF_EXPERIMENTS=1; opt-in via
11456 // FATHOMDB_PERF_SEARCH_LIMIT=<k>. No-op by default — preserves
11457 // 0.6.x unbounded result-set semantics. Removed (or made the
11458 // hardcoded default) at Wave 5 landing per
11459 // dev/plans/0.7.0-perf-experiments.md.
11460 let perf_limit: Option<usize> = if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_some() {
11461 std::env::var("FATHOMDB_PERF_SEARCH_LIMIT").ok().and_then(|s| s.parse().ok())
11462 } else {
11463 None
11464 };
11465 // G1: SELECT body + kind + write_cursor (interim id) and the
11466 // `bm25()` text-relevance score. IR-C (2026-06-10,
11467 // `performance-output-and-compare.md`): the per-branch rank RRF fuses on
11468 // must be **`bm25()` relevance**, not `write_cursor` (insertion order) —
11469 // the prior `ORDER BY write_cursor` meant the lexical arm never ranked by
11470 // relevance, the single biggest fusion bug. `bm25()` is more-negative ⇒
11471 // better, so ascending puts best matches first; `write_cursor` is the
11472 // deterministic tiebreak. The filter is applied as a Rust post-filter so
11473 // the unfiltered path is untouched.
11474 let limit_clause = perf_limit.map(|k| format!(" LIMIT {k}")).unwrap_or_default();
11475 // Cause-A: PREFER a logical_id-bearing query — LEFT JOIN canonical_nodes so
11476 // node hits carry the `l:`-tagged stable id. The join is 1:1 on
11477 // `write_cursor` (search_index holds node bodies only; edge bodies live in
11478 // search_index_edges), so the row-set and the `bm25(search_index),
11479 // write_cursor` ordering are byte-unchanged — only `cn.logical_id` is added.
11480 // FALL BACK to the original (logical_id-free) query on pre-step-12 schemas
11481 // (v10) whose `canonical_nodes` lacks `logical_id`: those hits key by the
11482 // `h:` content-hash. This keeps old-schema search byte-identical to the
11483 // pre-Cause-A behaviour (the prepare of the plain SQL is the exact prior
11484 // statement). Columns are qualified because both tables expose `write_cursor`.
11485 // CORRECTNESS (0.8.11.2 pico): `AND cn.superseded_at IS NULL` drops
11486 // superseded node versions. Node supersession is tombstone-then-insert
11487 // (`commit_batch`): the prior `canonical_nodes` row is UPDATEd to set
11488 // `superseded_at` (row kept, same write_cursor) and a NEW `search_index`
11489 // row is inserted for the new cursor — the OLD `search_index` row is
11490 // never deleted, so without this filter both versions stay live in FTS
11491 // and the stale one is returned. The other arms already filter this way
11492 // (edge branch, graph-arm node seed, point-recall `read_get_by_id`);
11493 // only this default node-text branch was missing it. The `LEFT JOIN` is
11494 // KEPT (not switched to inner): an active row joins to its `cn` with
11495 // `superseded_at = NULL` (kept); a superseded row joins to its tombstoned
11496 // `cn` with `superseded_at` NOT NULL (dropped); a legacy/orphan
11497 // `search_index` row with no `cn` gets `superseded_at = NULL` via the
11498 // LEFT JOIN (KEPT — preserves prior behaviour for ownerless rows).
11499 // TC-31 (0.8.20 Slice 10a): `cn.source_id` is selected off the SAME
11500 // already-present 1:1 LEFT JOIN that supplies `cn.logical_id` — one extra
11501 // column, no extra query, no row-set or ordering change.
11502 // fix-2 (codex §9 [P2]): the node-body FTS branch takes the SAME
11503 // validity conjunct, generated by `ReadView::validity_sql` rather than
11504 // hand-rolled — the predicate lives in exactly one place (Slice 10).
11505 // `?1` is the MATCH expression, so `:now` binds at `?2`.
11506 //
11507 // The generated conjunct is NULL-PERMISSIVE by construction
11508 // (`valid_from IS NULL OR ...`), which is exactly what this LEFT JOIN
11509 // needs: an ownerless `search_index` row with no `cn` reads NULL on both
11510 // columns and is KEPT, preserving the deliberate keep-ownerless
11511 // behaviour the `superseded_at` / `state` conjuncts above encode with
11512 // their explicit `OR ... IS NULL`. No extra `OR IS NULL` is needed here,
11513 // and none may be added: that would be a second, drifting copy of the
11514 // predicate.
11515 //
11516 // NO-REGRESSION: on a corpus that never authored a window every
11517 // `cn.valid_from` / `cn.valid_until` is NULL (step 22 back-filled NULL
11518 // with no DEFAULT), so both disjuncts are TRUE for every row and the
11519 // row-set, the `bm25(search_index), write_cursor` ordering and the
11520 // scores are all byte-unchanged.
11521 let text_validity = view.validity_sql("cn", 2);
11522 let join_sql = format!(
11523 "SELECT search_index.body, search_index.kind, search_index.write_cursor, \
11524 bm25(search_index), cn.logical_id, cn.source_id FROM search_index \
11525 LEFT JOIN canonical_nodes cn ON cn.write_cursor = search_index.write_cursor \
11526 WHERE search_index MATCH ?1 \
11527 AND cn.superseded_at IS NULL \
11528 AND (cn.state = 'active' OR cn.state IS NULL)\
11529 {text_validity} \
11530 ORDER BY bm25(search_index), search_index.write_cursor{limit_clause}"
11531 );
11532 // `:now` rides at ?2 only when the view emitted a conjunct; the relaxed
11533 // view produces the byte-identical single-parameter statement.
11534 let mut text_params: Vec<rusqlite::types::Value> =
11535 vec![rusqlite::types::Value::Text(compiled.match_expression.clone())];
11536 if let Some(now) = now_param {
11537 text_params.push(rusqlite::types::Value::Integer(now));
11538 }
11539 if let Ok(mut statement) = tx.prepare(&join_sql) {
11540 let rows =
11541 statement.query_map(rusqlite::params_from_iter(text_params.iter()), |row| {
11542 let body = row.get::<_, String>(0)?;
11543 let logical_id = row.get::<_, Option<String>>(4)?;
11544 Ok(SearchHit {
11545 id: derive_stable_id(logical_id.as_deref(), &body),
11546 body,
11547 kind: row.get::<_, String>(1)?,
11548 write_cursor: row.get::<_, i64>(2)? as u64,
11549 score: row.get::<_, f64>(3)?,
11550 branch: SoftFallbackBranch::Text,
11551 // TC-31: the NODE's own provenance. NULL for a legacy /
11552 // TC-11-spared governed row, and NULL for an ownerless
11553 // `search_index` row the LEFT JOIN keeps with no `cn`.
11554 source_id: row.get::<_, Option<String>>(5)?,
11555 ce_score: None,
11556 })
11557 })?;
11558 rows.flatten().collect()
11559 } else {
11560 // No `superseded_at IS NULL` filter here (and none is possible): this
11561 // fallback fires only on pre-step-12 schemas whose `canonical_nodes`
11562 // lacks `logical_id` — and step-12 adds `logical_id` and
11563 // `superseded_at` in the SAME migration, so this schema has neither
11564 // column. Supersession (`commit_batch`) is a no-op without
11565 // `logical_id`, so no superseded node rows can exist on this path.
11566 //
11567 // TC-31 (0.8.20 Slice 10a): `source_id` arrived in step 8, `logical_id`
11568 // in step 12, so a schema that lands HERE (no `logical_id`) may still
11569 // HAVE `source_id` — steps 8..11. Try a provenance-bearing variant
11570 // first, adding only `cn.source_id` over the SAME 1:1 LEFT JOIN shape
11571 // used above (row-set and ordering unchanged; a missing `cn` row keeps
11572 // NULL as before). Fall back to the historical, byte-identical
11573 // provenance-free statement on a pre-step-8 schema, where the column
11574 // genuinely does not exist and `None` is the only truthful answer.
11575 let source_sql = format!(
11576 "SELECT search_index.body, search_index.kind, search_index.write_cursor, \
11577 bm25(search_index), cn.source_id FROM search_index \
11578 LEFT JOIN canonical_nodes cn ON cn.write_cursor = search_index.write_cursor \
11579 WHERE search_index MATCH ?1 \
11580 ORDER BY bm25(search_index), search_index.write_cursor{limit_clause}"
11581 );
11582 if let Ok(mut statement) = tx.prepare(&source_sql) {
11583 let rows = statement.query_map([compiled.match_expression.as_str()], |row| {
11584 let body = row.get::<_, String>(0)?;
11585 Ok(SearchHit {
11586 // No logical_id column on this schema → content-hash id.
11587 id: derive_stable_id(None, &body),
11588 body,
11589 kind: row.get::<_, String>(1)?,
11590 write_cursor: row.get::<_, i64>(2)? as u64,
11591 score: row.get::<_, f64>(3)?,
11592 branch: SoftFallbackBranch::Text,
11593 source_id: row.get::<_, Option<String>>(4)?,
11594 ce_score: None,
11595 })
11596 })?;
11597 rows.flatten().collect()
11598 } else {
11599 // Pre-step-8: no `source_id` column anywhere. Byte-identical to
11600 // the historical statement.
11601 let plain_sql = format!(
11602 "SELECT body, kind, write_cursor, bm25(search_index) FROM search_index \
11603 WHERE search_index MATCH ?1 \
11604 ORDER BY bm25(search_index), write_cursor{limit_clause}"
11605 );
11606 let mut statement = tx.prepare(&plain_sql)?;
11607 let rows = statement.query_map([compiled.match_expression.as_str()], |row| {
11608 let body = row.get::<_, String>(0)?;
11609 Ok(SearchHit {
11610 // No logical_id column on this schema → content-hash id.
11611 id: derive_stable_id(None, &body),
11612 body,
11613 kind: row.get::<_, String>(1)?,
11614 write_cursor: row.get::<_, i64>(2)? as u64,
11615 score: row.get::<_, f64>(3)?,
11616 branch: SoftFallbackBranch::Text,
11617 source_id: None,
11618 ce_score: None,
11619 })
11620 })?;
11621 rows.flatten().collect()
11622 }
11623 }
11624 };
11625 let mut text_results: Vec<SearchHit> = Vec::with_capacity(text_candidates.len());
11626 for hit in text_candidates {
11627 if text_hit_passes_filter(&tx, hit.write_cursor, &hit.kind, filter)? {
11628 text_results.push(hit);
11629 }
11630 }
11631
11632 // G11 (Slice 15) — edge-body FTS branch from `search_index_edges`.
11633 // Appended to text_results; tagged with SoftFallbackBranch::TextEdge so
11634 // callers can distinguish edge hits from node hits.
11635 //
11636 // fix-1 [P2]: JOIN canonical_edges to exclude superseded edge rows
11637 // (invalidate-not-accumulate can leave a superseded body in the FTS index).
11638 // fix-2 [P2]: use edge_fts_hit_passes_filter (NOT text_hit_passes_filter).
11639 // Edge hits always have source_type="edge_fact"; text_hit_passes_filter
11640 // calls resolve_source_type(relation_kind) which returns Err for unknown
11641 // relation kinds, silently rejecting every edge hit when a source_type
11642 // filter is set — the exact inverse of correct behaviour.
11643 // fix-3 [P2]: edge_fts_hit_passes_filter now queries vector_default for
11644 // created_after/status (mirroring text_hit_passes_filter). Collect edge
11645 // candidates into a Vec first (drops stmt borrow on tx) so we can pass
11646 // &tx to edge_fts_hit_passes_filter without a borrow conflict.
11647 let edge_candidates: Vec<SearchHit> = {
11648 // Cause-A: the JOIN to canonical_edges already exists; additively select
11649 // `ce.logical_id` (edges always carry one) for the stable hit-id. No
11650 // ordering/row-set change.
11651 // TC-31 (0.8.20 Slice 10a): `ce.source_id` rides the SAME existing inner
11652 // JOIN as `ce.logical_id` — one extra column, no extra query, no
11653 // row-set/ordering change. An edge hit carries the EDGE's own provenance,
11654 // matching the graph arm's edge-source semantics.
11655 // fix-2 (codex §9 [P2]): the JOIN already dropped superseded edge rows,
11656 // but a body-bearing edge written with `t_invalid <= :now` (expired /
11657 // invalidated) still MATCHed and surfaced its body through ordinary
11658 // search — edge temporal validity was enforced on the graph-traversal and
11659 // projection paths but NOT on this FTS read path. Apply the shared
11660 // `edge_validity_sql` conjunct (the ONE generator every edge read site
11661 // uses, so no path can drift). `?1` is the MATCH expression, so the edge
11662 // `:now` binds at `?2`; the instant is the frozen `view.edge_now()` — a
11663 // bound value, never `datetime('now')` (the :9161 no-inline-clock rule),
11664 // and always present (edge invalidation is not relaxed by node existence
11665 // relaxation).
11666 let edge_validity = edge_validity_sql("ce", 2);
11667 let edge_sql = format!(
11668 "SELECT sei.body, sei.kind, sei.write_cursor, bm25(search_index_edges), \
11669 ce.logical_id, ce.source_id \
11670 FROM search_index_edges sei \
11671 JOIN canonical_edges ce ON ce.write_cursor = sei.write_cursor \
11672 WHERE search_index_edges MATCH ?1 \
11673 AND ce.superseded_at IS NULL{edge_validity} \
11674 ORDER BY bm25(search_index_edges), sei.write_cursor"
11675 );
11676 // search_index_edges may not exist on very old DBs not yet at step-14;
11677 // ignore the error gracefully (returns empty slice).
11678 if let Ok(mut stmt) = tx.prepare(&edge_sql) {
11679 if let Ok(rows) = stmt.query_map(
11680 rusqlite::params![compiled.match_expression.as_str(), view.edge_now()],
11681 |row| {
11682 let body = row.get::<_, String>(0)?;
11683 let logical_id = row.get::<_, Option<String>>(4)?;
11684 Ok(SearchHit {
11685 id: derive_stable_id(logical_id.as_deref(), &body),
11686 body,
11687 kind: row.get::<_, String>(1)?,
11688 write_cursor: row.get::<_, i64>(2)? as u64,
11689 score: row.get::<_, f64>(3)?,
11690 branch: SoftFallbackBranch::TextEdge,
11691 // TC-31: the EDGE's own provenance.
11692 source_id: row.get::<_, Option<String>>(5)?,
11693 ce_score: None,
11694 })
11695 },
11696 ) {
11697 rows.flatten().collect()
11698 } else {
11699 Vec::new()
11700 }
11701 } else {
11702 Vec::new()
11703 }
11704 };
11705 // Attribute predicates intentionally apply only to node projections. Count
11706 // edge-FTS candidates that would otherwise pass when the caller requested
11707 // the opt-in explanation, without adding work to the default search path.
11708 let mut dropped_edge_hits = 0_u32;
11709 for row in edge_candidates {
11710 if edge_fts_hit_passes_filter(&tx, row.write_cursor, &row.kind, filter)? {
11711 text_results.push(row);
11712 } else if explain
11713 && filter.is_some_and(|active_filter| !active_filter.attributes.is_empty())
11714 && edge_fts_hit_passes_non_attribute_filter(&tx, row.write_cursor, &row.kind, filter)?
11715 {
11716 dropped_edge_hits = dropped_edge_hits.saturating_add(1);
11717 }
11718 }
11719 tx.commit()?;
11720
11721 // GA-2 / Slice-40 (◆ B-1) measurement seam: when `vector_stage_only` is set
11722 // (only ever by the eu7 recall harness via `set_vector_stage_only_for_test`,
11723 // off for every production caller), return the pre-fusion VECTOR-branch
11724 // ranking (bit-KNN K=192 + f32 rerank) verbatim, skipping `fuse_rrf` /
11725 // recency / `rerank_fused`. This exposes the ANN-quantization FIDELITY
11726 // signal — vector top-N vs the exact-f32 VECTOR top-10 ground truth — that
11727 // the AC-075 0.90 floor is defined to measure. It is NOT a `fusion_mode`
11728 // knob: the production branch below is byte-unchanged and RRF stays
11729 // unconditional.
11730 // G0 Phase-2 (BLOCK-1) side-channel meter — default (all-zero, rate 0.0) on
11731 // the non-graph-arm paths; populated by the BFS seed phase when graph-arm runs.
11732 let mut graph_stats = GraphFrontierStats::default();
11733
11734 // 0.8.8 EXP-OBS (Slice 5) — capture per-arm rank maps + counts BEFORE the arms
11735 // are consumed by fusion. All reads; only when `explain` (else zero work).
11736 // `body_rank_map` keeps the FIRST occurrence (== the rank `fuse_three_arms`
11737 // uses, which dedups keeping the first). `*_fused_scores` is captured from the
11738 // post-recency / pre-CE intermediate so `fused_score` is faithful to what
11739 // `ce_rerank` normalizes.
11740 let body_rank_map = |hits: &[SearchHit]| -> HashMap<String, u32> {
11741 let mut m: HashMap<String, u32> = HashMap::new();
11742 for (i, h) in hits.iter().enumerate() {
11743 m.entry(h.body.clone()).or_insert(i as u32);
11744 }
11745 m
11746 };
11747 let body_score_map = |hits: &[SearchHit]| -> HashMap<String, f64> {
11748 hits.iter().map(|h| (h.body.clone(), h.score)).collect()
11749 };
11750
11751 let (exp_vector_ranks, exp_text_ranks, exp_vector_n, exp_text_n) = if explain {
11752 (
11753 Some(body_rank_map(&vector_results)),
11754 Some(body_rank_map(&text_results)),
11755 vector_results.len() as u32,
11756 text_results.len() as u32,
11757 )
11758 } else {
11759 (None, None, 0, 0)
11760 };
11761 let mut exp_graph_ranks: Option<HashMap<String, u32>> = None;
11762 let mut exp_fused_scores: Option<HashMap<String, f64>> = None;
11763 let mut exp_graph_n: u32 = 0;
11764 // F9 (0.8.16 Slice 5) — per-hit importance/confidence contribution maps
11765 // (keyed by hit id == write_cursor), captured for the explain sidecar.
11766 let mut exp_importance: Option<HashMap<u64, f64>> = None;
11767 let mut exp_confidence: Option<HashMap<u64, f64>> = None;
11768
11769 let results = if vector_stage_only {
11770 vector_results
11771 } else if use_graph_arm {
11772 // R3 (Slice 30) — graph arm: BFS over temporal fact-edges seeded from
11773 // the top-10 two-arm fused candidates, depth ≤ 3, cap 50.
11774 // Temporal filter: superseded_at IS NULL AND (t_invalid IS NULL OR t_invalid > now).
11775 // Synthesized-node penalty: kind = 'unknown' → score *= 0.3.
11776 //
11777 // Approach: compute the two-arm fused result first (for BFS seeding),
11778 // then fuse three arms: the two-arm result (as "vector" arm), an empty
11779 // text arm, and the graph candidates. The two-arm result preserves all
11780 // existing ranking semantics; the graph arm contributes new candidates.
11781 let two_arm_fused = fuse_rrf(vector_results, text_results);
11782 // C1: seed the graph arm from the query's FTS match expression (entities /
11783 // edge-facts), not the doc-node fused hits. `fused_hits` is still passed for
11784 // the seed-body exclusion set.
11785 let (graph_candidates, stats, graph_edge_confidence) = bfs_graph_arm_candidates(
11786 reader,
11787 &two_arm_fused,
11788 compiled.match_expression.as_str(),
11789 3,
11790 50,
11791 view,
11792 )?;
11793 graph_stats = stats;
11794 if explain {
11795 exp_graph_ranks = Some(body_rank_map(&graph_candidates));
11796 exp_graph_n = graph_candidates.len() as u32;
11797 }
11798 // Named intermediate (byte-identical to the prior nested call) so explain
11799 // can read the pre-CE fused scores without perturbing the ranking.
11800 let fused = apply_recency_reweight(
11801 fuse_three_arms(two_arm_fused, vec![], graph_candidates),
11802 recency_enabled,
11803 );
11804 // F9 — importance (node) / confidence (edge) reweight, OFF by default.
11805 // Order: AFTER recency (consistent placement), BEFORE the CE rerank seam.
11806 let (imp_map, mut conf_map) = if importance_enabled || explain {
11807 build_importance_confidence_maps(reader, &fused).unwrap_or_default()
11808 } else {
11809 (HashMap::new(), HashMap::new())
11810 };
11811 // F9 FIX-1: `build_importance_confidence_maps` keys edge confidence on the
11812 // EDGE `write_cursor`, which never matches a graph-arm NODE hit's cursor —
11813 // so it alone leaves graph-arm hits with no edge confidence. Merge the
11814 // BFS-collected per-node traversing-edge confidence (node cursor ⇒ conf).
11815 // Node/edge cursors are globally unique, so there is never a key collision
11816 // with the edge-fact confidence above; `or_insert` documents that intent.
11817 if importance_enabled || explain {
11818 for (cursor, conf) in &graph_edge_confidence {
11819 conf_map.entry(*cursor).or_insert(*conf);
11820 }
11821 }
11822 let fused = apply_importance_reweight(fused, &imp_map, &conf_map, importance_enabled);
11823 if explain {
11824 exp_importance = Some(imp_map);
11825 exp_confidence = Some(conf_map);
11826 exp_fused_scores = Some(body_score_map(&fused));
11827 }
11828 rerank_fused(raw_query, fused, rerank_depth, alpha, pool_n)
11829 } else {
11830 // G9 + G12: RRF-fuse the two ranked branches (keyed on body, vector-first
11831 // tiebreak) into the unconditional new ranking, recency-reweight (gated,
11832 // off by default), then pass through the identity rerank seam. The
11833 // vector-empty `soft_fallback` signal was computed above, BEFORE this
11834 // branch-collapse.
11835 let fused = apply_recency_reweight(fuse_rrf(vector_results, text_results), recency_enabled);
11836 // F9 — importance (node) / confidence (edge) reweight, OFF by default.
11837 // Same placement as the graph-arm branch: after recency, before CE rerank.
11838 let (imp_map, conf_map) = if importance_enabled || explain {
11839 build_importance_confidence_maps(reader, &fused).unwrap_or_default()
11840 } else {
11841 (HashMap::new(), HashMap::new())
11842 };
11843 let fused = apply_importance_reweight(fused, &imp_map, &conf_map, importance_enabled);
11844 if explain {
11845 exp_importance = Some(imp_map);
11846 exp_confidence = Some(conf_map);
11847 exp_fused_scores = Some(body_score_map(&fused));
11848 }
11849 rerank_fused(raw_query, fused, rerank_depth, alpha, pool_n)
11850 };
11851
11852 // 0.8.8 EXP-OBS — assemble the sidecar `Explanation` from the captured maps +
11853 // the final `results`. `embedder_id` is left empty here (the worker has no
11854 // identity) and filled by `search_inner_with_stats`.
11855 let explanation = if explain {
11856 let fused_scores = exp_fused_scores.unwrap_or_default();
11857 let per_hit: Vec<PerHitExplain> = results
11858 .iter()
11859 .map(|h| PerHitExplain {
11860 // `PerHitExplain.id` carries the engine-internal positional
11861 // `write_cursor` (the pre-C-2 `SearchHit.id`), matching the
11862 // telemetry `result_ids` / importance-map key space; the typed
11863 // `SearchHit.id` is the separate caller-facing identity.
11864 id: h.write_cursor,
11865 arm: h.branch,
11866 vector_rank: exp_vector_ranks.as_ref().and_then(|m| m.get(&h.body).copied()),
11867 text_rank: exp_text_ranks.as_ref().and_then(|m| m.get(&h.body).copied()),
11868 graph_rank: exp_graph_ranks.as_ref().and_then(|m| m.get(&h.body).copied()),
11869 fused_score: fused_scores.get(&h.body).copied().unwrap_or(h.score),
11870 ce_score: h.ce_score,
11871 blended: h.score,
11872 importance: exp_importance.as_ref().and_then(|m| m.get(&h.write_cursor).copied()),
11873 confidence: exp_confidence.as_ref().and_then(|m| m.get(&h.write_cursor).copied()),
11874 })
11875 .collect();
11876 let ce_active = rerank_depth > 0 && per_hit.iter().any(|p| p.ce_score.is_some());
11877 Some(Explanation {
11878 trace: QueryTrace {
11879 query_chars: raw_query.chars().count() as u32,
11880 k: final_limit as u32,
11881 rerank_depth: rerank_depth as u32,
11882 pool_n: pool_n as u32,
11883 alpha,
11884 use_graph_arm,
11885 recency: recency_enabled,
11886 embedder_id: String::new(),
11887 ce_active,
11888 vector_hits: exp_vector_n,
11889 text_hits: exp_text_n,
11890 graph_hits: exp_graph_n,
11891 dropped_edge_hits,
11892 },
11893 per_hit,
11894 })
11895 } else {
11896 None
11897 };
11898
11899 Ok((cursor, soft_fallback, results, graph_stats, explanation))
11900}
11901
11902/// R3 (Slice 30) + C1 (0.8.1 graph-arm seeding) — graph-arm BFS candidate generation.
11903///
11904/// **C1 seeding (the BLOCK-1 fix):** the frontier is seeded from the graph's OWN
11905/// query-matched text surfaces — NOT from doc-node hits (doc nodes carry
11906/// `logical_id = NULL`, so the old doc-seeding produced an empty frontier). Two
11907/// seed sources are unioned on `match_expression` (the compiled FTS query):
11908/// A. **edge-fact FTS** (`search_index_edges`) — both endpoints (`from_id`,
11909/// `to_id`) of matched, temporally-live, non-fallback edges;
11910/// B. **entity-node FTS** (`search_index` ⋈ `canonical_nodes`) — matched nodes
11911/// with `logical_id IS NOT NULL` (excludes doc nodes — the bug surface).
11912/// Each distinct candidate `logical_id` is counted in `seeds_considered`; those
11913/// confirmed active in `canonical_nodes` are `seeds_resolved` and pushed onto the
11914/// frontier (dangling edge endpoints count considered-but-unresolved).
11915///
11916/// Phase 2 is unchanged: BFS over `canonical_edges` with the temporal filter,
11917/// carrying each traversed edge's `source_id` (G0 BLOCK-2) onto the emitted hit.
11918/// Collects reachable node bodies (up to `cap`) as [`SearchHit`]s tagged
11919/// `SoftFallbackBranch::GraphArm`. Score = `1.0 / (1.0 + hop_count)` with a
11920/// synthesized-node penalty (`kind = 'unknown'` → score *= 0.3). Bodies already
11921/// present in `fused_hits` are excluded (already covered by the two-arm result).
11922///
11923/// **F9 (0.8.16 Slice 5) confidence carry:** the third tuple element maps each
11924/// emitted graph-arm hit's `write_cursor` (its `SearchHit.id`, a NODE cursor) to
11925/// the `confidence` of the EDGE traversed to reach that node — the input the F9
11926/// reweight (`graph_rrf_score(edge) = confidence × 1/(K+bfs_rank)`) consumes.
11927/// `build_importance_confidence_maps` keys edge confidence on the EDGE
11928/// `write_cursor`, which never equals a reached node's cursor, so without this
11929/// carry edge confidence never reaches a graph-arm hit. **Determinism rule (matches
11930/// the BLOCK-2 provenance carry):** when several edges reach the same node, the
11931/// FIRST edge to claim the node in the `visited` dedup wins — i.e. the edge that
11932/// produced the node's winning `bfs_rank` (seeds are considered before Phase-2
11933/// neighbors; within a phase, `ORDER BY write_cursor` makes the earliest-written
11934/// edge win). A NULL edge confidence is simply not inserted ⇒ neutral (1.0).
11935fn bfs_graph_arm_candidates(
11936 reader: &mut Connection,
11937 fused_hits: &[SearchHit],
11938 match_expression: &str,
11939 max_depth: u32,
11940 cap: usize,
11941 view: FrozenView,
11942) -> rusqlite::Result<(Vec<SearchHit>, GraphFrontierStats, HashMap<u64, f64>)> {
11943 // fix-2 (codex §9 [P2]): the opt-in graph arm hydrates NODES too, so it takes
11944 // the same validity conjunct as the vector and FTS branches — otherwise
11945 // `search_reranked(.., use_graph_arm = true)` would keep the exact leak the
11946 // other two branches just closed. Same generator, same bound `:now`.
11947 //
11948 // fix-3 (F2): the instant arrives ALREADY RESOLVED in the `FrozenView` — it
11949 // is the identical value the vector and FTS arms bound. This arm cannot
11950 // re-read the clock: a `FrozenView` carries no route to one.
11951 let now_param = view.now_param();
11952 // C1 — seed-FTS fan-out cap per source (A: edge endpoints, B: entity nodes).
11953 const SEED_FTS_N: usize = 10;
11954 const SYNTHESIZED_PENALTY: f64 = 0.3;
11955
11956 // Bodies already in the fused result — exclude these from graph arm output.
11957 let seed_bodies: std::collections::HashSet<&str> =
11958 fused_hits.iter().map(|h| h.body.as_str()).collect();
11959
11960 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
11961
11962 let mut frontier: VecDeque<(String, u32)> = VecDeque::new(); // (logical_id, depth)
11963 let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
11964 let mut candidates: Vec<SearchHit> = Vec::new();
11965 // F9 (0.8.16 Slice 5) — per-hit traversing-edge confidence, keyed by the
11966 // emitted hit's NODE `write_cursor`. First edge to reach a node wins (visited
11967 // dedup); NULL confidence is never inserted (⇒ neutral in the reweight).
11968 let mut edge_confidence_by_cursor: HashMap<u64, f64> = HashMap::new();
11969 // G0 Phase-2 (BLOCK-1) frontier meter — distinct seed candidates considered vs
11970 // resolved-active; `resolved_seed_rate` flips 0→>0 once entities/edge-facts seed.
11971 let mut stats = GraphFrontierStats::default();
11972 {
11973 // C1 seeding — gather distinct candidate (logical_id, provenance source_id)
11974 // pairs from the graph's OWN query-matched FTS surfaces (NOT doc-node hits).
11975 // Order-preserving dedup (first provenance wins) so `seeds_considered` counts
11976 // each candidate once. `source_id` is the session the seed traces back to: the
11977 // matched edge's `source_id` (source A) or the entity node's own (source B).
11978 // F9: each seed carries the confidence of the edge that surfaced it
11979 // (`None` for entity-FTS seeds, which have no traversing edge).
11980 let mut candidate_seeds: Vec<(String, Option<String>, Option<f64>)> = Vec::new();
11981 let mut seen_candidates: std::collections::HashSet<String> =
11982 std::collections::HashSet::new();
11983 let push_candidate =
11984 |lid: String,
11985 source_id: Option<String>,
11986 confidence: Option<f64>,
11987 seen: &mut std::collections::HashSet<String>,
11988 out: &mut Vec<(String, Option<String>, Option<f64>)>| {
11989 if seen.insert(lid.clone()) {
11990 out.push((lid, source_id, confidence));
11991 }
11992 };
11993
11994 // Seed source A — edge-fact endpoints (primary). Both endpoints of each
11995 // matched, temporally-live, non-fallback edge are candidate seeds, tagged with
11996 // the edge's `source_id` provenance and (F9) `confidence`. `search_index_edges`
11997 // may be absent on very old DBs (< step-14) — degrade to no edge seeds rather
11998 // than error.
11999 // TC-33: `?1` MATCH, `?2` LIMIT ⇒ the edge `:now` binds at `?3`.
12000 if let Ok(mut edge_seed_stmt) = tx.prepare(&format!(
12001 "SELECT ce.from_id, ce.to_id, ce.source_id, ce.confidence \
12002 FROM search_index_edges sei \
12003 JOIN canonical_edges ce ON ce.write_cursor = sei.write_cursor \
12004 WHERE search_index_edges MATCH ?1 \
12005 AND ce.superseded_at IS NULL{} \
12006 AND (ce.temporal_fallback IS NULL OR ce.temporal_fallback = 0) \
12007 ORDER BY bm25(search_index_edges), sei.write_cursor \
12008 LIMIT ?2",
12009 edge_validity_sql("ce", 3)
12010 )) {
12011 let rows = edge_seed_stmt.query_map(
12012 rusqlite::params![match_expression, SEED_FTS_N as i64, view.edge_now()],
12013 |row| {
12014 Ok((
12015 row.get::<_, String>(0)?,
12016 row.get::<_, String>(1)?,
12017 row.get::<_, Option<String>>(2)?,
12018 row.get::<_, Option<f64>>(3)?,
12019 ))
12020 },
12021 )?;
12022 for quad in rows {
12023 let (from_id, to_id, source_id, confidence) = quad?;
12024 push_candidate(
12025 from_id,
12026 source_id.clone(),
12027 confidence,
12028 &mut seen_candidates,
12029 &mut candidate_seeds,
12030 );
12031 push_candidate(
12032 to_id,
12033 source_id,
12034 confidence,
12035 &mut seen_candidates,
12036 &mut candidate_seeds,
12037 );
12038 }
12039 }
12040
12041 // Seed source B — entity-node FTS (isolated / strongly-named entities).
12042 // `logical_id IS NOT NULL` structurally excludes doc nodes (the bug surface).
12043 // Provenance = the node's own `source_id` (the session it was extracted from).
12044 {
12045 // `?1` MATCH, `?2` LIMIT ⇒ `:now` binds at `?3`.
12046 let seed_validity = view.validity_sql("cn", 3);
12047 let mut node_seed_stmt = tx.prepare(&format!(
12048 "SELECT cn.logical_id, cn.source_id \
12049 FROM search_index si \
12050 JOIN canonical_nodes cn ON cn.write_cursor = si.write_cursor \
12051 WHERE search_index MATCH ?1 \
12052 AND cn.superseded_at IS NULL \
12053 AND cn.state = 'active' \
12054 AND cn.logical_id IS NOT NULL\
12055 {seed_validity} \
12056 ORDER BY bm25(search_index), si.write_cursor \
12057 LIMIT ?2"
12058 ))?;
12059 let mut seed_params: Vec<rusqlite::types::Value> = vec![
12060 rusqlite::types::Value::Text(match_expression.to_string()),
12061 rusqlite::types::Value::Integer(SEED_FTS_N as i64),
12062 ];
12063 if let Some(now) = now_param {
12064 seed_params.push(rusqlite::types::Value::Integer(now));
12065 }
12066 let rows = node_seed_stmt
12067 .query_map(rusqlite::params_from_iter(seed_params.iter()), |row| {
12068 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
12069 })?;
12070 for pair in rows {
12071 let (lid, source_id) = pair?;
12072 // Entity-FTS seed: no traversing edge ⇒ no edge confidence (neutral).
12073 push_candidate(lid, source_id, None, &mut seen_candidates, &mut candidate_seeds);
12074 }
12075 }
12076
12077 // Resolve + emit: a seed is `resolved` only if an ACTIVE canonical_node carries
12078 // that logical_id (dangling edge endpoints count considered-not-resolved). A
12079 // resolved seed is BOTH a BFS root AND emitted as a graph-arm candidate (depth
12080 // 0, hop_score 1.0) — so an edge-only query match surfaces the connected ENTITY
12081 // nodes, not just the fact body (codex §9 [P2]). Seeds whose body is already in
12082 // the two-arm result are skipped; the cap is respected.
12083 let active_validity = view.validity_sql("canonical_nodes", 2);
12084 let mut active_stmt = tx.prepare(&format!(
12085 "SELECT kind, body, write_cursor FROM canonical_nodes \
12086 WHERE logical_id = ?1 AND superseded_at IS NULL AND state = 'active'\
12087 {active_validity} LIMIT 1"
12088 ))?;
12089 for (lid, source_id, seed_confidence) in candidate_seeds {
12090 stats.seeds_considered += 1;
12091 let mut active_params: Vec<rusqlite::types::Value> =
12092 vec![rusqlite::types::Value::Text(lid.clone())];
12093 if let Some(now) = now_param {
12094 active_params.push(rusqlite::types::Value::Integer(now));
12095 }
12096 let row: Option<(String, String, i64)> = active_stmt
12097 .query_row(rusqlite::params_from_iter(active_params.iter()), |r| {
12098 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, i64>(2)?))
12099 })
12100 .optional()?;
12101 if let Some((kind, body, write_cursor)) = row {
12102 stats.seeds_resolved += 1;
12103 if visited.insert(lid.clone()) {
12104 // Cause-A: the seed's `logical_id` is in hand (`lid`) — derive the
12105 // stable id before `lid` is moved onto the frontier (zero extra query).
12106 let id = derive_stable_id(Some(&lid), &body);
12107 frontier.push_back((lid, 0));
12108 if !seed_bodies.contains(body.as_str()) && candidates.len() < cap {
12109 // depth-0 hop_score = 1.0/(1.0+0) = 1.0; synthesized penalty for
12110 // 'unknown' kind (mirrors the Phase-2 neighbor scoring).
12111 let score = if kind == "unknown" { SYNTHESIZED_PENALTY } else { 1.0 };
12112 // F9: an edge-seeded endpoint carries its seeding edge's
12113 // confidence (source A); entity-FTS seeds carry None.
12114 if let Some(c) = seed_confidence {
12115 edge_confidence_by_cursor.insert(write_cursor as u64, c);
12116 }
12117 candidates.push(SearchHit {
12118 id,
12119 write_cursor: write_cursor as u64,
12120 kind,
12121 body,
12122 score,
12123 branch: SoftFallbackBranch::GraphArm,
12124 source_id,
12125 ce_score: None,
12126 });
12127 }
12128 }
12129 }
12130 }
12131 }
12132 stats.frontier_nonempty = !frontier.is_empty();
12133
12134 // Phase 2: BFS over canonical_edges (temporal filter). `candidates` already
12135 // holds the depth-0 emitted seeds; BFS appends the reachable neighbors.
12136 // Both statements are prepared ONCE outside the loops — re-preparing inside
12137 // would issue O(frontier_size × neighbors) sqlite3_prepare_v2 calls.
12138 let mut edge_stmt = tx.prepare(
12139 // G0 Phase-2 (BLOCK-2): carry the traversed edge's `source_id` so a
12140 // graph-reached neighbor can resolve back to the session it was extracted
12141 // from. `ORDER BY e.write_cursor` makes the traversal deterministic: when
12142 // several active edges connect this node to the SAME neighbor with
12143 // different `source_id`s, the earliest-written edge wins the `visited`
12144 // dedup, so the carried provenance is stable (not SQLite-order-dependent).
12145 // (codex §9 [P2]; the design §B already rejected the memo's arbitrary
12146 // `LIMIT 1` lookup for the same reason.)
12147 // F9: also carry the traversed edge's `confidence` — the reweight input for
12148 // the reached node (keyed downstream by the node's `write_cursor`). Same
12149 // determinism as `source_id`: the earliest-written edge wins the `visited`
12150 // dedup, so the reached node's confidence is the winning-`bfs_rank` edge's.
12151 // TC-33: `?1` is the anchor logical_id ⇒ the edge `:now` binds at `?2`.
12152 &format!(
12153 "SELECT e.from_id, e.to_id, e.source_id, e.confidence \
12154 FROM canonical_edges e \
12155 WHERE (e.from_id = ?1 OR e.to_id = ?1) \
12156 AND e.superseded_at IS NULL{} \
12157 AND (e.temporal_fallback IS NULL OR e.temporal_fallback = 0) \
12158 ORDER BY e.write_cursor \
12159 LIMIT 64",
12160 edge_validity_sql("e", 2)
12161 ),
12162 )?;
12163 // Fetch write_cursor alongside kind+body so graph-arm hits carry a real id
12164 // for apply_recency_reweight (id=0 would force min_id=0 and distort span).
12165 let body_validity = view.validity_sql("canonical_nodes", 2);
12166 let mut body_stmt = tx.prepare(&format!(
12167 "SELECT kind, body, write_cursor FROM canonical_nodes \
12168 WHERE logical_id = ?1 AND superseded_at IS NULL AND state = 'active'\
12169 {body_validity} \
12170 LIMIT 1"
12171 ))?;
12172
12173 while let Some((lid, depth)) = frontier.pop_front() {
12174 if candidates.len() >= cap {
12175 break;
12176 }
12177 if depth >= max_depth {
12178 continue;
12179 }
12180
12181 // Fetch temporal-live neighbors via edges, each paired with the
12182 // traversing edge's `source_id` (BLOCK-2 provenance carry) and (F9)
12183 // `confidence` (the reweight input for the reached node).
12184 let neighbors: Vec<(String, Option<String>, Option<f64>)> = {
12185 let rows = edge_stmt.query_map(params![&lid, view.edge_now()], |row| {
12186 Ok((
12187 row.get::<_, String>(0)?,
12188 row.get::<_, String>(1)?,
12189 row.get::<_, Option<String>>(2)?,
12190 row.get::<_, Option<f64>>(3)?,
12191 ))
12192 })?;
12193 rows.flatten()
12194 .map(|(from_id, to_id, source_id, confidence)| {
12195 let neighbor = if from_id == lid { to_id } else { from_id };
12196 (neighbor, source_id, confidence)
12197 })
12198 .collect()
12199 };
12200
12201 for (neighbor, edge_source_id, edge_confidence) in neighbors {
12202 if visited.contains(&neighbor) {
12203 continue;
12204 }
12205 visited.insert(neighbor.clone());
12206
12207 // Fetch neighbor body + write_cursor from canonical_nodes.
12208 let mut body_params: Vec<rusqlite::types::Value> =
12209 vec![rusqlite::types::Value::Text(neighbor.clone())];
12210 if let Some(now) = now_param {
12211 body_params.push(rusqlite::types::Value::Integer(now));
12212 }
12213 let row: Option<(String, String, i64)> = body_stmt
12214 .query_row(rusqlite::params_from_iter(body_params.iter()), |row| {
12215 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?))
12216 })
12217 .optional()?;
12218
12219 if let Some((kind, body, write_cursor)) = row {
12220 // Skip bodies already covered by the two-arm result.
12221 if !seed_bodies.contains(body.as_str()) {
12222 let hop_score = 1.0 / (1.0 + (depth + 1) as f64);
12223 let score =
12224 if kind == "unknown" { hop_score * SYNTHESIZED_PENALTY } else { hop_score };
12225 // Cause-A: the neighbor's `logical_id` is `neighbor` (still in
12226 // scope here; only moved onto the frontier below) — derive the
12227 // stable id with no extra query.
12228 let id = derive_stable_id(Some(&neighbor), &body);
12229 // F9: record the traversing edge's confidence for this node
12230 // (first edge wins — this is the winning-`bfs_rank` edge).
12231 if let Some(c) = edge_confidence {
12232 edge_confidence_by_cursor.insert(write_cursor as u64, c);
12233 }
12234 candidates.push(SearchHit {
12235 id,
12236 write_cursor: write_cursor as u64,
12237 kind,
12238 body,
12239 score,
12240 branch: SoftFallbackBranch::GraphArm,
12241 // BLOCK-2: the session this fact-edge was extracted from.
12242 source_id: edge_source_id.clone(),
12243 ce_score: None,
12244 });
12245 if candidates.len() >= cap {
12246 break;
12247 }
12248 }
12249 // Always push neighbor to frontier for further BFS expansion.
12250 frontier.push_back((neighbor, depth + 1));
12251 }
12252 }
12253 }
12254
12255 drop(edge_stmt);
12256 drop(body_stmt);
12257 tx.commit()?;
12258 stats.graph_candidates_emitted = candidates.len() as u32;
12259 Ok((candidates, stats, edge_confidence_by_cursor))
12260}
12261
12262/// Slice 30 (G3) — the ~1M cap on a single op-store read-back page. The public
12263/// `read.collection` / `read.mutations` LIMIT is `min(caller_limit, this)`, so
12264/// no API path can issue an unbounded SELECT. Cursor/limit hardening under a
12265/// genuine ~1M-row append-only log is reserved-gap Slice 32.
12266const READ_COLLECTION_MAX_LIMIT: usize = 1_000_000;
12267
12268/// Slice 30 (G2) — active-only point lookup by `logical_id` on the DEFERRED
12269/// reader tx (mirrors `read_search_in_tx`'s snapshot-stable BEGIN DEFERRED). One
12270/// returned slot per requested id, in REQUEST ORDER; `None` where no ACTIVE row
12271/// (`superseded_at IS NULL`) carries that id. Mirrors the `:4170` canonical
12272/// projection columns + `logical_id`; superseded versions are never returned.
12273fn read_get_by_id_in_tx(
12274 reader: &mut Connection,
12275 logical_ids: &[String],
12276 view: &ReadView,
12277) -> rusqlite::Result<Vec<Option<NodeRecord>>> {
12278 if logical_ids.is_empty() {
12279 return Ok(Vec::new());
12280 }
12281 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12282 // De-duplicate the requested ids for the IN(...) probe, then re-expand into
12283 // request order (a repeated id echoes the same active row).
12284 let mut found: HashMap<String, NodeRecord> = HashMap::new();
12285 {
12286 let unique: Vec<&String> = {
12287 let mut seen = std::collections::HashSet::new();
12288 logical_ids.iter().filter(|id| seen.insert((*id).clone())).collect()
12289 };
12290 let placeholders = std::iter::repeat_n("?", unique.len()).collect::<Vec<_>>().join(", ");
12291 // The `?` placeholders above auto-number 1..=unique.len(), so the
12292 // validity instant takes the next positional slot.
12293 let now_idx = unique.len() + 1;
12294 let node_sql = view.node_sql("canonical_nodes", now_idx);
12295 // R-20-RV: with `include_superseded` a logical_id can match several
12296 // rows. `ORDER BY write_cursor` + last-write-wins into `found` resolves
12297 // the slot DETERMINISTICALLY to the most recent version, rather than
12298 // leaving it at the mercy of scan order.
12299 let sql = format!(
12300 "SELECT logical_id, kind, body, write_cursor
12301 FROM canonical_nodes
12302 WHERE logical_id IN ({placeholders}){node_sql}
12303 ORDER BY write_cursor"
12304 );
12305 let mut statement = tx.prepare(&sql)?;
12306 let mut binds: Vec<rusqlite::types::Value> =
12307 unique.iter().map(|s| rusqlite::types::Value::Text((*s).clone())).collect();
12308 if let Some(now) = view.now_param() {
12309 binds.push(rusqlite::types::Value::Integer(now));
12310 }
12311 let rows = statement.query_map(rusqlite::params_from_iter(binds.iter()), |row| {
12312 let logical_id: String = row.get(0)?;
12313 Ok(NodeRecord {
12314 logical_id,
12315 kind: row.get(1)?,
12316 body: row.get(2)?,
12317 write_cursor: row.get::<_, i64>(3)? as u64,
12318 })
12319 })?;
12320 for row in rows {
12321 let record = row?;
12322 found.insert(record.logical_id.clone(), record);
12323 }
12324 }
12325 // tx is read-only; dropping it rolls back the (empty) transaction.
12326 let out = logical_ids.iter().map(|id| found.get(id).cloned()).collect();
12327 Ok(out)
12328}
12329
12330/// Slice 30 (G3) — paginated op-store read-back over `operational_mutations` for
12331/// one `collection`, `ORDER BY id`, on the DEFERRED reader tx. The effective SQL
12332/// LIMIT is `min(limit, READ_COLLECTION_MAX_LIMIT)`; a caller `limit == 0`
12333/// returns an empty `Vec` without a SELECT. The after-id cursor (`id > ?`,
12334/// default 0) excludes the boundary row. The `_for_test` SELECTs
12335/// (`lib.rs` op-store probes) are a shape oracle only — this is a new statement.
12336///
12337/// Slice 33 (G3 / F4-READ) — hardened under a genuine large multi-collection log:
12338/// the SELECT rides the step-13 `operational_mutations(collection_name, id)`
12339/// index (`SEARCH … USING INDEX …(collection_name=? AND id>?)`), so the per-page
12340/// cost is O(page) — the leading `collection_name` equality fixes the prefix and
12341/// the trailing `id` serves both the cursor range and `ORDER BY id` with no temp
12342/// B-tree. The cursor is normalized with `.max(0)` so a negative `after_id` is
12343/// explicitly clamped to the start of the log (ids are ≥ 1) and is never confused
12344/// with a row id; `after_id` past the end and unknown collections yield empty
12345/// pages.
12346fn read_collection_in_tx(
12347 reader: &mut Connection,
12348 collection: &str,
12349 after_id: Option<i64>,
12350 limit: usize,
12351) -> rusqlite::Result<Vec<OpStoreRow>> {
12352 if limit == 0 {
12353 return Ok(Vec::new());
12354 }
12355 let clamped = limit.min(READ_COLLECTION_MAX_LIMIT) as i64;
12356 // Normalize the cursor: a negative after_id is clamped to the start of the
12357 // log. `operational_mutations.id` is autoincrement (≥ 1), so `id > 0` is the
12358 // full log; clamping removes the "is a negative cursor a sentinel or a row
12359 // id?" ambiguity without changing happy-path semantics.
12360 let after = after_id.unwrap_or(0).max(0);
12361 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12362 let mut statement = tx.prepare(
12363 "SELECT id, collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
12364 FROM operational_mutations
12365 WHERE collection_name = ?1 AND id > ?2
12366 ORDER BY id
12367 LIMIT ?3",
12368 )?;
12369 let rows = statement.query_map(params![collection, after, clamped], |row| {
12370 Ok(OpStoreRow {
12371 id: row.get(0)?,
12372 collection: row.get(1)?,
12373 record_key: row.get(2)?,
12374 op_kind: row.get(3)?,
12375 payload: row.get(4)?,
12376 schema_id: row.get(5)?,
12377 write_cursor: row.get::<_, i64>(6)? as u64,
12378 })
12379 })?;
12380 let mut out = Vec::new();
12381 for row in rows {
12382 out.push(row?);
12383 }
12384 Ok(out)
12385}
12386
12387/// Slice 35 (G4) — execute `read.list` inside a DEFERRED reader transaction.
12388///
12389/// Builds parameterized SQL: `kind = ?1 AND superseded_at IS NULL [AND
12390/// json_extract(body, '$.field') <op> ?N ...]` — injection-safe because:
12391/// (a) `kind` is `?1` (bound parameter);
12392/// (b) each predicate value is a bound `?N` parameter;
12393/// (c) the json_extract path is the ALLOWLIST ENTRY (a server-side constant
12394/// validated at `Predicate` construction time), never the raw caller string;
12395/// (d) `ComparisonOp` compiles to a server-side literal operator string from a
12396/// closed enum, not a caller-supplied string.
12397fn read_list_in_tx(
12398 reader: &mut Connection,
12399 kind: &str,
12400 predicates: &[Predicate],
12401 limit: usize,
12402 view: &ReadView,
12403) -> rusqlite::Result<Vec<NodeRecord>> {
12404 if limit == 0 {
12405 return Ok(Vec::new());
12406 }
12407 // Build the SQL WHERE clauses for each predicate.
12408 // Parameters: ?1 = kind; ?2..?N = predicate values; limit is inlined.
12409 // `logical_id IS NOT NULL` is a SQL-level predicate so that LIMIT counts
12410 // only rows that can be represented as NodeRecord (which requires a non-null
12411 // String logical_id). Anonymous nodes (PreparedWrite::Node { logical_id: None })
12412 // cannot be included in NodeRecord results and are excluded before LIMIT.
12413 // When predicates are present we add `json_valid(body)` so rows with
12414 // non-JSON bodies are skipped rather than causing a `malformed JSON` error.
12415 let json_valid_guard = if predicates.is_empty() { "" } else { " AND json_valid(body)" };
12416 // R-20-RV/R-20-NV: the view's predicates replace the previously hard-coded
12417 // existence pair. The validity instant takes the positional slot AFTER the
12418 // predicate binds (?1 = kind, ?2..=?(1+n) = predicate values), so it is
12419 // `?{predicates.len() + 2}`. Positional `?N` is order-independent in SQLite,
12420 // so emitting it here — textually before the predicate clauses appended
12421 // below — is safe and unambiguous.
12422 let now_idx = predicates.len() + 2;
12423 let node_sql = view.node_sql("canonical_nodes", now_idx);
12424 let mut sql = format!(
12425 "SELECT logical_id, kind, body, write_cursor \
12426 FROM canonical_nodes \
12427 WHERE kind = ?1{node_sql} \
12428 AND logical_id IS NOT NULL{json_valid_guard}"
12429 );
12430
12431 // Predicate params start at ?2.
12432 for (i, pred) in predicates.iter().enumerate() {
12433 let param_idx = i + 2; // ?1 is kind
12434 sql.push_str(" AND ");
12435 sql.push_str(&pred.to_sql_clause(param_idx));
12436 }
12437 sql.push_str(&format!(" LIMIT {limit}"));
12438
12439 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12440 let mut statement = tx.prepare(&sql)?;
12441
12442 // Bind all parameters: [kind, predicate_values...]
12443 let mut params: Vec<rusqlite::types::Value> = Vec::with_capacity(2 + predicates.len());
12444 params.push(rusqlite::types::Value::Text(kind.to_string()));
12445 for pred in predicates {
12446 params.push(pred.bind_value());
12447 }
12448 // Lands at index `now_idx` (= predicates.len() + 2), matching `?{now_idx}`
12449 // emitted by `ReadView::validity_sql`. Omitted entirely when the view
12450 // relaxes validity, in which case no `?{now_idx}` was emitted either.
12451 if let Some(now) = view.now_param() {
12452 params.push(rusqlite::types::Value::Integer(now));
12453 }
12454
12455 let rows = statement.query_map(rusqlite::params_from_iter(params.iter()), |row| {
12456 Ok(NodeRecord {
12457 logical_id: row.get(0)?,
12458 kind: row.get(1)?,
12459 body: row.get(2)?,
12460 write_cursor: row.get::<_, i64>(3)? as u64,
12461 })
12462 })?;
12463
12464 let mut out = Vec::new();
12465 for row in rows {
12466 out.push(row?);
12467 }
12468 Ok(out)
12469}
12470
12471// ---------------------------------------------------------------------------
12472// Slice 20 (G5/G6) — BFS graph-traversal helpers
12473// ---------------------------------------------------------------------------
12474
12475/// Hard cap on the number of nodes returned by a single `graph_neighbors` call.
12476/// Ported from v0.5.6 `MAX_TRAVERSAL_DEPTH` (applied as a LIMIT on the CTE and
12477/// the final SELECT). Defense-in-depth against unbounded traversal.
12478const GRAPH_NEIGHBORS_HARD_CAP: usize = 50;
12479
12480/// Build the BFS CTE SQL for the given `direction`, under `view`.
12481///
12482/// Parameters (positional):
12483/// `?1` — root `logical_id`
12484/// `?2` — max_depth (`u32`, SDK-facing depth ceiling ≤ 3)
12485/// `?3` — R-20-NV node-validity instant (`:now` seam), emitted at EVERY node
12486/// position; omitted entirely when the view relaxes validity.
12487///
12488/// `LIMIT {GRAPH_NEIGHBORS_HARD_CAP}` appears on both the CTE and the final SELECT.
12489///
12490/// # Why one template instead of three
12491///
12492/// The three directions previously carried three hand-maintained copies of the
12493/// CTE, each repeating the node predicate at THREE positions (anchor, recursive
12494/// join, final projection) — nine hand-written copies in total. R-20-RV requires
12495/// a relax flag to apply at every one of them, and nine copies is exactly the
12496/// shape in which "it works on `Outgoing` but silently not on `Both`" hides. The
12497/// directions are folded into ONE template parameterised by the two things that
12498/// actually differ (the edge join condition and the traversed-to expression), so
12499/// `view.node_sql(...)` is written once per position and applying to all three
12500/// directions is structural rather than a thing to remember.
12501///
12502/// **TC-33: the `canonical_edges` temporal filter is now parameterised too.** It
12503/// was `datetime(e.t_invalid) > datetime('now')` inline, deliberately left alone
12504/// while edge validity was ISO-8601 TEXT. Edge timestamps are INTEGER epoch
12505/// seconds now, so the predicate is generated by [`edge_validity_sql`] and binds
12506/// the frozen instant at `?4` — no inline clock remains in this template.
12507fn build_bfs_sql(direction: TraversalDirection, view: &ReadView) -> String {
12508 let cap = GRAPH_NEIGHBORS_HARD_CAP;
12509 // cte_cap: the SQLite CTE LIMIT counts path-rows, not distinct nodes. In a
12510 // multigraph (multiple parallel edges between the same pair of nodes), the CTE
12511 // can contain duplicate-target rows before the final SELECT DISTINCT. A cap of
12512 // cap+1 would be exhausted by ~50 parallel edges to the same node, preventing
12513 // other neighbors from being discovered. Use cap*cap as a generous safety
12514 // ceiling that still bounds CTE growth for any realistic graph while allowing
12515 // the final SELECT LIMIT cap to be the authoritative distinct-node cap.
12516 let cte_cap = cap * cap;
12517 // Cycle guard uses char(30) (ASCII Record Separator, 0x1E) as delimiter instead
12518 // of comma, so logical_ids containing commas are handled correctly. char(30) is
12519 // a non-printable control character that callers cannot place in logical_id values
12520 // via normal text input.
12521 //
12522 // `?3` is the node-validity instant. Positional (not named), so the repeated
12523 // occurrences across the three node positions all bind the SAME value once.
12524 const NOW_IDX: usize = 3;
12525 // TC-33: `?4` is the EDGE-validity instant, bound separately because the node
12526 // instant is `Option` (relaxed by `include_out_of_window`) while edge recency
12527 // is always applied.
12528 const EDGE_NOW_IDX: usize = 4;
12529
12530 // The ONLY two things that differ between directions.
12531 let (edge_join, target_expr) = match direction {
12532 TraversalDirection::Outgoing => ("e.from_id = t.logical_id", "e.to_id"),
12533 TraversalDirection::Incoming => ("e.to_id = t.logical_id", "e.from_id"),
12534 TraversalDirection::Both => (
12535 "(e.from_id = t.logical_id OR e.to_id = t.logical_id)",
12536 "CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END",
12537 ),
12538 };
12539
12540 // Position 1 (anchor), position 2 (recursive join), position 3 (final
12541 // projection) — the view is applied at all three, for every direction.
12542 let anchor_node = view.node_sql("n", NOW_IDX);
12543 let next_node = view.node_sql("next_n", NOW_IDX);
12544 let projection_node = view.node_sql("n", NOW_IDX);
12545 let edge_valid = edge_validity_sql("e", EDGE_NOW_IDX);
12546
12547 format!(
12548 "WITH RECURSIVE
12549 traversal(logical_id, depth, visited) AS (
12550 SELECT n.logical_id, 0, char(30) || n.logical_id || char(30)
12551 FROM canonical_nodes n
12552 WHERE n.logical_id = ?1{anchor_node}
12553 UNION ALL
12554 SELECT {target_expr}, t.depth + 1, t.visited || {target_expr} || char(30)
12555 FROM traversal t
12556 JOIN canonical_edges e ON {edge_join}
12557 JOIN canonical_nodes next_n ON next_n.logical_id = {target_expr}{next_node}
12558 WHERE t.depth < ?2
12559 AND e.superseded_at IS NULL{edge_valid}
12560 AND instr(t.visited, char(30) || {target_expr} || char(30)) = 0
12561 LIMIT {cte_cap}
12562 )
12563SELECT DISTINCT n.logical_id, n.kind, n.body, n.write_cursor
12564FROM traversal tr
12565JOIN canonical_nodes n ON n.logical_id = tr.logical_id
12566WHERE tr.logical_id != ?1{projection_node}
12567LIMIT {cap}"
12568 )
12569}
12570
12571/// Build the BFS CTE SQL for `search_expand` — identical to `build_bfs_sql`
12572/// but the final SELECT uses `GROUP BY` + `MIN(tr.depth)` so that each
12573/// expanded node carries its actual BFS distance from the root.
12574///
12575/// Returns 5 columns: logical_id, kind, body, write_cursor, min_depth.
12576fn build_bfs_with_depth_sql() -> String {
12577 let cap = GRAPH_NEIGHBORS_HARD_CAP;
12578 let cte_cap = cap * cap; // same multigraph-safe headroom as build_bfs_sql
12579 // TC-33: `?1` anchor, `?2` depth ⇒ the edge `:now` binds at `?3`. This is a
12580 // SECOND, separate BFS template — the edge-validity predicate has to be
12581 // re-grounded here too or `search_expand` silently keeps the old semantics.
12582 let edge_valid = edge_validity_sql("e", 3);
12583 format!(
12584 "WITH RECURSIVE
12585 traversal(logical_id, depth, visited) AS (
12586 SELECT n.logical_id, 0, char(30) || n.logical_id || char(30)
12587 FROM canonical_nodes n
12588 WHERE n.logical_id = ?1 AND n.superseded_at IS NULL AND n.state = 'active'
12589 UNION ALL
12590 SELECT
12591 CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END,
12592 t.depth + 1,
12593 t.visited || CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END || char(30)
12594 FROM traversal t
12595 JOIN canonical_edges e ON (e.from_id = t.logical_id OR e.to_id = t.logical_id)
12596 JOIN canonical_nodes next_n
12597 ON next_n.logical_id = CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END
12598 AND next_n.superseded_at IS NULL AND next_n.state = 'active'
12599 WHERE t.depth < ?2
12600 AND e.superseded_at IS NULL{edge_valid}
12601 AND instr(t.visited,
12602 char(30) || CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END || char(30)) = 0
12603 LIMIT {cte_cap}
12604 )
12605SELECT n.logical_id, n.kind, n.body, n.write_cursor, MIN(tr.depth) AS min_depth
12606FROM traversal tr
12607JOIN canonical_nodes n ON n.logical_id = tr.logical_id
12608WHERE n.superseded_at IS NULL AND n.state = 'active'
12609 AND tr.logical_id != ?1
12610GROUP BY n.logical_id
12611LIMIT {cap}"
12612 )
12613}
12614
12615/// 0.8.20 Slice 10b (R-20-NV) — the validity-boundary hook, on the DEFERRED
12616/// reader transaction.
12617///
12618/// Reports nodes whose `valid_from` and/or `valid_until` falls in the half-open
12619/// interval `(since, upper]`. Both bounds are BOUND parameters (`?1`, `?2`) —
12620/// the node-validity path never inlines `datetime('now')`.
12621///
12622/// The view's EXISTENCE conjunct applies (default: current + active rows only);
12623/// its VALIDITY conjunct deliberately does not, because the question is "did
12624/// this window cross a boundary", not "is this row valid now".
12625fn crossed_boundary_since_in_tx(
12626 reader: &mut Connection,
12627 since: i64,
12628 view: &ReadView,
12629) -> rusqlite::Result<Vec<BoundaryCrossing>> {
12630 // `now_param()` is None exactly when the view relaxes validity, which here
12631 // means "no upper bound on the interval".
12632 let upper = view.now_param().unwrap_or(i64::MAX);
12633 let existence = view.existence_sql("canonical_nodes");
12634 // `1 = 1` keeps the leading ` AND ` of `existence_sql` well-formed even when
12635 // every existence flag is relaxed and the conjunct is empty.
12636 let sql = format!(
12637 "SELECT logical_id, kind, body, write_cursor, valid_from, valid_until \
12638 FROM canonical_nodes \
12639 WHERE 1 = 1{existence} \
12640 AND logical_id IS NOT NULL \
12641 AND ( (valid_from IS NOT NULL AND valid_from > ?1 AND valid_from <= ?2) \
12642 OR (valid_until IS NOT NULL AND valid_until > ?1 AND valid_until <= ?2) ) \
12643 ORDER BY write_cursor"
12644 );
12645 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12646 let mut statement = tx.prepare(&sql)?;
12647 let rows = statement.query_map(params![since, upper], |row| {
12648 let valid_from: Option<i64> = row.get(4)?;
12649 let valid_until: Option<i64> = row.get(5)?;
12650 Ok(BoundaryCrossing {
12651 node: NodeRecord {
12652 logical_id: row.get(0)?,
12653 kind: row.get(1)?,
12654 body: row.get(2)?,
12655 write_cursor: row.get::<_, i64>(3)? as u64,
12656 },
12657 became_valid_at: valid_from.filter(|t| *t > since && *t <= upper),
12658 became_invalid_at: valid_until.filter(|t| *t > since && *t <= upper),
12659 })
12660 })?;
12661 let mut out = Vec::new();
12662 for row in rows {
12663 out.push(row?);
12664 }
12665 Ok(out)
12666}
12667
12668/// Slice 20 (G5) — execute a bounded BFS on the DEFERRED reader transaction.
12669/// Called inside the reader worker loop.
12670fn graph_neighbors_in_tx(
12671 reader: &mut Connection,
12672 root_logical_id: &str,
12673 depth: u32,
12674 direction: TraversalDirection,
12675 view: &ReadView,
12676) -> rusqlite::Result<Vec<NodeRecord>> {
12677 let sql = build_bfs_sql(direction, view);
12678 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12679 let depth_i64 = depth as i64;
12680 let mut statement = tx.prepare(&sql)?;
12681 // ?1 root, ?2 depth, ?3 = the NODE validity instant, ?4 = the EDGE validity
12682 // instant (TC-33).
12683 //
12684 // ?3 is bound UNCONDITIONALLY even when the view relaxes node validity and
12685 // `build_bfs_sql` emitted no `?3`: the template still references ?4, so
12686 // SQLite's parameter count is 4 and the positions must not shift. Binding an
12687 // index the SQL never reads is harmless; letting ?4's value slide into ?3
12688 // would silently compare edge times against a placeholder.
12689 let frozen = (*view).freeze();
12690 let binds: Vec<rusqlite::types::Value> = vec![
12691 rusqlite::types::Value::Text(root_logical_id.to_string()),
12692 rusqlite::types::Value::Integer(depth_i64),
12693 rusqlite::types::Value::Integer(frozen.now_param().unwrap_or_default()),
12694 rusqlite::types::Value::Integer(frozen.edge_now()),
12695 ];
12696 let rows = statement.query_map(rusqlite::params_from_iter(binds.iter()), |row| {
12697 Ok(NodeRecord {
12698 logical_id: row.get(0)?,
12699 kind: row.get(1)?,
12700 body: row.get(2)?,
12701 write_cursor: row.get::<_, i64>(3)? as u64,
12702 })
12703 })?;
12704 let mut out = Vec::new();
12705 for row in rows {
12706 out.push(row?);
12707 }
12708 Ok(out)
12709}
12710
12711/// Slice 20 (G6) — resolve search hit `write_cursor`s to `logical_id`s, run
12712/// BFS for each root, and merge into a [`SearchExpandResult`]. Called inside
12713/// the reader worker loop on the DEFERRED reader transaction.
12714fn search_expand_in_tx(
12715 reader: &mut Connection,
12716 search_hits: &[SearchHit],
12717 depth: u32,
12718) -> rusqlite::Result<SearchExpandResult> {
12719 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12720
12721 // Step 1: resolve write_cursor → logical_id for each search hit.
12722 // Possible outcomes per hit:
12723 // - None: no matching write_cursor in canonical_nodes (superseded) → drop.
12724 // - Some(""): row exists but logical_id IS NULL (anonymous node) or TextEdge hit
12725 // → keep as valid search result, skip BFS expansion (empty sentinel).
12726 // - Some(lid): active named node → keep; use as BFS root.
12727 let mut hit_logical_ids: Vec<Option<String>> = Vec::with_capacity(search_hits.len());
12728 {
12729 let mut node_stmt = tx.prepare(
12730 "SELECT logical_id FROM canonical_nodes
12731 WHERE write_cursor = ?1 AND superseded_at IS NULL AND state = 'active'
12732 LIMIT 1",
12733 )?;
12734 let mut edge_stmt = tx.prepare(
12735 "SELECT 1 FROM canonical_edges
12736 WHERE write_cursor = ?1 AND superseded_at IS NULL
12737 LIMIT 1",
12738 )?;
12739 for hit in search_hits {
12740 if hit.branch == SoftFallbackBranch::TextEdge {
12741 // Edge-body hit: verify the edge row is still active in THIS snapshot.
12742 // Stale edge hits (superseded between search and expansion) are dropped.
12743 let cursor_i64 = hit.write_cursor as i64;
12744 let active: Option<i32> =
12745 edge_stmt.query_row([cursor_i64], |row| row.get(0)).optional()?;
12746 if active.is_some() {
12747 hit_logical_ids.push(Some(String::new())); // sentinel: keep hit, skip BFS
12748 } else {
12749 hit_logical_ids.push(None); // superseded edge: drop
12750 }
12751 } else {
12752 let cursor_i64 = hit.write_cursor as i64;
12753 // Returns Option<Option<String>>:
12754 // None → no row → superseded
12755 // Some(None) → row with NULL logical_id → anonymous node
12756 // Some(Some(s)) → active named node
12757 let resolved = node_stmt
12758 .query_row([cursor_i64], |row| row.get::<_, Option<String>>(0))
12759 .optional()?;
12760 match resolved {
12761 None => hit_logical_ids.push(None), // superseded: drop
12762 Some(None) => hit_logical_ids.push(Some(String::new())), // anon: keep, skip BFS
12763 Some(Some(lid)) => hit_logical_ids.push(Some(lid)), // named: keep + BFS root
12764 }
12765 }
12766 }
12767 }
12768
12769 // Build a set of logical_ids present in the search hits (for deduplication).
12770 // Empty-string sentinels (TextEdge hits) are excluded — they are not real node ids.
12771 let hit_id_set: std::collections::HashSet<String> =
12772 hit_logical_ids.iter().filter_map(|id| id.clone()).filter(|s| !s.is_empty()).collect();
12773
12774 // Step 2: for each root logical_id, run the BFS and collect expanded nodes.
12775 // A node already in `hit_id_set` is NOT added to `expanded`.
12776 // Use the depth-aware variant so each node reports its actual BFS distance.
12777 let bfs_sql = build_bfs_with_depth_sql();
12778 let depth_i64 = depth as i64;
12779 // nearest_hop: for each expanded logical_id track the minimum hop count
12780 // seen across ALL search-hit roots. A node reachable from multiple roots
12781 // at different depths must report the shortest distance (nearest root).
12782 let mut nearest_hop: std::collections::HashMap<String, (NodeRecord, u32)> =
12783 std::collections::HashMap::new();
12784
12785 if depth > 0 {
12786 let mut bfs_stmt = tx.prepare(&bfs_sql)?;
12787 // TC-33: `?3` is the edge-validity instant. `search_expand` has no
12788 // `ReadView` in scope, so it uses the default (strict) semantics —
12789 // resolved ONCE here, not per root, so every root in one call agrees.
12790 let edge_now = current_epoch_seconds();
12791 for root_id in hit_logical_ids.iter().flatten().filter(|s| !s.is_empty()) {
12792 let neighbor_rows =
12793 bfs_stmt.query_map(params![root_id, depth_i64, edge_now], |row| {
12794 let node = NodeRecord {
12795 logical_id: row.get(0)?,
12796 kind: row.get(1)?,
12797 body: row.get(2)?,
12798 write_cursor: row.get::<_, i64>(3)? as u64,
12799 };
12800 let min_depth: i64 = row.get(4)?;
12801 Ok((node, min_depth as u32))
12802 })?;
12803 for row_result in neighbor_rows {
12804 let (node, hop_count) = row_result?;
12805 if hit_id_set.contains(&node.logical_id) {
12806 // Already a search hit — skip (search score takes priority).
12807 continue;
12808 }
12809 nearest_hop
12810 .entry(node.logical_id.clone())
12811 .and_modify(|(_, prev_hop)| {
12812 if hop_count < *prev_hop {
12813 *prev_hop = hop_count;
12814 }
12815 })
12816 .or_insert((node, hop_count));
12817 }
12818 }
12819 }
12820
12821 // Materialize expanded in insertion order (deterministic for tests).
12822 let mut expanded: Vec<(NodeRecord, u32)> = nearest_hop.into_values().collect();
12823 expanded.sort_by(|(a, _), (b, _)| a.logical_id.cmp(&b.logical_id));
12824
12825 // Filter search_hits to only include those whose write_cursor resolved to an
12826 // active logical_id in THIS snapshot. Hits that were superseded between the
12827 // search phase and the expansion phase (the two-snapshot window) are dropped
12828 // rather than returned with stale data.
12829 let resolved_hits: Vec<SearchHit> = search_hits
12830 .iter()
12831 .zip(hit_logical_ids.iter())
12832 .filter_map(|(hit, lid)| lid.as_ref().map(|_| hit.clone()))
12833 .collect();
12834
12835 // Build `all_logical_ids` = resolved search-hit logical_ids + expanded node ids.
12836 // Empty-string sentinels (TextEdge hits) are excluded — they are not real node ids.
12837 let mut all_logical_ids: Vec<String> =
12838 hit_logical_ids.into_iter().flatten().filter(|s| !s.is_empty()).collect();
12839 for (node, _) in &expanded {
12840 if !all_logical_ids.contains(&node.logical_id) {
12841 all_logical_ids.push(node.logical_id.clone());
12842 }
12843 }
12844
12845 Ok(SearchExpandResult { search_hits: resolved_hits, expanded, all_logical_ids })
12846}
12847
12848/// Slice 20 test seam — run `EXPLAIN QUERY PLAN` on the BFS CTE SQL and return
12849/// the plan `detail` column (column index 3) for each row. Used by
12850/// `explain_plan_uses_indexes` to assert index usage.
12851fn explain_graph_neighbors_in_tx(
12852 reader: &mut Connection,
12853 root_logical_id: &str,
12854 depth: u32,
12855 direction: TraversalDirection,
12856) -> rusqlite::Result<Vec<String>> {
12857 // The EXPLAIN index-usage gate measures the DEFAULT (strict) read path.
12858 let view = ReadView::default();
12859 let bfs_sql = build_bfs_sql(direction, &view);
12860 let explain_sql = format!("EXPLAIN QUERY PLAN {bfs_sql}");
12861 let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12862 let depth_i64 = depth as i64;
12863 let mut statement = tx.prepare(&explain_sql)?;
12864 // EXPLAIN QUERY PLAN returns rows: (id, parent, notused, detail).
12865 // We collect the `detail` column (index 3).
12866 // The strict view emits `?3` (the node-validity instant) at every node
12867 // position; TC-33 adds `?4`, the edge-validity instant.
12868 let frozen = view.freeze();
12869 let now = frozen.now_param().expect("the strict view always binds a validity instant");
12870 let rows = statement
12871 .query_map(params![root_logical_id, depth_i64, now, frozen.edge_now()], |row| {
12872 row.get::<_, String>(3)
12873 })?;
12874 let mut out = Vec::new();
12875 for row in rows {
12876 out.push(row?);
12877 }
12878 Ok(out)
12879}
12880
12881fn projection_dispatcher_loop(shared: Arc<ProjectionRuntimeShared>) {
12882 let connection = match open_runtime_connection(&shared.path) {
12883 Ok(connection) => connection,
12884 Err(_) => return,
12885 };
12886 // 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — read ONCE:
12887 // `ProjectionRuntimeShared::embedder` is fixed for the session's lifetime.
12888 let dense_arm_live = shared.embedder.is_some();
12889 loop {
12890 let in_flight = {
12891 let mut state = match shared.state.lock() {
12892 Ok(state) => state,
12893 Err(_) => return,
12894 };
12895 while !state.stopping
12896 && (!state.pending_scan
12897 || state.frozen
12898 || state.active_jobs + state.queued_jobs >= PROJECTION_INFLIGHT_LIMIT)
12899 {
12900 state = match shared.state_cvar.wait(state) {
12901 Ok(state) => state,
12902 Err(_) => return,
12903 };
12904 }
12905 if state.stopping {
12906 return;
12907 }
12908 state.pending_scan = false;
12909 state.in_flight.clone()
12910 };
12911
12912 // Fetch up to the in-flight budget in one SQL roundtrip and
12913 // enqueue them as a batch — previously this loop fetched ONE job
12914 // per cycle, which capped projection throughput at one row per
12915 // scanner/worker handshake regardless of how much work was queued
12916 // in canonical_nodes.
12917 let budget = {
12918 let state = match shared.state.lock() {
12919 Ok(state) => state,
12920 Err(_) => return,
12921 };
12922 PROJECTION_INFLIGHT_LIMIT.saturating_sub(state.active_jobs + state.queued_jobs)
12923 };
12924 let fetch_cap = budget.clamp(1, PROJECTION_SCAN_FETCH);
12925 // 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — with no live embedder a
12926 // NODE job can only come back DEFERRED (`ProjectionOutcome::Deferred`),
12927 // which by design records no terminal, so dispatching one would re-fetch
12928 // the SAME cursor forever. fix-5 (codex §9 round 4 [P1]) moved that
12929 // exclusion INSIDE the scan, so the `LIMIT` applies to the already-filtered
12930 // set and a pending EDGE body behind a full window of node rows is still
12931 // reachable. See `next_pending_projection_jobs`.
12932 let fetched =
12933 next_pending_projection_jobs(&connection, &in_flight, fetch_cap, dense_arm_live);
12934 // Cheap assertion only — it can never DROP a job, which is precisely what
12935 // the fix-4 shape did.
12936 debug_assert!(
12937 fetched
12938 .as_ref()
12939 .map(|jobs| dense_arm_live || jobs.iter().all(|job| job.kind == EDGE_FACT_KIND))
12940 .unwrap_or(true),
12941 "no-embedder scan returned a NODE job: the exclusion must be in the scan's SQL"
12942 );
12943 match fetched {
12944 Ok(jobs) if !jobs.is_empty() => {
12945 if let Ok(mut state) = shared.state.lock() {
12946 state.queued_jobs = state.queued_jobs.saturating_add(jobs.len());
12947 for job in &jobs {
12948 state.in_flight.insert(job.cursor);
12949 }
12950 state.pending_scan = true;
12951 shared.state_cvar.notify_all();
12952 }
12953 if let Ok(mut queue) = shared.queue.lock() {
12954 for job in jobs {
12955 queue.push_back(job);
12956 }
12957 shared.queue_cvar.notify_all();
12958 }
12959 }
12960 Ok(_) => {}
12961 Err(_) => {
12962 if let Ok(mut state) = shared.state.lock() {
12963 state.pending_scan = false;
12964 shared.state_cvar.notify_all();
12965 }
12966 }
12967 }
12968 }
12969}
12970
12971fn projection_worker_loop(shared: Arc<ProjectionRuntimeShared>) {
12972 let mut connection = match open_runtime_connection(&shared.path) {
12973 Ok(connection) => connection,
12974 Err(_) => return,
12975 };
12976 if ensure_vector_partition(&mut connection, shared.embedder_identity.dimension).is_err() {
12977 return;
12978 }
12979 loop {
12980 let jobs = {
12981 let mut queue = match shared.queue.lock() {
12982 Ok(queue) => queue,
12983 Err(_) => return,
12984 };
12985 loop {
12986 let stopping = shared.state.lock().map(|state| state.stopping).unwrap_or(true);
12987 if stopping && queue.is_empty() {
12988 return;
12989 }
12990 if let Some(job) = queue.pop_front() {
12991 let mut jobs = vec![job];
12992 while jobs.len() < PROJECTION_COMMIT_BATCH {
12993 let Some(job) = queue.pop_front() else {
12994 break;
12995 };
12996 jobs.push(job);
12997 }
12998 if let Ok(mut state) = shared.state.lock() {
12999 state.queued_jobs = state.queued_jobs.saturating_sub(jobs.len());
13000 state.active_jobs = state.active_jobs.saturating_add(jobs.len());
13001 shared.state_cvar.notify_all();
13002 }
13003 break jobs;
13004 }
13005 queue = match shared.queue_cvar.wait(queue) {
13006 Ok(queue) => queue,
13007 Err(_) => return,
13008 };
13009 }
13010 };
13011
13012 // EU-5f — isolate worker faults. A panic inside `embed()` (or the
13013 // commit) must not skip the state cleanup below, or `active_jobs`
13014 // would stay elevated forever and `wait_for_idle` / `drain` would
13015 // wedge into `EngineError::Scheduler` (Finding A). Mirrors the
13016 // reader pool's `LiveGuard` panic-safety. The local commit tx rolls
13017 // back on unwind, leaving the connection clean for reuse.
13018 let commit_result = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
13019 run_projection_jobs(&shared, &mut connection, &jobs)
13020 })) {
13021 Ok(result) => result,
13022 Err(_) => commit_projection_panic_failures(&shared, &mut connection, &jobs),
13023 };
13024 if let Err(err) = commit_result {
13025 // Host subscribers are arbitrary application code. Their panic must
13026 // not bypass the mandatory state cleanup below, or the durable
13027 // pending row would stay stranded in `in_flight` forever.
13028 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
13029 report_projection_commit_failure(&shared, &err);
13030 }));
13031 #[cfg(debug_assertions)]
13032 if let Some((reported, release)) = shared
13033 .projection_commit_failure_pause
13034 .lock()
13035 .unwrap_or_else(|poisoned| poisoned.into_inner())
13036 .take()
13037 {
13038 reported.wait();
13039 release.wait();
13040 }
13041 }
13042
13043 if let Ok(mut state) = shared.state.lock() {
13044 state.active_jobs = state.active_jobs.saturating_sub(jobs.len());
13045 for job in &jobs {
13046 state.in_flight.remove(&job.cursor);
13047 }
13048 if !state.stopping {
13049 state.pending_scan = true;
13050 }
13051 shared.state_cvar.notify_all();
13052 }
13053 }
13054}
13055
13056enum ProjectionOutcome {
13057 /// `blob` is the un-centered f32 BLOB persisted to
13058 /// `vector_default.embedding`. `bin_blob` is the (possibly centered)
13059 /// f32 BLOB fed to `vec_quantize_binary` for the sign-bit column.
13060 /// EU-5a2: `bin_blob == blob` unless the identity is MC-required
13061 /// AND a mean_vec is pinned.
13062 Success {
13063 cursor: u64,
13064 kind: String,
13065 blob: Vec<u8>,
13066 bin_blob: Vec<u8>,
13067 },
13068 Failure {
13069 cursor: u64,
13070 failure_code: &'static str,
13071 },
13072 /// 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — the ENVIRONMENT could
13073 /// not serve this row, as distinct from the embed FAILING. Records nothing
13074 /// at all: no `projection_failures` audit row and, decisively, **no
13075 /// terminal**. The row stays `terminal IS NULL`, i.e. PENDING, so the next
13076 /// session that DOES have an embedder picks it up through the ordinary
13077 /// scheduler — no graft path and no recovery machinery.
13078 ///
13079 /// The only producer is the absent-embedder check at the top of
13080 /// [`run_projection_job`]. That condition cannot change within a session, so
13081 /// this can never become a retry loop for a genuinely-failing row.
13082 ///
13083 /// It carries NO cursor, deliberately: the other two variants carry one
13084 /// because they identify the row they are about to WRITE, and this variant
13085 /// writes nothing at all. A row's deferral is represented on disk by the
13086 /// continued ABSENCE of its `_fathomdb_projection_terminal` row, which is
13087 /// exactly the state it was already in.
13088 Deferred,
13089}
13090
13091fn run_projection_jobs(
13092 shared: &ProjectionRuntimeShared,
13093 connection: &mut Connection,
13094 jobs: &[ProjectionJob],
13095) -> rusqlite::Result<()> {
13096 let outcomes = embed_projection_batch(shared, jobs);
13097 commit_projection_outcomes(connection, &outcomes, shared)
13098}
13099
13100/// Embed a whole commit-batch in ONE `embed_batch` call (amortizes per-call
13101/// overhead; saturates the GPU — minutes -> seconds on a full-corpus embed). The
13102/// batched path is the fast HAPPY path only; on ANY anomaly — no embedder, breaker
13103/// open, single job, batch timeout/failure, row-count or per-row dimension mismatch
13104/// — it falls back to the proven per-job [`run_projection_job`], which carries the
13105/// full retry + circuit-breaker + failure-isolation semantics. So batching can only
13106/// make the common case faster, never change correctness. A panic inside the batch
13107/// embed resume-unwinds exactly like the per-embed watchdog, so the worker's
13108/// batch-level `catch_unwind` records `ProjectionPanic` as before.
13109///
13110/// Batching is **opt-in** via `FATHOMDB_PROJECTION_BATCH=1` (`true`/`on` accepted).
13111/// It reshapes the PR-9 per-embed watchdog/breaker accounting into per-batch, so the
13112/// conservative DEFAULT keeps the proven per-job path — leaving every PR-9 safety
13113/// test (watchdog, serialization, circuit breaker) behaving exactly as before. The
13114/// eval GPU-embed run sets the env to get the batched-forward speedup (minutes ->
13115/// seconds), where the per-job fallback below still backs every error case.
13116fn projection_batch_enabled() -> bool {
13117 matches!(
13118 std::env::var("FATHOMDB_PROJECTION_BATCH").ok().as_deref(),
13119 Some("1") | Some("true") | Some("on")
13120 )
13121}
13122
13123fn embed_projection_batch(
13124 shared: &ProjectionRuntimeShared,
13125 jobs: &[ProjectionJob],
13126) -> Vec<ProjectionOutcome> {
13127 let per_job = || jobs.iter().map(|job| run_projection_job(shared, job)).collect();
13128
13129 let Some(embedder) = shared.embedder.as_ref() else {
13130 return per_job();
13131 };
13132 if jobs.len() < 2
13133 || shared.embed_circuit_open.load(Ordering::Relaxed)
13134 || !projection_batch_enabled()
13135 {
13136 return per_job();
13137 }
13138
13139 let bodies: Vec<String> = jobs.iter().map(|job| job.body.clone()).collect();
13140 let embed_timeout = Duration::from_millis(shared.embed_timeout_ms.load(Ordering::Relaxed));
13141 // Each row keeps its single-embed budget worst-case (batch <= COMMIT_BATCH=16).
13142 let batch_timeout = embed_timeout.saturating_mul(jobs.len() as u32);
13143
13144 let vectors = {
13145 // PR-9 — serialize the embedder call (ONE batched call at a time) and make
13146 // the breaker decision with the guard held (race-free vs other workers),
13147 // mirroring `run_projection_job`. The batch thread counts as one live embed.
13148 let _embed_permit =
13149 shared.embed_serialize.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13150 let threshold = shared.embed_circuit_threshold.load(Ordering::Relaxed);
13151 if shared.embed_circuit_open.load(Ordering::Relaxed)
13152 || (threshold != 0 && shared.live_embed_threads.load(Ordering::Relaxed) >= threshold)
13153 {
13154 shared.embed_circuit_open.store(true, Ordering::Relaxed);
13155 return per_job();
13156 }
13157 match embed_batch_with_watchdog(
13158 embedder,
13159 &bodies,
13160 batch_timeout,
13161 &shared.live_embed_threads,
13162 ) {
13163 Ok(vectors) => vectors,
13164 // Timeout / failed / disconnected -> the per-job path retries each row
13165 // and engages the breaker exactly as before.
13166 Err(_) => return per_job(),
13167 }
13168 };
13169
13170 if vectors.len() != jobs.len() {
13171 return per_job();
13172 }
13173 let mut outcomes = Vec::with_capacity(jobs.len());
13174 for (job, vector) in jobs.iter().zip(vectors) {
13175 if u32::try_from(vector.len()).unwrap_or(u32::MAX) != shared.embedder_identity.dimension {
13176 // A row came back wrong-dim: fall back per-job for the whole batch
13177 // (rare; keeps the dimension-mismatch failure path identical).
13178 return per_job();
13179 }
13180 // Mirror run_projection_job's post-embed step exactly: persisted f32 BLOB is
13181 // un-centered; centering for the binary column is finalized in
13182 // commit_projection_outcomes (so bin_blob == blob here).
13183 let blob = encode_vector_blob(&vector);
13184 let bin_blob = blob.clone();
13185 outcomes.push(ProjectionOutcome::Success {
13186 cursor: job.cursor,
13187 kind: job.kind.clone(),
13188 blob,
13189 bin_blob,
13190 });
13191 }
13192 outcomes
13193}
13194
13195/// EU-5f — record every job in a panicked batch as a terminal projection
13196/// failure so the scheduler does not re-enqueue and re-panic on the same
13197/// cursors. Best-effort; runs after the worker caught a panic.
13198fn commit_projection_panic_failures(
13199 shared: &ProjectionRuntimeShared,
13200 connection: &mut Connection,
13201 jobs: &[ProjectionJob],
13202) -> rusqlite::Result<()> {
13203 let outcomes: Vec<ProjectionOutcome> = jobs
13204 .iter()
13205 .map(|job| ProjectionOutcome::Failure {
13206 cursor: job.cursor,
13207 failure_code: "ProjectionPanic",
13208 })
13209 .collect();
13210 commit_projection_outcomes(connection, &outcomes, shared)
13211}
13212
13213/// Route a background projection-commit failure through the engine's existing
13214/// host subscriber path. A SQLite error retains its stable SQLite code; a
13215/// rusqlite-layer error is an engine storage failure rather than a fabricated
13216/// SQLite diagnostic.
13217fn report_projection_commit_failure(shared: &ProjectionRuntimeShared, err: &rusqlite::Error) {
13218 let event = if let Some(code) = sqlite_extended_code_name(err) {
13219 lifecycle::Event {
13220 phase: lifecycle::Phase::Failed,
13221 source: lifecycle::EventSource::SqliteInternal,
13222 category: lifecycle::EventCategory::Error,
13223 code: Some(code),
13224 }
13225 } else {
13226 lifecycle::Event {
13227 phase: lifecycle::Phase::Failed,
13228 source: lifecycle::EventSource::Engine,
13229 category: lifecycle::EventCategory::Error,
13230 code: Some("StorageError"),
13231 }
13232 };
13233 shared.subscribers.dispatch(&event);
13234}
13235
13236/// PR-9 — ADR-0.6.0-embedder-protocol **Invariant 5**: run one `embed()`
13237/// under a per-call deadline. A hung (non-panicking) embed would otherwise
13238/// park a projection worker forever — the EU-5f `catch_unwind` only catches
13239/// *panics*. On timeout we return `RuntimeEmbedderError::Timeout`, which the
13240/// caller's existing retry/failure path already handles.
13241///
13242/// Cancellation follows Invariant 5 exactly: the embed runs on a detached
13243/// thread that is allowed to *finish + discard* its result — never aborted
13244/// mid-call (there is no safe thread-cancel API). The caller (the projection
13245/// worker) holds `embed_serialize` across this call, but DROPS it the moment
13246/// this returns — including on timeout — so the abandoned detached thread
13247/// runs lock-free and a hung embed can neither hold the serialization guard
13248/// forever nor deadlock the pool. (The commit happens later, outside this
13249/// call, under the separate `commit_gate`.)
13250///
13251/// Panic-transparent: if `embed()` panics, the panic payload is captured on
13252/// the watchdog thread and resumed on the worker thread, so the existing
13253/// batch-level `catch_unwind` records `ProjectionPanic` exactly as before.
13254///
13255/// `live` counts embed threads currently alive: incremented before the spawn
13256/// and decremented by the thread when it finishes (even if its result was
13257/// abandoned on timeout). The caller reads it to bound the abandoned-thread
13258/// leak via the circuit breaker.
13259fn embed_with_watchdog(
13260 embedder: &Arc<dyn Embedder>,
13261 body: &str,
13262 timeout: Duration,
13263 live: &Arc<AtomicU64>,
13264) -> Result<Vec<f32>, RuntimeEmbedderError> {
13265 let (tx, rx) = mpsc::channel();
13266 let embedder = Arc::clone(embedder);
13267 let body = body.to_string();
13268 // Count this embed thread as live before spawning; the thread decrements
13269 // when it finishes, whether or not its result is still wanted.
13270 live.fetch_add(1, Ordering::Relaxed);
13271 let live_thread = Arc::clone(live);
13272 thread::spawn(move || {
13273 let outcome =
13274 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| embedder.embed(&body)));
13275 // The receiver may already be gone (this call timed out): an async
13276 // channel send never blocks, and a send to a dropped receiver is a
13277 // no-op error we deliberately ignore — the result is discarded.
13278 let _ = tx.send(outcome);
13279 live_thread.fetch_sub(1, Ordering::Relaxed);
13280 });
13281 match rx.recv_timeout(timeout) {
13282 Ok(Ok(result)) => result,
13283 Ok(Err(panic_payload)) => std::panic::resume_unwind(panic_payload),
13284 Err(mpsc::RecvTimeoutError::Timeout) => Err(RuntimeEmbedderError::Timeout),
13285 // The watchdog thread dropped its sender without sending — should not
13286 // happen (panics are captured above), but treat as a failed embed so
13287 // the retry/failure path engages rather than silently succeeding.
13288 Err(mpsc::RecvTimeoutError::Disconnected) => Err(RuntimeEmbedderError::Failed {
13289 message: "embed watchdog thread dropped its result channel".to_string(),
13290 }),
13291 }
13292}
13293
13294/// Batch sibling of [`embed_with_watchdog`]: run ONE `embed_batch` on a detached,
13295/// timeout-bounded thread. Same Invariant-5 cancellation contract (the thread is
13296/// allowed to finish + discard on timeout, never aborted mid-call), same
13297/// panic-transparency (a panic is resumed on the caller so the worker's batch-level
13298/// `catch_unwind` records `ProjectionPanic`), same `live` accounting (one batch
13299/// thread = one live embed, bounding the abandoned-thread leak via the breaker).
13300fn embed_batch_with_watchdog(
13301 embedder: &Arc<dyn Embedder>,
13302 bodies: &[String],
13303 timeout: Duration,
13304 live: &Arc<AtomicU64>,
13305) -> Result<Vec<Vec<f32>>, RuntimeEmbedderError> {
13306 let (tx, rx) = mpsc::channel();
13307 let embedder = Arc::clone(embedder);
13308 let bodies = bodies.to_vec();
13309 live.fetch_add(1, Ordering::Relaxed);
13310 let live_thread = Arc::clone(live);
13311 thread::spawn(move || {
13312 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
13313 let refs: Vec<&str> = bodies.iter().map(String::as_str).collect();
13314 embedder.embed_batch(&refs)
13315 }));
13316 let _ = tx.send(outcome);
13317 live_thread.fetch_sub(1, Ordering::Relaxed);
13318 });
13319 match rx.recv_timeout(timeout) {
13320 Ok(Ok(result)) => result,
13321 Ok(Err(panic_payload)) => std::panic::resume_unwind(panic_payload),
13322 Err(mpsc::RecvTimeoutError::Timeout) => Err(RuntimeEmbedderError::Timeout),
13323 Err(mpsc::RecvTimeoutError::Disconnected) => Err(RuntimeEmbedderError::Failed {
13324 message: "embed batch watchdog thread dropped its result channel".to_string(),
13325 }),
13326 }
13327}
13328
13329fn run_projection_job(shared: &ProjectionRuntimeShared, job: &ProjectionJob) -> ProjectionOutcome {
13330 // 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — an ABSENT embedder is an
13331 // ENVIRONMENT fact, not an embed failure, and it CANNOT appear mid-job:
13332 // `ProjectionRuntimeShared::embedder` is fixed for the whole session. So for a
13333 // NODE row the whole retry ladder (0 + 1 + 4 + 16 s) can only reach a
13334 // conclusion that was already knowable at entry — answer it NOW, with the
13335 // NON-TERMINAL `Deferred`: no audit row, no terminal, and therefore a write
13336 // the next live-embedder session can still recover.
13337 //
13338 // That is codex's finding. With the kind already ENROLLED, the shipped
13339 // `'failed'` terminal was PERMANENT (nothing reopens one, and nothing should:
13340 // re-enqueueing one would loop a genuinely-failing row forever), so the write
13341 // was lost while `dense_readiness` read `ready`.
13342 //
13343 // `projection_dispatcher_loop` already declines to dispatch node jobs in a
13344 // no-embedder session — it must, or the still-pending row would be re-scanned
13345 // in a hot loop. This check is the LOCAL backstop for the same invariant:
13346 // whatever reaches a worker with no embedder must not be TERMINATED. Keeping
13347 // the invariant beside the code that would otherwise write the terminal is
13348 // what makes it hold if that dispatcher-side filter is ever loosened.
13349 //
13350 // EDGE rows deliberately fall THROUGH to the shipped path, ladder and all.
13351 // `'edge_fact'` is auto-registered by the edge write itself, UN-gated on the
13352 // embedder (`project_canonical_edge_row`, G11 — see the note in
13353 // `enrol_batch_vector_kinds`), so an edge body written with no embedder is
13354 // outstanding the moment it lands. Deferring it would leave `drain` and
13355 // `excise_source` returning `EngineError::Scheduler` on paths with nothing to
13356 // do with the dense arm (MEASURED: 4 shipped tests across
13357 // `tc31_source_id_on_every_hit`, `provenance_mandatory` and
13358 // `multidoc_extractor_provenance`). Making edges recoverable needs their
13359 // enrolment gated the way fix-2 gated node kinds — reported as OOS-13, and
13360 // outside codex's finding, which is the node path.
13361 //
13362 // Their LADDER is left alone for a second, separately MEASURED reason:
13363 // shortening it makes the worker's terminal-commit land while a caller's own
13364 // write is still open, which used to trip the governed write-race. Measured on
13365 // `consolidate_provider` under 6-way concurrency: 0/48 failures with the
13366 // ladder, 8/48 without. Left byte-for-byte as shipped; the ladder length is
13367 // reported as OOS-17 rather than newly exposed by a fix round.
13368 //
13369 // 0.8.20 Slice 21 (TC-57) — this note used to name that race
13370 // `SQLITE_BUSY_SNAPSHOT` and call it PRE-EXISTING. Both are corrected: the
13371 // characterized mechanism is plain `SQLITE_BUSY` (5) on a read→write lock
13372 // PROMOTION, with the busy handler invoked ZERO times (SQLite skips it for
13373 // deadlock avoidance), so no `busy_timeout` could absorb it;
13374 // `SQLITE_BUSY_SNAPSHOT` (517) is only a second, narrower exit of the same
13375 // shape. And the race is FIXED — `commit_batch` now takes `BEGIN IMMEDIATE`
13376 // (see the note there), so the governed path never promotes. The 0/48-vs-8/48
13377 // measurement above stands as the reason not to shorten the ladder, but it is
13378 // no longer load-bearing for correctness of the governed write path.
13379 if shared.embedder.is_none() && job.kind != EDGE_FACT_KIND {
13380 return ProjectionOutcome::Deferred;
13381 }
13382 // PR-9 — embed circuit breaker (see `embed_circuit_open`). Once abandoned
13383 // (timed-out) embed threads have piled up to the threshold the embedder is
13384 // treated as broken; fail subsequent jobs fast WITHOUT attempting an embed,
13385 // so a wedged embedder cannot keep leaking abandoned watchdog threads. This
13386 // entry check is the fast path; the latch decision itself is made under the
13387 // embed guard below (race-free against other workers).
13388 if shared.embed_circuit_open.load(Ordering::Relaxed) {
13389 return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: "EmbedderError" };
13390 }
13391 let delays = shared.retry_delays_ms.lock().map(|delays| delays.clone()).unwrap_or_default();
13392 let mut last_code = "EmbedderError";
13393 for (attempt, delay_ms) in std::iter::once(0_u64).chain(delays.iter().copied()).enumerate() {
13394 if attempt > 0 {
13395 if shared.state.lock().map(|state| state.stopping).unwrap_or(true) {
13396 return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code };
13397 }
13398 thread::sleep(Duration::from_millis(delay_ms));
13399 }
13400 // PR-9 — re-check the breaker on every attempt, not just at entry:
13401 // another worker (or an earlier attempt of this job) may have latched
13402 // it while we were sleeping between retries. Bail before spawning yet
13403 // another timeout-bound watchdog thread, so the abandoned-thread leak
13404 // stays bounded even on the multi-retry path.
13405 if shared.embed_circuit_open.load(Ordering::Relaxed) {
13406 return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code };
13407 }
13408 // PR-9 / ADR-0.6.0 Invariant 5 — every embed runs under the per-call
13409 // watchdog deadline so a hung embed surfaces Timeout instead of
13410 // parking this worker forever.
13411 let embed_timeout = Duration::from_millis(shared.embed_timeout_ms.load(Ordering::Relaxed));
13412 let vector = match shared.embedder.as_ref() {
13413 Some(embedder) => {
13414 // PR-9 — serialize the embed call engine-side (see
13415 // `embed_serialize`): the shared embedder is invoked one call
13416 // at a time, for SAFETY with arbitrary caller-supplied
13417 // embedders (throughput is ~neutral on the candle default).
13418 // The guard is held across the watchdog call and released
13419 // here, so commit/IO below stays parallel and a timed-out
13420 // embed frees it. The guard owns no data; a panic-resumed
13421 // embed poisons it, so we recover the inner guard rather than
13422 // wedge the whole pool.
13423 let _embed_permit =
13424 shared.embed_serialize.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13425 // PR-9 — breaker decision, made WITH the guard held so it is
13426 // race-free against other workers: if abandoned embed threads
13427 // from earlier timeouts have piled up to the threshold, latch
13428 // the breaker and fail fast WITHOUT spawning another one. The
13429 // live count is checked here (also covers a breaker latched by
13430 // another worker while we were queued on the lock), bounding
13431 // the abandoned-thread leak to ~threshold regardless of whether
13432 // the embedder hangs always or only intermittently.
13433 let threshold = shared.embed_circuit_threshold.load(Ordering::Relaxed);
13434 if shared.embed_circuit_open.load(Ordering::Relaxed)
13435 || (threshold != 0
13436 && shared.live_embed_threads.load(Ordering::Relaxed) >= threshold)
13437 {
13438 shared.embed_circuit_open.store(true, Ordering::Relaxed);
13439 return ProjectionOutcome::Failure {
13440 cursor: job.cursor,
13441 failure_code: last_code,
13442 };
13443 }
13444 match embed_with_watchdog(
13445 embedder,
13446 &job.body,
13447 embed_timeout,
13448 &shared.live_embed_threads,
13449 ) {
13450 Ok(vector) => vector,
13451 Err(RuntimeEmbedderError::Timeout) => {
13452 // The embed thread is now abandoned (still counted in
13453 // live_embed_threads until it returns); the breaker
13454 // check above caps how many can accumulate.
13455 last_code = "EmbedderError";
13456 continue;
13457 }
13458 Err(RuntimeEmbedderError::Failed { .. }) => {
13459 last_code = "EmbedderError";
13460 continue;
13461 }
13462 }
13463 }
13464 None => {
13465 last_code = "EmbedderNotConfiguredError";
13466 continue;
13467 }
13468 };
13469
13470 if u32::try_from(vector.len()).unwrap_or(u32::MAX) != shared.embedder_identity.dimension {
13471 last_code = "EmbedderDimensionMismatchError";
13472 continue;
13473 }
13474
13475 let blob = encode_vector_blob(&vector);
13476 // EU-5a2 mean-centering apply path (projection write side). The
13477 // f32 BLOB persisted is ALWAYS un-centered; `bin_blob` carries
13478 // the (possibly centered) f32 fed to `vec_quantize_binary`. The
13479 // centering decision is finalized in `commit_projection_outcomes`
13480 // where the writer connection is in-hand and the read of
13481 // `_fathomdb_embedder_profiles.mean_vec` is in the same tx as
13482 // the INSERT. NoopEmbedder (EU-5a2's only live identity) is not
13483 // MC-required, so `bin_blob == blob` throughout EU-5a2.
13484 let bin_blob = blob.clone();
13485 return ProjectionOutcome::Success {
13486 cursor: job.cursor,
13487 kind: job.kind.clone(),
13488 blob,
13489 bin_blob,
13490 };
13491 }
13492
13493 ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code }
13494}
13495
13496/// 0.8.20 Slice 20 fix-1 (codex §9 [P2]) — the ONE definition of "a canonical
13497/// EDGE row the vector pipeline still owes an embed for".
13498///
13499/// Two call sites must agree on this predicate and had drifted:
13500///
13501/// - [`next_pending_projection_jobs`] — the SCHEDULER, and therefore the
13502/// authority on what will actually be embedded. It joins
13503/// `_fathomdb_vector_kinds` on `'edge_fact'`, so an edge body is only ever
13504/// scheduled when that kind is registered.
13505/// - [`connection_has_pending_projection_work`] — the PROBE behind
13506/// `drain`/`wait_for_idle` and, since this slice, `dense_readiness`. It
13507/// omitted that join.
13508///
13509/// The consequence of the drift: a live edge body written while `edge_fact` was
13510/// not a registered vector kind (e.g. edges carried forward from before the G11
13511/// edge-vector pipeline, which is what auto-registers the kind) counted as
13512/// outstanding work the scheduler would NEVER take. `dense_readiness` reported
13513/// `embedding` forever and `drain` could never report idle — both the mirror
13514/// image of R-20-DR's property. Building both edge arms from this one fragment
13515/// makes a repeat drift unrepresentable.
13516///
13517/// Emits the `FROM`/`JOIN` clauses plus the shared `WHERE` predicates, with the
13518/// edge table aliased `ce` and the projection terminal aliased `pt`; `now_idx`
13519/// is the 1-based bind index of the `:now` seam that [`edge_validity_sql`]
13520/// consumes. Callers may append further `AND` predicates.
13521///
13522/// **The one predicate deliberately NOT shared** is the scheduler's
13523/// `write_cursor > :cursor` watermark filter, which the scheduler appends and
13524/// the probe must not: per the G11 (Slice 15) fix-1 note on the probe, the
13525/// probe has to see edge bodies left un-projected BELOW the watermark when the
13526/// engine closed mid-flight, or `drain` would report idle with edge vectors
13527/// still missing on reopen. That asymmetry is intentional and load-bearing; the
13528/// row-eligibility predicates above are not, and are shared.
13529fn pending_edge_projection_from_where(now_idx: usize) -> String {
13530 format!(
13531 "FROM canonical_edges ce
13532 JOIN _fathomdb_vector_kinds
13533 ON _fathomdb_vector_kinds.kind = 'edge_fact'
13534 LEFT JOIN _fathomdb_projection_terminal pt
13535 ON pt.write_cursor = ce.write_cursor
13536 WHERE ce.body IS NOT NULL
13537 AND ce.superseded_at IS NULL{}
13538 AND pt.write_cursor IS NULL",
13539 edge_validity_sql("ce", now_idx)
13540 )
13541}
13542
13543/// The SCHEDULER's scan: the next `max_jobs` pending projection jobs in
13544/// `write_cursor` order.
13545///
13546/// `dense_arm_live` is `ProjectionRuntimeShared::embedder.is_some()`, read once
13547/// per dispatcher because it is fixed for the session's lifetime.
13548///
13549/// # fix-5 (codex §9 round 4 [P1]) — why the node exclusion is IN the SQL
13550///
13551/// With no live embedder a NODE job can only come back
13552/// [`ProjectionOutcome::Deferred`], which by design records no terminal (fix-4),
13553/// so dispatching one would re-fetch the SAME cursor forever: a hot loop for the
13554/// whole life of the session. fix-4 suppressed that by filtering the vector this
13555/// function RETURNS — i.e. after the `ORDER BY … LIMIT`, so the `LIMIT` still
13556/// applied to the UNFILTERED set. More than `PROJECTION_SCAN_FETCH` pending node
13557/// rows ordered before a pending EDGE body therefore filled the entire window
13558/// with jobs that were then all dropped, the dispatcher went back to sleep with
13559/// `pending_scan` already consumed, and the edge body was never scheduled at all
13560/// — permanently, since those node rows stay pending for the session's life. The
13561/// exclusion belongs here, where the `LIMIT` applies to the ALREADY-FILTERED set
13562/// and a later edge job is always reachable.
13563///
13564/// Edges are NOT excluded: `'edge_fact'` is auto-registered by the edge write
13565/// itself, un-gated on the embedder (`project_canonical_edge_row`, G11, and the
13566/// note in [`Engine::enrol_batch_vector_kinds`]), so an edge body still
13567/// TERMINATES on an absent embedder exactly as it has shipped since G11. Making
13568/// edges recoverable too needs that enrolment gated first (OOS-13).
13569fn next_pending_projection_jobs(
13570 connection: &Connection,
13571 in_flight: &BTreeSet<u64>,
13572 max_jobs: usize,
13573 dense_arm_live: bool,
13574) -> rusqlite::Result<Vec<ProjectionJob>> {
13575 if max_jobs == 0 {
13576 return Ok(Vec::new());
13577 }
13578 let cursor = load_projection_cursor(connection)?;
13579 // Over-fetch by `in_flight.len()` so the post-filter still returns
13580 // up to `max_jobs` after skipping cursors already in-flight.
13581 let sql_limit = max_jobs.saturating_add(in_flight.len()).min(256);
13582 // G11 (Slice 15) — UNION extends the projection queue to include edge bodies.
13583 // Edge bodies use kind `'edge_fact'` so `resolve_source_type` maps them to
13584 // `source_type = 'edge_fact'` in `vector_default` (partition correctness).
13585 // The UNION is ordered by write_cursor so projection proceeds in
13586 // insertion order across nodes and edges.
13587 //
13588 // fix-5 [P1]: with no dense arm the NODE arm is omitted outright rather than
13589 // predicated false, so the planner never walks it. The edge arm keeps both
13590 // binds (`?1` the cursor, `?2` the `:now` seam), so the bound parameter set
13591 // is identical either way.
13592 let node_arm = if dense_arm_live {
13593 "SELECT canonical_nodes.write_cursor AS write_cursor,
13594 canonical_nodes.kind AS kind,
13595 canonical_nodes.body AS body
13596 FROM canonical_nodes
13597 JOIN _fathomdb_vector_kinds
13598 ON _fathomdb_vector_kinds.kind = canonical_nodes.kind
13599 LEFT JOIN _fathomdb_projection_terminal
13600 ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
13601 WHERE canonical_nodes.write_cursor > ?1
13602 AND _fathomdb_projection_terminal.write_cursor IS NULL
13603
13604 UNION ALL
13605
13606 "
13607 } else {
13608 ""
13609 };
13610 let sql = format!(
13611 "SELECT write_cursor, kind, body FROM (
13612 {node_arm}SELECT ce.write_cursor AS write_cursor,
13613 'edge_fact' AS kind,
13614 ce.body AS body
13615 {edge_arm}
13616 AND ce.write_cursor > ?1
13617 ) ORDER BY write_cursor
13618 LIMIT {sql_limit}",
13619 // fix-1 [P2]: the edge arm's row-eligibility predicates come from the
13620 // shared fragment so this and `connection_has_pending_projection_work`
13621 // cannot disagree about what is outstanding. The `write_cursor > ?1`
13622 // watermark is appended here and ONLY here — see the fragment's doc.
13623 // TC-33: `?1` is the projection cursor ⇒ the edge `:now` binds at `?2`.
13624 edge_arm = pending_edge_projection_from_where(2)
13625 );
13626 let mut statement = connection.prepare_cached(&sql)?;
13627 let rows = statement.query_map(params![cursor, current_epoch_seconds()], |row| {
13628 Ok(ProjectionJob { cursor: row.get(0)?, kind: row.get(1)?, body: row.get(2)? })
13629 })?;
13630 let mut jobs = Vec::with_capacity(max_jobs);
13631 for row in rows {
13632 let job = row?;
13633 if in_flight.contains(&job.cursor) {
13634 continue;
13635 }
13636 jobs.push(job);
13637 if jobs.len() >= max_jobs {
13638 break;
13639 }
13640 }
13641 Ok(jobs)
13642}
13643
13644fn database_has_pending_projection_work(path: &Path) -> rusqlite::Result<bool> {
13645 let connection = open_runtime_connection(path)?;
13646 connection_has_pending_projection_work(&connection)
13647}
13648
13649/// 0.8.20 Slice 20 (R-20-DR) — the body of
13650/// [`database_has_pending_projection_work`], lifted so it can also run on a
13651/// connection the caller ALREADY holds (the engine's own connection, inside
13652/// [`Engine::read_projections`]) instead of opening a runtime connection from a
13653/// path. Both callers run the same two arms and the same predicates — which is
13654/// the point. Readiness and `drain`/`wait_for_idle` must key off ONE definition
13655/// of "outstanding embed", or readiness could report `ready` for work `drain`
13656/// still waits on.
13657///
13658/// fix-1 (codex §9 [P2]) — the edge arm is no longer a hand-copied mirror of
13659/// the scheduler's: both are built from
13660/// [`pending_edge_projection_from_where`]. The copy had lost the
13661/// `_fathomdb_vector_kinds` join, so this probe reported permanent pending work
13662/// for edge bodies the scheduler would never schedule. That was PRE-EXISTING —
13663/// it reached `Engine::drain` through `wait_for_idle` before readiness existed.
13664fn connection_has_pending_projection_work(connection: &Connection) -> rusqlite::Result<bool> {
13665 let cursor = load_projection_cursor(connection)?;
13666 // Check canonical_nodes for un-projected work.
13667 let has_node_work: bool = connection
13668 .query_row(
13669 "SELECT 1
13670 FROM canonical_nodes
13671 JOIN _fathomdb_vector_kinds ON _fathomdb_vector_kinds.kind = canonical_nodes.kind
13672 LEFT JOIN _fathomdb_projection_terminal
13673 ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
13674 WHERE canonical_nodes.write_cursor > ?1
13675 AND _fathomdb_projection_terminal.write_cursor IS NULL
13676 LIMIT 1",
13677 [cursor],
13678 |_row| Ok(true),
13679 )
13680 .or_else(|err| match err {
13681 rusqlite::Error::QueryReturnedNoRows => Ok(false),
13682 _ => Err(err),
13683 })?;
13684 if has_node_work {
13685 return Ok(true);
13686 }
13687 // G11 (Slice 15) fix-1 [P2] — also check canonical_edges for edge bodies
13688 // that were not projected before the engine closed. Without this check,
13689 // drain() returns idle while edge vectors remain unembedded on reopen.
13690 // fix-31 [P2]: exclude superseded edges from the pending check so the
13691 // scheduler does not pick up stale tombstoned rows as projection work.
13692 // 0.8.12 Slice A (R-CON-2 named default-ON blocker; Slice-20 codex §9
13693 // [P2]) — also exclude t_invalid-excluded (recency-consolidated) edges,
13694 // mirroring `next_pending_projection_jobs`'s edge arm. Required: without
13695 // this mirror, a rebuild-truncated t_invalid edge that
13696 // `next_pending_projection_jobs` now correctly skips would never gain a
13697 // `_fathomdb_projection_terminal` row, so this probe would flag it as
13698 // phantom-pending forever and `drain()`/`wait_for_idle` would hang.
13699 // Slice-20 fix-1 [P2]: the mirror is now STRUCTURAL — the arm is built from
13700 // `pending_edge_projection_from_where`, the same fragment the scheduler
13701 // uses — because the hand-copied mirror had already lost the
13702 // `_fathomdb_vector_kinds` join and produced exactly the phantom-pending
13703 // hang described above for edge bodies under an unregistered `edge_fact`.
13704 connection
13705 .query_row(
13706 // TC-33: no other parameter here ⇒ the edge `:now` binds at `?1`.
13707 // No `write_cursor > cursor` filter — see the fragment's doc for
13708 // why the probe deliberately looks BELOW the watermark too.
13709 &format!("SELECT 1 {} LIMIT 1", pending_edge_projection_from_where(1)),
13710 params![current_epoch_seconds()],
13711 |_row| Ok(true),
13712 )
13713 .or_else(|err| match err {
13714 rusqlite::Error::QueryReturnedNoRows => Ok(false),
13715 _ => Err(err),
13716 })
13717}
13718
13719/// 0.8.20 Slice 20 (R-20-DR) — the `dense_readiness` of the `searchable→vector`
13720/// projection, DERIVED. There is no stored flag and this feature adds no schema
13721/// step or `MIGRATIONS` entry; later unrelated migrations do not affect that
13722/// property.
13723///
13724/// **Why derived is the design, not a shortcut.** §4.1 invariant 1 requires
13725/// `{ vector-insert ∧ dense_readiness := ready }` to be ONE transaction, with a
13726/// torn `ready`-without-vector FORBIDDEN. A stored flag is precisely the thing
13727/// that can tear. Deriving it makes the invariant true **by construction**:
13728/// readiness is a pure function of state that
13729/// [`commit_projection_outcomes`] already writes inside a single transaction —
13730/// the `vector_default` / `_fathomdb_vector_rows` INSERTs, the
13731/// `_fathomdb_projection_terminal` row ([`record_projection_terminal`]) and the
13732/// readiness watermark ([`advance_projection_cursor`], which only ever steps
13733/// over cursors that ALREADY hold a terminal) all commit together or not at all.
13734/// So `ready` cannot be observed before the vector is durable, and the only
13735/// reachable torn state is the tolerated one (`embedding` with the vector
13736/// absent — the dense arm simply reads as partial).
13737///
13738/// It reuses the EXACT predicate `drain`/`wait_for_idle` use
13739/// ([`connection_has_pending_projection_work`]), so "readiness is `ready`" and
13740/// "`drain` reports idle" cannot disagree.
13741///
13742/// **Scope note (honest boundary).** The predicate is corpus-wide, not
13743/// per-attribute, because Slice 15d persists the `searchable→vector` sub-object
13744/// but DEFERS building any per-attribute embedding (`ProjectionDelta::deferred`)
13745/// — every declared vector projection is served by the one engine vector
13746/// pipeline, so per-projection scoping has no distinct meaning yet. A stored
13747/// column would not have been more specific; it would only have been tearable.
13748/// When per-attribute embedding lands, this function is where the scoping goes.
13749///
13750/// **Failure boundary.** A row whose embed FAILED terminally records a `failed`
13751/// terminal (no vector row), so it stops being outstanding and readiness returns
13752/// to `ready`. That is the correct reading of a two-member vocabulary — the row
13753/// will never embed, so reporting `embedding` forever would be a lie — and
13754/// failures stay separately observable through the `projection_failures`
13755/// collection. It is the one case where a `ready` corpus can lack a vector row,
13756/// and it is NOT a torn write: no `up_to_date` terminal exists for it.
13757fn derive_dense_readiness(connection: &Connection) -> Result<DenseReadiness, EngineError> {
13758 if connection_has_pending_projection_work(connection).map_err(|_| EngineError::Storage)? {
13759 Ok(DenseReadiness::Embedding)
13760 } else {
13761 Ok(DenseReadiness::Ready)
13762 }
13763}
13764
13765struct CanonicalNodeRow {
13766 cursor: u64,
13767 kind: String,
13768 body: String,
13769 row_kind: RowKind,
13770 /// fix-2 [P2] — whether this row is in the attribute projection's row set
13771 /// (`state = 'active' AND superseded_at IS NULL`, the exact `backfill_attribute`
13772 /// predicate). A projector-replay rebuild uses this to gate the attribute
13773 /// projection so it does not re-surface a pending / superseded node's values.
13774 /// Node-FTS / vector shadows are rebuilt for every row (their stale versions
13775 /// are excluded by the read-side lifecycle join, unchanged from before).
13776 attr_projected: bool,
13777}
13778
13779/// 0.8.0 Slice 5 (G1) — re-tokenize `search_index` from the canonical source
13780/// rows after the step-11 tokenizer-default upgrade drops + recreates the FTS5
13781/// virtual table. Projection-only: it reads `canonical_nodes` (the source of
13782/// truth, untouched) and rewrites the FTS shadow; it performs **no**
13783/// source-record migration. Every canonical node already carries an FTS row at
13784/// write time (the projection-time INSERT is unconditional), so reinserting
13785/// every node exactly reproduces the prior index content under the new
13786/// tokenizer. Runs in a single transaction on the writer connection before
13787/// readers spawn.
13788///
13789/// Crash-retryable (fix-1): the reindex and its durable completion marker
13790/// (`SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY` in `_fathomdb_open_state`)
13791/// commit together in ONE `BEGIN IMMEDIATE…COMMIT`. A crash before the commit
13792/// rolls both back, leaving no marker; the next open re-runs. A crash after
13793/// the commit finds the marker present and skips. Idempotent.
13794fn reproject_search_index_after_tokenizer_upgrade(connection: &Connection) -> rusqlite::Result<()> {
13795 let rows = canonical_node_rows(connection)?;
13796 connection.execute_batch("BEGIN IMMEDIATE")?;
13797 let result = (|| {
13798 // 0.8.20 Slice 5a (R-20-E1) — registry-driven: re-tokenize EVERY
13799 // node-FTS projection, not just `search_index`. `search_index_v2` uses
13800 // the SAME tokenizer (`porter unicode61 remove_diacritics 2`), so it is
13801 // equally invalidated by a tokenizer-default upgrade; before this slice
13802 // it was neither cleared nor re-tokenized here. Edge FTS is out of scope
13803 // for this open-path repair (it postdates the step-11 upgrade and is
13804 // rebuilt by `rebuild_projections`).
13805 truncate_row_projections_in(connection, &[ProjectionClass::NodeFts])?;
13806 for row in &rows {
13807 project_canonical_node_row(
13808 connection,
13809 row.cursor,
13810 &row.kind,
13811 &row.body,
13812 row.row_kind,
13813 ProjectionPass::FtsOnly,
13814 // FtsOnly never touches the attribute store (predates step 24), so
13815 // `node_active` is inert here; forward the row's flag anyway (it is
13816 // the backfill's active-and-non-superseded predicate) so the field
13817 // has a reader in every build configuration.
13818 row.attr_projected,
13819 )?;
13820 }
13821 connection.execute(
13822 "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
13823 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
13824 params![SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY, "1"],
13825 )?;
13826 Ok(())
13827 })();
13828 match result {
13829 Ok(()) => connection.execute_batch("COMMIT"),
13830 Err(err) => {
13831 let _ = connection.execute_batch("ROLLBACK");
13832 Err(err)
13833 }
13834 }
13835}
13836
13837/// 0.8.0 Slice 5 (G1) fix-1 — has the post-tokenizer-upgrade re-tokenization
13838/// committed durably on this DB? Keys off the
13839/// `SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY` row written inside the reindex
13840/// transaction; its absence on a v11 DB means the reindex never committed
13841/// (fresh-after-step-11 or crash-in-window) and must (re-)run.
13842///
13843/// A MISSING `_fathomdb_open_state` table is reported as "complete" (skip the
13844/// reproject): that table is created by migration step 1, so its absence means
13845/// the DB never ran our migrations (e.g. a synthetic DB whose `user_version`
13846/// was stamped to 11 by hand, or a legacy/foreign shape). Such DBs are
13847/// rejected by the downstream embedder-identity/integrity probes; the reproject
13848/// must not run — and must not mask those errors — on them. On a genuinely
13849/// migrated DB the table always exists, so the crash-repair path is unaffected.
13850fn search_index_tokenizer_reproject_complete(connection: &Connection) -> rusqlite::Result<bool> {
13851 match connection.query_row(
13852 "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
13853 [SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY],
13854 |row| row.get::<_, String>(0),
13855 ) {
13856 Ok(value) => Ok(value == "1"),
13857 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
13858 Err(rusqlite::Error::SqliteFailure(_, Some(ref message)))
13859 if message.contains("no such table") =>
13860 {
13861 Ok(true)
13862 }
13863 Err(err) => Err(err),
13864 }
13865}
13866
13867/// 0.8.20 Slice 15c (TC-33) fix-6 — has the one-time edge-vector prune committed
13868/// durably on this DB? Keys off the [`EDGE_VECTOR_PRUNE_MARKER_KEY`] row written
13869/// inside the prune transaction; its absence means the prune never ran (a DB
13870/// upgraded before this fix shipped, or a crash between the step-23 commit and
13871/// the prune commit) and must (re-)run.
13872///
13873/// A MISSING `_fathomdb_open_state` table is reported as "complete" (skip the
13874/// prune) — that table is created by migration step 1, so its absence means the
13875/// DB never ran our migrations (a synthetic/foreign shape rejected downstream);
13876/// the prune must not run, and must not mask those errors, on it. Mirrors
13877/// [`search_index_tokenizer_reproject_complete`].
13878fn edge_vector_prune_complete(connection: &Connection) -> rusqlite::Result<bool> {
13879 match connection.query_row(
13880 "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
13881 [EDGE_VECTOR_PRUNE_MARKER_KEY],
13882 |row| row.get::<_, String>(0),
13883 ) {
13884 Ok(value) => Ok(value == "1"),
13885 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
13886 Err(rusqlite::Error::SqliteFailure(_, Some(ref message)))
13887 if message.contains("no such table") =>
13888 {
13889 Ok(true)
13890 }
13891 Err(err) => Err(err),
13892 }
13893}
13894
13895/// 0.8.20 Slice 15c (TC-33) fix-6 — delete every `vector_default` (vec0) row that
13896/// has NO `_fathomdb_vector_rows` sidecar entry, then record the durable
13897/// completion marker, all in one `BEGIN IMMEDIATE` transaction (crash-retryable:
13898/// a crash before COMMIT leaves no marker and the next open re-runs).
13899///
13900/// A vec0 row and its sidecar row are written and deleted TOGETHER (same
13901/// transaction) on every steady-state path, so a sidecar-less vec0 row is ONLY
13902/// ever produced by the step-23 recreate, which drops the edge rows and their
13903/// sidecar entries but cannot reach the engine-created vec0 table. So this
13904/// targets exactly the dropped edges' orphans and touches NOTHING on a healthy
13905/// corpus. Node vec0 rows keep their sidecar entry, so they are never pruned —
13906/// node recall is unaffected.
13907///
13908/// The orphans are gathered with plain scans (both proven vec0 forms — a full
13909/// `SELECT rowid FROM vector_default` and per-`rowid` `DELETE`) and diffed in
13910/// Rust, rather than relying on a compound `DELETE ... WHERE rowid NOT IN (...)`
13911/// over the virtual table.
13912fn prune_orphaned_edge_vectors(connection: &Connection) -> rusqlite::Result<()> {
13913 connection.execute_batch("BEGIN IMMEDIATE")?;
13914 let result = (|| {
13915 let sidecar: std::collections::HashSet<i64> = {
13916 let mut statement =
13917 connection.prepare("SELECT write_cursor FROM _fathomdb_vector_rows")?;
13918 let rows = statement.query_map([], |row| row.get::<_, i64>(0))?;
13919 let mut set = std::collections::HashSet::new();
13920 for r in rows {
13921 set.insert(r?);
13922 }
13923 set
13924 };
13925 let vec_rowids: Vec<i64> = {
13926 let mut statement = connection.prepare("SELECT rowid FROM vector_default")?;
13927 let rows = statement.query_map([], |row| row.get::<_, i64>(0))?;
13928 let mut out = Vec::new();
13929 for r in rows {
13930 out.push(r?);
13931 }
13932 out
13933 };
13934 for rowid in vec_rowids {
13935 if !sidecar.contains(&rowid) {
13936 // vec0 rowid IS the canonical write_cursor; delete by rowid (the
13937 // proven vec0 delete form, as `prune_edge_projection_shadows`),
13938 // through the one TC-76-safe vec0-delete primitive.
13939 delete_vector_partition_row(connection, rowid)?;
13940 }
13941 }
13942 connection.execute(
13943 "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
13944 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
13945 params![EDGE_VECTOR_PRUNE_MARKER_KEY, "1"],
13946 )?;
13947 Ok(())
13948 })();
13949 match result {
13950 Ok(()) => connection.execute_batch("COMMIT"),
13951 Err(err) => {
13952 let _ = connection.execute_batch("ROLLBACK");
13953 Err(err)
13954 }
13955 }
13956}
13957
13958fn canonical_node_rows(connection: &Connection) -> rusqlite::Result<Vec<CanonicalNodeRow>> {
13959 // fix-2 [P2] — also read `state` + `superseded_at` so a replay rebuild can gate
13960 // the attribute projection to the backfill's row set. `attr_projected` mirrors
13961 // the exact `backfill_attribute` predicate (`state = 'active' AND
13962 // superseded_at IS NULL`): a NULL/foreign state is NOT 'active' and so is
13963 // excluded, identical to the SQL equality.
13964 let mut statement = connection.prepare(
13965 "SELECT write_cursor, kind, body, row_kind, state, superseded_at \
13966 FROM canonical_nodes ORDER BY write_cursor",
13967 )?;
13968 let rows = statement.query_map([], |row| {
13969 let state: Option<String> = row.get::<_, Option<String>>(4)?;
13970 let superseded_at: Option<i64> = row.get::<_, Option<i64>>(5)?;
13971 Ok(CanonicalNodeRow {
13972 cursor: row.get::<_, u64>(0)?,
13973 kind: row.get::<_, String>(1)?,
13974 body: row.get::<_, String>(2)?,
13975 row_kind: row_kind_from_column(&row.get::<_, String>(3)?),
13976 attr_projected: state.as_deref() == Some("active") && superseded_at.is_none(),
13977 })
13978 })?;
13979 rows.collect()
13980}
13981
13982/// 0.8.20 Slice 5a — inverse of [`RowKind::as_str`] for the stored
13983/// `canonical_nodes.row_kind` column. An unrecognized spelling degrades to
13984/// `Leaf`, the column DEFAULT and the shape every pre-EXP-S row carries; that
13985/// keeps a projector replay behavior-identical to the pre-registry rebuild,
13986/// which ignored `row_kind` entirely.
13987fn row_kind_from_column(value: &str) -> RowKind {
13988 match value {
13989 "coverage" => RowKind::Coverage,
13990 "graph" => RowKind::Graph,
13991 _ => RowKind::Leaf,
13992 }
13993}
13994
13995#[cfg(feature = "operator")]
13996fn hex_encode(bytes: &[u8]) -> String {
13997 let mut out = String::with_capacity(bytes.len() * 2);
13998 for byte in bytes {
13999 out.push(hex_nibble(byte >> 4));
14000 out.push(hex_nibble(byte & 0x0f));
14001 }
14002 out
14003}
14004
14005#[cfg(feature = "operator")]
14006fn hex_nibble(value: u8) -> char {
14007 match value {
14008 0..=9 => (b'0' + value) as char,
14009 10..=15 => (b'a' + value - 10) as char,
14010 _ => unreachable!(),
14011 }
14012}
14013
14014#[cfg(feature = "operator")]
14015fn physical_section(connection: &Connection, full: bool) -> Section {
14016 let mut findings = Vec::new();
14017 if let Err(err) = connection.query_row("PRAGMA page_count", [], |row| row.get::<_, i64>(0)) {
14018 findings.push(Finding {
14019 code: "E_CORRUPT_HEADER",
14020 stage: "PhysicalProbe",
14021 locator: locator_from_rusqlite_error(&err),
14022 doc_anchor: "design/recovery.md#header-malformed",
14023 detail: format!("page_count probe failed: {err}"),
14024 });
14025 }
14026 if full {
14027 match collect_integrity_check_findings(connection) {
14028 Ok(rows) => findings.extend(rows),
14029 Err(err) => findings.push(Finding {
14030 code: "E_CORRUPT_INTEGRITY_CHECK",
14031 stage: "IntegrityCheck",
14032 locator: locator_from_rusqlite_error(&err),
14033 doc_anchor: "design/recovery.md#integrity-check-full-findings",
14034 detail: format!("PRAGMA integrity_check failed: {err}"),
14035 }),
14036 }
14037 }
14038 if findings.is_empty() {
14039 Section::Clean
14040 } else {
14041 Section::Findings(findings)
14042 }
14043}
14044
14045#[cfg(feature = "operator")]
14046fn logical_section(connection: &Connection) -> Section {
14047 let mut findings = Vec::new();
14048 if let Err(err) = connection.query_row("PRAGMA schema_version", [], |row| row.get::<_, i64>(0))
14049 {
14050 findings.push(Finding {
14051 code: "E_CORRUPT_SCHEMA",
14052 stage: "SchemaProbe",
14053 locator: locator_from_rusqlite_error(&err),
14054 doc_anchor: "design/recovery.md#schema-inconsistent",
14055 detail: format!("schema_version probe failed: {err}"),
14056 });
14057 }
14058 match connection.query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)) {
14059 Ok(0) => findings.push(Finding {
14060 code: "E_CORRUPT_SCHEMA",
14061 stage: "SchemaProbe",
14062 locator: CorruptionLocator::MigrationStep { from: 0, to: 0 },
14063 doc_anchor: "design/recovery.md#schema-inconsistent",
14064 detail: "user_version is zero".to_string(),
14065 }),
14066 Ok(_) => {}
14067 Err(err) => findings.push(Finding {
14068 code: "E_CORRUPT_SCHEMA",
14069 stage: "SchemaProbe",
14070 locator: locator_from_rusqlite_error(&err),
14071 doc_anchor: "design/recovery.md#schema-inconsistent",
14072 detail: format!("user_version probe failed: {err}"),
14073 }),
14074 }
14075 if findings.is_empty() {
14076 Section::Clean
14077 } else {
14078 Section::Findings(findings)
14079 }
14080}
14081
14082#[cfg(feature = "operator")]
14083fn semantic_section(connection: &Connection) -> Section {
14084 match load_default_profile(connection) {
14085 Ok(_) => Section::Clean,
14086 Err(rusqlite::Error::QueryReturnedNoRows) => Section::Findings(vec![Finding {
14087 code: "E_CORRUPT_EMBEDDER_IDENTITY",
14088 stage: "EmbedderIdentity",
14089 locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
14090 doc_anchor: "design/recovery.md#embedder-identity-drift",
14091 detail: "default embedder profile row is missing".to_string(),
14092 }]),
14093 Err(err) => Section::Findings(vec![Finding {
14094 code: "E_CORRUPT_EMBEDDER_IDENTITY",
14095 stage: "EmbedderIdentity",
14096 locator: locator_from_rusqlite_error(&err),
14097 doc_anchor: "design/recovery.md#embedder-identity-drift",
14098 detail: format!("default embedder profile probe failed: {err}"),
14099 }]),
14100 }
14101}
14102
14103#[cfg(feature = "operator")]
14104fn collect_integrity_check_findings(connection: &Connection) -> rusqlite::Result<Vec<Finding>> {
14105 let mut statement = connection.prepare("PRAGMA integrity_check")?;
14106 let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
14107 let mut findings = Vec::new();
14108 for row in rows {
14109 let message = row?;
14110 if message == "ok" {
14111 continue;
14112 }
14113 findings.push(Finding {
14114 code: "E_CORRUPT_INTEGRITY_CHECK",
14115 stage: "IntegrityCheck",
14116 locator: CorruptionLocator::OpaqueSqliteError {
14117 sqlite_extended_code: rusqlite::ffi::SQLITE_CORRUPT,
14118 },
14119 doc_anchor: "design/recovery.md#integrity-check-full-findings",
14120 detail: message,
14121 });
14122 }
14123 Ok(findings)
14124}
14125
14126#[cfg(feature = "operator")]
14127fn locator_from_rusqlite_error(err: &rusqlite::Error) -> CorruptionLocator {
14128 let extended = err.sqlite_error().map(|inner| inner.extended_code).unwrap_or(0);
14129 CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: extended }
14130}
14131
14132fn open_runtime_connection(path: &Path) -> rusqlite::Result<Connection> {
14133 let connection = Connection::open(path)?;
14134 connection.pragma_update(None, "journal_mode", "WAL")?;
14135 // OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — `secure_delete=ON` at
14136 // EVERY open. The projection/vector-rewrite runtime connection performs
14137 // DELETEs (shadow-table rewrites), so its freed pages must be scrubbed too;
14138 // setting the pragma only on the writer left a GDPR-erasure leak here.
14139 connection.pragma_update(None, "secure_delete", "ON")?;
14140 Ok(connection)
14141}
14142
14143fn load_projection_cursor(connection: &Connection) -> rusqlite::Result<u64> {
14144 connection
14145 .query_row(
14146 "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
14147 [PROJECTION_CURSOR_KEY],
14148 |row| row.get::<_, String>(0),
14149 )
14150 .map(|value| value.parse::<u64>().unwrap_or(0))
14151 .or_else(|err| match err {
14152 rusqlite::Error::QueryReturnedNoRows => Ok(0),
14153 _ => Err(err),
14154 })
14155}
14156
14157fn store_projection_cursor(connection: &Connection, cursor: u64) -> rusqlite::Result<()> {
14158 connection.execute(
14159 "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
14160 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
14161 params![PROJECTION_CURSOR_KEY, cursor.to_string()],
14162 )?;
14163 Ok(())
14164}
14165
14166fn record_projection_terminal(
14167 connection: &Connection,
14168 cursor: u64,
14169 state: &str,
14170) -> rusqlite::Result<()> {
14171 connection.execute(
14172 "INSERT OR IGNORE INTO _fathomdb_projection_terminal(write_cursor, state) VALUES(?1, ?2)",
14173 params![cursor, state],
14174 )?;
14175 Ok(())
14176}
14177
14178fn terminal_state_for_cursor(
14179 connection: &Connection,
14180 cursor: u64,
14181) -> rusqlite::Result<Option<String>> {
14182 connection
14183 .query_row(
14184 "SELECT state FROM _fathomdb_projection_terminal WHERE write_cursor = ?1",
14185 [cursor],
14186 |row| row.get::<_, String>(0),
14187 )
14188 .map(Some)
14189 .or_else(|err| match err {
14190 rusqlite::Error::QueryReturnedNoRows => Ok(None),
14191 _ => Err(err),
14192 })
14193}
14194
14195fn advance_projection_cursor(connection: &Connection) -> rusqlite::Result<u64> {
14196 let mut cursor = load_projection_cursor(connection)?;
14197 loop {
14198 let next = cursor.saturating_add(1);
14199 if terminal_state_for_cursor(connection, next)?.is_some() {
14200 cursor = next;
14201 } else {
14202 break;
14203 }
14204 }
14205 store_projection_cursor(connection, cursor)?;
14206 Ok(cursor)
14207}
14208
14209fn commit_projection_outcomes(
14210 connection: &mut Connection,
14211 outcomes: &[ProjectionOutcome],
14212 shared: &ProjectionRuntimeShared,
14213) -> rusqlite::Result<()> {
14214 let embedder_identity = &shared.embedder_identity;
14215 let mc = identity_requires_mean_centering(embedder_identity);
14216 // EU-5f — serialize the whole commit across workers so the at-pin
14217 // re-quantize sees a totally-ordered history (see `commit_gate`).
14218 let _gate = shared.commit_gate.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
14219 // Take the WAL write lock before the reads below. A deferred transaction
14220 // would need a read-to-write promotion while a concurrent Engine::write
14221 // holds its own immediate transaction, which SQLite rejects without
14222 // invoking the busy handler and forces the worker to recompute the batch.
14223 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
14224 // The accumulator is mutable process state coupled to this transaction.
14225 // Keep the shared value untouched while building a candidate so rollback
14226 // cannot count a vector or consume the pin threshold prematurely.
14227 let mut shared_accumulator =
14228 shared.mean_accumulator.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
14229 let mut candidate_accumulator = shared_accumulator.clone();
14230 // EU-5a2/EU-5f — the live pinned mean. Read once at the top; may pin
14231 // mid-batch (set to `Some` after a threshold-crossing row below).
14232 let mut current_mean: Option<Vec<f32>> = if mc {
14233 tx.query_row(
14234 "SELECT mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
14235 [],
14236 |row| row.get::<_, Option<Vec<u8>>>(0),
14237 )
14238 .ok()
14239 .flatten()
14240 .map(|bytes| decode_vector_blob(&bytes))
14241 } else {
14242 None
14243 };
14244 let mut staged_events: Vec<EmbedderEvent> = Vec::new();
14245 for outcome in outcomes {
14246 match outcome {
14247 ProjectionOutcome::Success { cursor, kind, blob, bin_blob } => {
14248 if terminal_state_for_cursor(&tx, *cursor)?.is_some() {
14249 continue;
14250 }
14251 // Build the threshold decision in the transaction-local
14252 // candidate. The shared accumulator changes only after commit.
14253 let pin_mean: Option<Vec<f32>> = if mc && current_mean.is_none() {
14254 match candidate_accumulator.as_mut() {
14255 Some(a) => {
14256 a.add(&decode_vector_blob(bin_blob));
14257 if a.count() >= MEAN_VEC_PIN_THRESHOLD {
14258 let mean = a.materialize();
14259 candidate_accumulator = None;
14260 Some(mean)
14261 } else {
14262 None
14263 }
14264 }
14265 None => None,
14266 }
14267 } else {
14268 None
14269 };
14270
14271 let source_type = resolve_source_type(kind).map_err(|_| {
14272 rusqlite::Error::SqliteFailure(
14273 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
14274 Some(format!("unknown kind for source_type mapping: {kind}")),
14275 )
14276 })?;
14277 let now_unix =
14278 SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
14279 as i64;
14280 tx.execute(
14281 "INSERT OR IGNORE INTO _fathomdb_vector_rows(rowid, kind, write_cursor) VALUES(?1, ?2, ?3)",
14282 params![cursor, kind, cursor],
14283 )?;
14284 // EU-5a2/EU-5f — sign-quant input is the mean-subtracted
14285 // vector iff a mean is live (`current_mean`); otherwise the
14286 // un-centered `bin_blob`. A row inserted just before the
14287 // crossing is centered retroactively by the re-quantize
14288 // pass below.
14289 let centered_blob: Vec<u8> = match ¤t_mean {
14290 Some(mean) if mean.len() * 4 == bin_blob.len() => {
14291 encode_vector_blob(&subtract_mean(&decode_vector_blob(bin_blob), mean))
14292 }
14293 _ => bin_blob.clone(),
14294 };
14295 // Slice 10 / G10 — `status` ships the empty-string sentinel (vec0
14296 // TEXT metadata is NOT NULL-able); no real population source yet
14297 // (reserved-gap candidate 13).
14298 //
14299 // 0.8.20 Slice 15e — when the live `vector_default` carries
14300 // `filterable` `attr_<hex>` columns, EVERY column must be bound
14301 // (vec0 rejects a partial-column INSERT). Bind each from the node
14302 // body's scalar extraction (or the `''` sentinel). The body is not
14303 // carried on the embed job, so it is read from `canonical_nodes` by
14304 // `write_cursor` (== rowid) — but ONLY when attr columns exist, so
14305 // the common no-filterable hot path stays byte-identical and does
14306 // NO extra lookup.
14307 if actual_vector_attr_columns(&tx)?.is_empty() {
14308 tx.execute(
14309 "INSERT OR IGNORE INTO vector_default(
14310 rowid, embedding, embedding_bin, source_type, kind, created_at, status
14311 ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, '')",
14312 params![cursor, blob, centered_blob, source_type, kind, now_unix],
14313 )?;
14314 } else {
14315 let body: String = tx
14316 .query_row(
14317 "SELECT body FROM canonical_nodes WHERE write_cursor = ?1 LIMIT 1",
14318 [*cursor as i64],
14319 |row| row.get(0),
14320 )
14321 .optional()?
14322 .unwrap_or_default();
14323 let (cols_sql, ph_sql, attr_vals) =
14324 vector_attr_insert_fragments(&tx, &body, 7)?;
14325 let sql = format!(
14326 "INSERT OR IGNORE INTO vector_default(
14327 rowid, embedding, embedding_bin, source_type, kind, created_at, status{cols_sql}
14328 ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, ''{ph_sql})"
14329 );
14330 let mut pv: Vec<rusqlite::types::Value> = vec![
14331 rusqlite::types::Value::Integer(*cursor as i64),
14332 rusqlite::types::Value::Blob(blob.clone()),
14333 rusqlite::types::Value::Blob(centered_blob.clone()),
14334 rusqlite::types::Value::Text(source_type.to_string()),
14335 rusqlite::types::Value::Text(kind.to_string()),
14336 rusqlite::types::Value::Integer(now_unix),
14337 ];
14338 pv.extend(attr_vals);
14339 tx.execute(&sql, rusqlite::params_from_iter(pv.iter()))?;
14340 }
14341 record_projection_terminal(&tx, *cursor, "up_to_date")?;
14342
14343 // EU-5f — this row crossed the threshold: pin the mean and
14344 // re-quantize every row written so far (incl. earlier rows
14345 // in this same tx, which are visible to the SELECT) within
14346 // the same transaction so the pin is atomic.
14347 if let Some(mean) = pin_mean {
14348 tx.execute(
14349 "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
14350 params![encode_vector_blob(&mean)],
14351 )?;
14352 let rows: Vec<(i64, Vec<u8>)> = {
14353 let mut statement = tx.prepare(
14354 "SELECT rowid, embedding FROM vector_default ORDER BY rowid",
14355 )?;
14356 let mapped = statement.query_map([], |row| {
14357 Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?))
14358 })?;
14359 let mut out = Vec::new();
14360 for r in mapped {
14361 out.push(r?);
14362 }
14363 out
14364 };
14365 let (doc_count, _) =
14366 run_pin_and_requantize_pass(&tx, &rows, &mean).map_err(|_| {
14367 rusqlite::Error::SqliteFailure(
14368 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
14369 Some("mean-centering re-quantize pass failed".to_string()),
14370 )
14371 })?;
14372 staged_events.push(EmbedderEvent::MeanVecPinned {
14373 dim: u32::try_from(mean.len()).unwrap_or(u32::MAX),
14374 doc_count,
14375 });
14376 current_mean = Some(mean);
14377 }
14378 }
14379 ProjectionOutcome::Failure { cursor, failure_code } => {
14380 if terminal_state_for_cursor(&tx, *cursor)?.is_some() {
14381 continue;
14382 }
14383 let existing: u64 = tx.query_row(
14384 "SELECT COUNT(*) FROM operational_mutations
14385 WHERE collection_name = 'projection_failures'
14386 AND json_extract(payload_json, '$.write_cursor') = ?1",
14387 [cursor],
14388 |row| row.get(0),
14389 )?;
14390 if existing == 0 {
14391 let payload = format!(
14392 r#"{{"write_cursor":{cursor},"failure_code":"{failure_code}","recorded_at":0}}"#
14393 );
14394 tx.execute(
14395 "INSERT INTO operational_mutations(
14396 collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
14397 ) VALUES('projection_failures', ?1, 'append', ?2, NULL, ?3)",
14398 params![cursor.to_string(), payload, cursor],
14399 )?;
14400 }
14401 record_projection_terminal(&tx, *cursor, "failed")?;
14402 }
14403 // 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — record NOTHING.
14404 //
14405 // No `projection_failures` audit row (an ABSENT embedder is an
14406 // environment fact, not an embed failure) and, decisively, no
14407 // terminal: the row keeps `terminal IS NULL`, so
14408 // `advance_projection_cursor` below cannot step over it, the shared
14409 // `connection_has_pending_projection_work` predicate still reports it
14410 // outstanding, and `derive_dense_readiness` therefore reads
14411 // `embedding`. That is the ONLY torn state
14412 // `dev/design/record-lifecycle-protocol/projection-registry-and-async-embed.md`
14413 // §4.1 invariant 1 tolerates; the alternative — the enqueue-side gate
14414 // — puts an `'up_to_date'` terminal on an ENROLLED row with no
14415 // vector, which is the torn `ready` that invariant calls FORBIDDEN.
14416 //
14417 // Q6a graceful-absent governs ROLE DECLARATION ("you declared a
14418 // projection I cannot build yet" -> defer + graft), i.e. the
14419 // NOT-yet-enrolled case fix-1/fix-2 handle. Once a kind IS enrolled,
14420 // §4.1 invariant 1 governs. (HITL ruling, 0.8.20 Slice 20c fix-4.)
14421 //
14422 // Consumer-visible consequence, accepted deliberately and pinned by
14423 // `slice20c_flush_barrier`: for the REST of that no-embedder session
14424 // `dense_readiness` stays `embedding` and `drain` burns its timeout
14425 // into `EngineError::Scheduler`. Loud and recoverable, rather than
14426 // silent and lost.
14427 ProjectionOutcome::Deferred => {}
14428 }
14429 }
14430 // 0.7.2 PR-2bc S2 — the AUTOMATIC in-ingest drift detector (EWMA recent
14431 // mean + cos-threshold + debounce + 200k cap + `MeanRecomputeDeferred`)
14432 // was CARVED OUT and DEFERRED to 0.8.x; its recall premise was refuted
14433 // (the mean is a non-lever) and the benefit is unmeasured. The mean is
14434 // refreshed only on demand via `Engine::recompute_mean` (the
14435 // `doctor recompute-mean` verb). See `dev/design/embedder.md` §0.3 and
14436 // `dev/plans/prompts/0.8.x-auto-mean-drift-DEFERRED.md`. Nothing here
14437 // mutates `mean_vec` after the initial pin.
14438
14439 advance_projection_cursor(&tx)?;
14440 #[cfg(debug_assertions)]
14441 match shared.force_projection_commit_failure.swap(0, Ordering::SeqCst) {
14442 1 => {
14443 return Err(rusqlite::Error::SqliteFailure(
14444 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
14445 Some("forced projection commit failure".to_string()),
14446 ));
14447 }
14448 2 => return Err(rusqlite::Error::InvalidQuery),
14449 _ => {}
14450 }
14451 tx.commit()?;
14452 // Commit and the accumulator transition become visible together. On every
14453 // earlier error the transaction and the local candidate drop, leaving the
14454 // runtime state exactly as it was before this attempt.
14455 *shared_accumulator = candidate_accumulator;
14456 // EU-5f — publish MeanVecPinned only after the pin tx is durable, so a
14457 // rolled-back pin never emits a spurious event.
14458 if !staged_events.is_empty() {
14459 if let Ok(mut events) = shared.pending_events.lock() {
14460 events.extend(staged_events);
14461 }
14462 }
14463 Ok(())
14464}
14465
14466/// EU-5f — open-time recovery pin (`dev/design/embedder.md` §0.3, Hazard 4).
14467/// Derives the corpus mean from the existing un-centered `vector_default`
14468/// rows, pins it, and re-quantizes every row, all in one transaction on the
14469/// single-threaded open connection (no workers running yet, so no gate is
14470/// needed). Called only when MC is required, no mean is pinned, and the row
14471/// count already meets the threshold.
14472fn recover_mean_vec_pin(
14473 connection: &mut Connection,
14474 identity: &EmbedderIdentity,
14475) -> Result<(), EngineError> {
14476 let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
14477 recompute_mean_in_tx(&tx, identity)?;
14478 tx.commit().map_err(|_| EngineError::Storage)?;
14479 Ok(())
14480}
14481
14482/// 0.7.2 PR-2b — shared mean (re)compute core, run INSIDE the caller's
14483/// transaction. Derives the FULL-corpus mean from the un-centered
14484/// `vector_default.embedding` BLOBs, writes `mean_vec`, and re-quantizes
14485/// EVERY row via the existing [`run_pin_and_requantize_pass`] so no row is
14486/// left under a stale centering.
14487///
14488/// This generalizes the EU-5f open-time recovery pin: it has NO "no mean
14489/// pinned yet" guard, so it equally serves the FIRST pin (recovery) and a
14490/// REFRESH of an already-pinned mean (PR-2b drift / `doctor recompute-mean`).
14491/// The caller owns the transaction boundary, which is what makes a fault
14492/// between the `mean_vec` UPDATE and re-quantize completion roll back
14493/// wholesale (`dev/design/embedder.md` §0.5 atomicity). It does NOT publish
14494/// any event — that is the caller's job, strictly post-durable-commit.
14495fn recompute_mean_in_tx(
14496 tx: &rusqlite::Transaction<'_>,
14497 identity: &EmbedderIdentity,
14498) -> Result<MeanRecomputeReport, EngineError> {
14499 recompute_mean_in_tx_inner(tx, identity, false)
14500}
14501
14502/// 0.7.2 PR-2b — recompute core with an optional fault-injection point. The
14503/// `fail_after_mean_update` flag (debug builds only, set via a test seam)
14504/// errors AFTER the `mean_vec` UPDATE but BEFORE the re-quantize completes,
14505/// so the caller's tx rolls back the partial recentering.
14506fn recompute_mean_in_tx_inner(
14507 tx: &rusqlite::Transaction<'_>,
14508 identity: &EmbedderIdentity,
14509 fail_after_mean_update: bool,
14510) -> Result<MeanRecomputeReport, EngineError> {
14511 let started = Instant::now();
14512 let dim = identity.dimension as usize;
14513 // The previously-pinned mean (if any) is read first so we can report
14514 // the pre-recompute drift cosine.
14515 let old_mean = read_pinned_mean_vec(tx, identity.dimension)?;
14516 let rows: Vec<(i64, Vec<u8>)> = {
14517 let mut statement = tx
14518 .prepare("SELECT rowid, embedding FROM vector_default ORDER BY rowid")
14519 .map_err(|_| EngineError::Storage)?;
14520 let mapped = statement
14521 .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)))
14522 .map_err(|_| EngineError::Storage)?;
14523 let mut out = Vec::new();
14524 for r in mapped {
14525 out.push(r.map_err(|_| EngineError::Storage)?);
14526 }
14527 out
14528 };
14529 let mut accumulator = MeanAccumulator::new(dim);
14530 for (_rowid, blob) in &rows {
14531 if blob.len() != dim * 4 {
14532 return Err(EngineError::Storage);
14533 }
14534 accumulator.add(&decode_vector_blob(blob));
14535 }
14536 let old_doc_count = accumulator.count();
14537 let mean = accumulator.materialize();
14538 let drift_cos_before = match &old_mean {
14539 Some(old) => cosine_similarity(&mean, old),
14540 None => 1.0,
14541 };
14542 tx.execute(
14543 "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
14544 params![encode_vector_blob(&mean)],
14545 )
14546 .map_err(|_| EngineError::Storage)?;
14547 if fail_after_mean_update {
14548 // Injected fault: bail before re-quantizing so the caller's tx
14549 // rolls back the `mean_vec` UPDATE too (crash-atomicity proof).
14550 return Err(EngineError::Storage);
14551 }
14552 let (doc_count, _) = run_pin_and_requantize_pass(tx, &rows, &mean)?;
14553 Ok(MeanRecomputeReport {
14554 dim: u32::try_from(dim).unwrap_or(u32::MAX),
14555 old_doc_count,
14556 doc_count_requantized: doc_count,
14557 drift_cos_before,
14558 mean_was_pinned: old_mean.is_some(),
14559 elapsed_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
14560 })
14561}
14562
14563/// Cap sweep over the op-store mutation log: keeps the newest `cap` SWEEPABLE
14564/// rows, dropping the oldest by `id`.
14565///
14566/// **Pending-redaction exemption (0.8.20 Slice 5 fix-1).**
14567/// [`ERASURE_PENDING_REDACTION_COLLECTION`] is exempt on the same principle for a
14568/// stronger reason: that row is not a record of a discharged obligation but an
14569/// UNDISCHARGED one. Sweeping it would silently drop an erasure the engine still
14570/// owes, and the next retry would then report success with the leaked stable ids
14571/// still in the telemetry sink — exactly the R-20-E5 violation this mechanism
14572/// exists to prevent.
14573///
14574/// **Erasure-audit exemption (0.8.20 Slice 5b, design v5 §2 defect D-A;
14575/// HITL-ruled 2026-07-19: *"there must be an auditable record of deletion
14576/// event."*).** Rows in [`ERASURE_AUDIT_COLLECTIONS`] are excluded from BOTH the
14577/// count and the DELETE, and are therefore **never removed by retention
14578/// pressure**. Previously this swept `operational_mutations` cap-first,
14579/// oldest-`id`-first, with no collection filter — so the `excise_source_audit`
14580/// row proving an erasure occurred shared one retention pool with the very
14581/// payloads it must prove erased, and (being written before whatever workload
14582/// followed) was among the first evicted. Accountability is a distinct
14583/// obligation from erasure; a sweep must not silently discharge it.
14584///
14585/// Consequence of excluding audit rows from the count: `cap` is a cap on
14586/// SWEEPABLE rows, not on the physical table size. That is deliberate — the
14587/// alternative (counting exempt rows toward the cap) would let a growing audit
14588/// trail evict ordinary provenance ever more aggressively, and in the limit
14589/// leave nothing sweepable while the sweep churned every write.
14590fn enforce_provenance_retention(connection: &Connection, cap: u64) -> rusqlite::Result<()> {
14591 if cap == 0 {
14592 return Ok(());
14593 }
14594 // Static, engine-internal identifiers — no caller input reaches this SQL.
14595 let exempt = ERASURE_AUDIT_COLLECTIONS
14596 .iter()
14597 .copied()
14598 .chain(std::iter::once(ERASURE_PENDING_REDACTION_COLLECTION))
14599 .map(|name| format!("'{name}'"))
14600 .collect::<Vec<_>>()
14601 .join(", ");
14602 let slack = cap.max(20) / 20;
14603 let upper = cap.saturating_add(slack.max(1));
14604 let count: u64 = connection.query_row(
14605 &format!(
14606 "SELECT COUNT(*) FROM operational_mutations
14607 WHERE collection_name NOT IN ({exempt})"
14608 ),
14609 [],
14610 |row| row.get(0),
14611 )?;
14612 if count <= upper {
14613 return Ok(());
14614 }
14615 let to_delete = count.saturating_sub(cap);
14616 connection.execute(
14617 &format!(
14618 "DELETE FROM operational_mutations
14619 WHERE id IN (
14620 SELECT id FROM operational_mutations
14621 WHERE collection_name NOT IN ({exempt})
14622 ORDER BY id
14623 LIMIT ?1
14624 )"
14625 ),
14626 [to_delete],
14627 )?;
14628 Ok(())
14629}
14630
14631/// 0.8.20 Slice 5b (R-20-E6) — the prefixed stable ids
14632/// ([`IdSpace::to_prefixed`]) of the canonical rows an erasure verb is about to
14633/// delete, so they can be redacted from the telemetry sink.
14634///
14635/// Must be called INSIDE the erasing transaction and BEFORE the DELETEs — after
14636/// them the rows, and with them the `logical_id`/`body` the ids derive from, are
14637/// gone. Both queries take one bound parameter (`?1`), applied to nodes and
14638/// edges respectively; `derive_stable_id` reproduces exactly what
14639/// `capture_telemetry` wrote into `result_stable_ids`.
14640fn collect_erased_stable_ids(
14641 tx: &Connection,
14642 node_sql: &str,
14643 edge_sql: &str,
14644 bind: &str,
14645) -> Result<Vec<String>, EngineError> {
14646 let mut ids = Vec::new();
14647 for sql in [node_sql, edge_sql] {
14648 let mut stmt = tx.prepare(sql).map_err(|_| EngineError::Storage)?;
14649 let rows = stmt
14650 .query_map(params![bind], |row| {
14651 Ok((row.get::<_, Option<String>>(0)?, row.get::<_, Option<String>>(1)?))
14652 })
14653 .map_err(|_| EngineError::Storage)?;
14654 for row in rows {
14655 let (logical_id, body) = row.map_err(|_| EngineError::Storage)?;
14656 ids.push(
14657 derive_stable_id(logical_id.as_deref(), body.as_deref().unwrap_or(""))
14658 .to_prefixed(),
14659 );
14660 }
14661 }
14662 ids.sort_unstable();
14663 ids.dedup();
14664 Ok(ids)
14665}
14666
14667/// 0.8.20 Slice 5 fix-1 (codex §9 P2) — record, INSIDE the erasing transaction,
14668/// that a telemetry redaction is owed for `erased_stable_ids`.
14669///
14670/// Must be called in the same transaction as the DELETEs. That is the whole
14671/// point: "the rows are gone" and "a redaction is owed for them" then commit
14672/// atomically, so no crash or failure can leave the first true and the second
14673/// unrecorded. [`Engine::discharge_pending_redactions`] drains the queue and
14674/// deletes the entry only once the sink has actually been rewritten.
14675///
14676/// `record_key` is the VERB, never a stable id — the ids live in the payload,
14677/// which is deleted on discharge.
14678fn enqueue_pending_redaction(
14679 tx: &Connection,
14680 verb: &str,
14681 erased_stable_ids: &[String],
14682 write_cursor: u64,
14683) -> Result<(), EngineError> {
14684 if erased_stable_ids.is_empty() {
14685 return Ok(());
14686 }
14687 let payload =
14688 serde_json::json!({ "verb": verb, "erased_stable_ids": erased_stable_ids }).to_string();
14689 tx.execute(
14690 "INSERT INTO operational_mutations(
14691 collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
14692 ) VALUES(?1, ?2, 'append', ?3, NULL, ?4)",
14693 params![ERASURE_PENDING_REDACTION_COLLECTION, verb, payload, write_cursor],
14694 )
14695 .map_err(|_| EngineError::Storage)?;
14696 Ok(())
14697}
14698
14699/// 0.8.20 Slice 5b (R-20-E7) — the audit handle for an erased op-store record:
14700/// `SHA-256(collection + 0x1F + record_key)`, lowercase hex.
14701///
14702/// A record key is arbitrary caller-supplied text and may itself be the
14703/// identifier being erased, so a durable audit row must not echo it. `0x1F`
14704/// (ASCII unit separator) is the delimiter because it cannot appear in a
14705/// well-formed collection name, keeping the pairing unambiguous.
14706#[cfg(feature = "operator")]
14707fn digest_record_identity(collection: &str, record_key: &str) -> String {
14708 let mut hasher = Sha256::new();
14709 hasher.update(collection.as_bytes());
14710 hasher.update([0x1f_u8]);
14711 hasher.update(record_key.as_bytes());
14712 hasher.finalize().iter().map(|b| format!("{b:02x}")).collect()
14713}
14714
14715fn projection_status(
14716 connection: &Connection,
14717 kind: &str,
14718) -> Result<lifecycle::ProjectionStatus, EngineError> {
14719 let latest = connection
14720 .query_row(
14721 "SELECT COALESCE(MAX(write_cursor), 0) FROM canonical_nodes WHERE kind = ?1",
14722 [kind],
14723 |row| row.get::<_, u64>(0),
14724 )
14725 .map_err(|_| EngineError::Storage)?;
14726 if latest == 0 {
14727 return Ok(lifecycle::ProjectionStatus::UpToDate);
14728 }
14729 let pending: u64 = connection
14730 .query_row(
14731 "SELECT COUNT(*)
14732 FROM canonical_nodes
14733 LEFT JOIN _fathomdb_projection_terminal
14734 ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
14735 WHERE canonical_nodes.kind = ?1
14736 AND _fathomdb_projection_terminal.write_cursor IS NULL",
14737 [kind],
14738 |row| row.get(0),
14739 )
14740 .map_err(|_| EngineError::Storage)?;
14741 if pending > 0 {
14742 return Ok(lifecycle::ProjectionStatus::Pending);
14743 }
14744 match terminal_state_for_cursor(connection, latest).map_err(|_| EngineError::Storage)? {
14745 Some(state) if state == "failed" => Ok(lifecycle::ProjectionStatus::Failed),
14746 _ => Ok(lifecycle::ProjectionStatus::UpToDate),
14747 }
14748}
14749
14750fn canonical_database_path(path: &Path) -> Result<PathBuf, EngineOpenError> {
14751 let parent = path
14752 .parent()
14753 .filter(|parent| !parent.as_os_str().is_empty())
14754 .unwrap_or_else(|| Path::new("."));
14755 let canonical_parent = parent.canonicalize().map_err(|_| EngineOpenError::Io {
14756 message: "database parent directory is not accessible".to_string(),
14757 })?;
14758 let file_name = path.file_name().ok_or_else(|| EngineOpenError::Io {
14759 message: "database path has no file name".to_string(),
14760 })?;
14761
14762 Ok(canonical_parent.join(file_name))
14763}
14764
14765fn acquire_lock(path: &Path) -> Result<File, EngineOpenError> {
14766 let lock_path = lock_path(path);
14767 let mut options = OpenOptions::new();
14768 options.read(true).write(true).create(true);
14769 #[cfg(unix)]
14770 options.mode(0o600);
14771
14772 let mut file = options.open(&lock_path).map_err(|_| EngineOpenError::Io {
14773 message: "could not open database lock file".to_string(),
14774 })?;
14775
14776 match file.try_lock() {
14777 Ok(()) => {
14778 let pid = std::process::id().to_string();
14779 let _ = file.set_len(0);
14780 let _ = file.seek(SeekFrom::Start(0));
14781 let _ = file.write_all(pid.as_bytes());
14782 Ok(file)
14783 }
14784 Err(std::fs::TryLockError::WouldBlock) => {
14785 Err(EngineOpenError::DatabaseLocked { holder_pid: read_holder_pid(&lock_path) })
14786 }
14787 Err(_) => {
14788 Err(EngineOpenError::Io { message: "could not acquire database lock".to_string() })
14789 }
14790 }
14791}
14792
14793fn lock_path(path: &Path) -> PathBuf {
14794 let mut lock_path = path.as_os_str().to_os_string();
14795 lock_path.push(LOCK_SUFFIX);
14796 PathBuf::from(lock_path)
14797}
14798
14799fn read_holder_pid(path: &Path) -> Option<u32> {
14800 std::fs::read_to_string(path).ok()?.trim().parse().ok()
14801}
14802
14803fn map_migration_error(err: SchemaMigrationError) -> EngineOpenError {
14804 match err {
14805 SchemaMigrationError::IncompatibleSchemaVersion { seen, supported } => {
14806 EngineOpenError::IncompatibleSchemaVersion { seen, supported }
14807 }
14808 SchemaMigrationError::MigrationError(report) => EngineOpenError::MigrationError {
14809 schema_version_before: report.schema_version_before,
14810 schema_version_current: report.schema_version_current,
14811 step_id: report.migration_steps.last().map_or(0, |step| step.step_id),
14812 },
14813 SchemaMigrationError::Storage { message } => {
14814 EngineOpenError::Io { message: message.to_string() }
14815 }
14816 }
14817}
14818
14819/// 0.7.0 perf-experiments hook: process-start `sqlite3_config` calls.
14820/// Runs exactly once per process; must precede any `Connection::open`.
14821/// Gated on `FATHOMDB_PERF_EXPERIMENTS=1`. Each individual config
14822/// option is opt-in via its own env var so unrelated experiments do
14823/// not implicitly co-fire.
14824///
14825/// Currently supports:
14826/// - `FATHOMDB_PERF_SQLITE_MEMSTATUS_OFF=1`:
14827/// `sqlite3_config(SQLITE_CONFIG_MEMSTATUS, 0)` — drops the
14828/// allocator stats locking surface (whitepaper § 7.4). Composes
14829/// with other levers; small payoff alone.
14830///
14831/// Pattern: shutdown → config → initialize, mirroring B.1 attempt #2
14832/// (`d448263`, reverted). The captured rc for each config call is
14833/// logged to stderr so experiments can verify the call took effect.
14834fn init_perf_experiments_runtime() {
14835 static INIT: Once = Once::new();
14836 INIT.call_once(|| {
14837 if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
14838 return;
14839 }
14840 let memstatus_off =
14841 std::env::var_os("FATHOMDB_PERF_SQLITE_MEMSTATUS_OFF").is_some_and(|v| v == "1");
14842 // FATHOMDB_PERF_SQLITE_PAGECACHE=<page_size_bytes>:<page_count>
14843 // E.g. "4096:5000" => pre-allocate 4096 B × 5000 pages = 20 MB
14844 // global page-cache backing. SQLite distributes this across
14845 // connections; reduces global allocator pressure for page
14846 // cache fills.
14847 let pagecache = std::env::var("FATHOMDB_PERF_SQLITE_PAGECACHE").ok();
14848 // FATHOMDB_PERF_SQLITE_PCACHE2=1 installs the per-instance
14849 // custom page-cache allocator (pcache2.rs). Targets AC-020
14850 // residual contention on the default pcache1 mutex.
14851 let pcache2_on =
14852 std::env::var_os("FATHOMDB_PERF_SQLITE_PCACHE2").is_some_and(|v| v == "1");
14853 if !memstatus_off && pagecache.is_none() && !pcache2_on {
14854 return;
14855 }
14856 // SAFETY: sqlite3_shutdown / sqlite3_initialize are documented
14857 // as safe to call before any other SQLite API; sqlite3_config
14858 // must be called between shutdown and initialize. We pre-empt
14859 // rusqlite's lazy first-call sqlite3_initialize via this
14860 // explicit shutdown-then-config-then-initialize sequence,
14861 // identical to B.1 attempt #2's plumbing.
14862 unsafe {
14863 let rc_shutdown = rusqlite::ffi::sqlite3_shutdown();
14864 let rc_memstatus = if memstatus_off {
14865 rusqlite::ffi::sqlite3_config(rusqlite::ffi::SQLITE_CONFIG_MEMSTATUS, 0_i32)
14866 } else {
14867 -1
14868 };
14869 // SQLITE_CONFIG_PAGECACHE = 7 per sqlite3.h. With buffer=NULL,
14870 // SQLite allocates the backing memory itself but still
14871 // partitions it for use as the page-cache pool.
14872 let rc_pagecache = if let Some(spec) = pagecache.as_ref() {
14873 let mut parts = spec.split(':');
14874 let sz = parts.next().and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
14875 let n = parts.next().and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
14876 if sz > 0 && n > 0 {
14877 rusqlite::ffi::sqlite3_config(
14878 7, // SQLITE_CONFIG_PAGECACHE
14879 std::ptr::null_mut::<std::ffi::c_void>(),
14880 sz,
14881 n,
14882 )
14883 } else {
14884 eprintln!(
14885 "perf-experiment: bad FATHOMDB_PERF_SQLITE_PAGECACHE spec '{spec}' (expect '<bytes>:<count>')"
14886 );
14887 -1
14888 }
14889 } else {
14890 -1
14891 };
14892 let rc_pcache2 = if pcache2_on {
14893 // SQLITE_CONFIG_PCACHE2 = 18 per sqlite3.h. The methods
14894 // table must outlive the SQLite engine; we pass a
14895 // pointer to our static.
14896 rusqlite::ffi::sqlite3_config(
14897 rusqlite::ffi::SQLITE_CONFIG_PCACHE2,
14898 &raw const pcache2::PCACHE2_METHODS.0,
14899 )
14900 } else {
14901 -1
14902 };
14903 let rc_init = rusqlite::ffi::sqlite3_initialize();
14904 eprintln!(
14905 "perf-experiment: runtime-config rcs shutdown={rc_shutdown} \
14906 memstatus={rc_memstatus} pagecache={rc_pagecache} pcache2={rc_pcache2} \
14907 initialize={rc_init} (0=SQLITE_OK; 21=SQLITE_MISUSE; -1=not configured)"
14908 );
14909 }
14910 });
14911}
14912
14913fn register_sqlite_vec_extension() {
14914 static REGISTER: Once = Once::new();
14915 REGISTER.call_once(|| unsafe {
14916 let entrypoint: unsafe extern "C" fn(
14917 *mut rusqlite::ffi::sqlite3,
14918 *mut *const std::os::raw::c_char,
14919 *const rusqlite::ffi::sqlite3_api_routines,
14920 ) -> std::os::raw::c_int = std::mem::transmute(sqlite3_vec_init as *const ());
14921 rusqlite::ffi::sqlite3_auto_extension(Some(entrypoint));
14922 });
14923}
14924
14925fn probe_open_integrity(connection: &Connection) -> Result<(), EngineOpenError> {
14926 // `SELECT COUNT(*) FROM sqlite_schema` forces a full traversal of the
14927 // sqlite_schema b-tree; this surfaces page-1 b-tree corruption that a
14928 // bare `PRAGMA schema_version` (which only reads the schema cookie
14929 // out of the file header) would miss.
14930 connection
14931 .query_row("SELECT COUNT(*) FROM sqlite_schema", [], |row| row.get::<_, i64>(0))
14932 .map(|_| ())
14933 .map_err(|err| map_open_sqlite_error(err, OpenStage::SchemaProbe))
14934}
14935
14936fn probe_database_header(connection: &Connection) -> Result<(), EngineOpenError> {
14937 connection
14938 .query_row("PRAGMA application_id", [], |row| row.get::<_, i64>(0))
14939 .map(|_| ())
14940 .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))
14941}
14942
14943/// Pre-`pragma WAL` sidecar validation. SQLite silently discards a WAL
14944/// file whose header magic is wrong or whose advertised page size is
14945/// outside `[512, SQLITE_MAX_PAGE_SIZE]`, which would cause us to lose
14946/// committed frames at open time. AC-035a requires that we instead
14947/// refuse to open with `Corruption(WalReplayFailure)` rather than
14948/// silently rebuild from a truncated WAL.
14949fn probe_wal_sidecar(db_path: &Path) -> Result<(), EngineOpenError> {
14950 let mut wal_path = db_path.as_os_str().to_owned();
14951 wal_path.push("-wal");
14952 let wal_path = PathBuf::from(wal_path);
14953 // Bounded read: the WAL header is fixed-layout in the first 32
14954 // bytes (magic + format + page-size + checkpoint-seq + salts +
14955 // checksums); frame data starts at offset 32 and is irrelevant to
14956 // the magic + page-size pre-check. A `std::fs::read` of the whole
14957 // sidecar would force an unclean-shutdown open path to allocate
14958 // and copy the entire WAL into memory before SQLite touches
14959 // recovery — a real latency + RSS regression on AC-035.
14960 use std::io::Read;
14961 let mut file = match std::fs::File::open(&wal_path) {
14962 Ok(file) => file,
14963 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
14964 Err(_) => return Ok(()),
14965 };
14966 let mut bytes = [0u8; 32];
14967 if file.read_exact(&mut bytes).is_err() {
14968 // A short (< 32-byte) sidecar carries no committed frames;
14969 // SQLite treats it as empty and re-initializes WAL state.
14970 return Ok(());
14971 }
14972 let magic = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
14973 let page_size = u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
14974 // WAL_MAGIC mask per SQLite `walIndexRecover`: low bit distinguishes
14975 // big-endian vs little-endian checksum encoding; the rest of the
14976 // magic is fixed.
14977 const WAL_MAGIC_MASK: u32 = 0xFFFF_FFFE;
14978 const WAL_MAGIC: u32 = 0x377F_0682;
14979 const SQLITE_MAX_PAGE_SIZE: u32 = 65536;
14980 let magic_ok = (magic & WAL_MAGIC_MASK) == WAL_MAGIC;
14981 let page_size_ok =
14982 page_size.is_power_of_two() && (512..=SQLITE_MAX_PAGE_SIZE).contains(&page_size);
14983 if magic_ok && page_size_ok {
14984 return Ok(());
14985 }
14986 Err(EngineOpenError::Corruption(CorruptionDetail {
14987 kind: CorruptionKind::WalReplayFailure,
14988 stage: OpenStage::WalReplay,
14989 locator: CorruptionLocator::FileOffset { offset: if !magic_ok { 0 } else { 8 } },
14990 recovery_hint: RecoveryHint {
14991 code: "E_CORRUPT_WAL_REPLAY",
14992 doc_anchor: "design/recovery.md#wal-replay-failures",
14993 },
14994 }))
14995}
14996
14997fn reject_legacy_shape(connection: &Connection) -> Result<(), EngineOpenError> {
14998 let has_legacy_table = table_exists(connection, "fathom_nodes")
14999 || table_exists(connection, "fathom_edges")
15000 || table_exists(connection, "fathom_chunks");
15001 if !has_legacy_table {
15002 return Ok(());
15003 }
15004
15005 let seen =
15006 connection.query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)).unwrap_or(0);
15007 Err(EngineOpenError::IncompatibleSchemaVersion { seen, supported: SCHEMA_VERSION })
15008}
15009
15010fn table_exists(connection: &Connection, table: &str) -> bool {
15011 connection
15012 .query_row(
15013 "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?1",
15014 [table],
15015 |_row| Ok(()),
15016 )
15017 .is_ok()
15018}
15019
15020#[cfg(feature = "operator")]
15021fn read_schema_objects(
15022 connection: &Connection,
15023 obj_type: &str,
15024) -> Result<Vec<SchemaObject>, EngineError> {
15025 let mut stmt = connection
15026 .prepare(
15027 "SELECT name, sql FROM sqlite_schema
15028 WHERE type = ?1 AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL
15029 ORDER BY name",
15030 )
15031 .map_err(|_| EngineError::Storage)?;
15032 let rows = stmt
15033 .query_map([obj_type], |row| {
15034 Ok(SchemaObject { name: row.get::<_, String>(0)?, sql: row.get::<_, String>(1)? })
15035 })
15036 .map_err(|_| EngineError::Storage)?;
15037 let mut out = Vec::new();
15038 for row in rows {
15039 out.push(row.map_err(|_| EngineError::Storage)?);
15040 }
15041 Ok(out)
15042}
15043
15044#[cfg(feature = "operator")]
15045fn order_canonical_first(mut objects: Vec<SchemaObject>) -> Vec<SchemaObject> {
15046 let mut canonical: Vec<SchemaObject> = Vec::new();
15047 for name in CANONICAL_TABLES {
15048 if let Some(pos) = objects.iter().position(|o| o.name == *name) {
15049 canonical.push(objects.remove(pos));
15050 }
15051 }
15052 canonical.extend(objects);
15053 canonical
15054}
15055
15056fn load_default_profile(connection: &Connection) -> rusqlite::Result<EmbedderIdentity> {
15057 connection.query_row(
15058 "SELECT name, revision, dimension FROM _fathomdb_embedder_profiles WHERE profile = ?1",
15059 [DEFAULT_VECTOR_PROFILE],
15060 |row| {
15061 Ok(EmbedderIdentity::new(
15062 row.get::<_, String>(0)?,
15063 row.get::<_, String>(1)?,
15064 row.get::<_, u32>(2)?,
15065 ))
15066 },
15067 )
15068}
15069
15070fn default_profile_dimension(connection: &Connection) -> Result<u32, EngineError> {
15071 load_default_profile(connection)
15072 .map(|identity| identity.dimension)
15073 .map_err(|_| EngineError::Storage)
15074}
15075
15076fn kind_is_vector_indexed(connection: &Connection, kind: &str) -> Result<bool, EngineError> {
15077 connection
15078 .query_row("SELECT 1 FROM _fathomdb_vector_kinds WHERE kind = ?1", [kind], |_row| Ok(()))
15079 .map(|_| true)
15080 .or_else(|err| match err {
15081 rusqlite::Error::QueryReturnedNoRows => Ok(false),
15082 _ => Err(EngineError::Storage),
15083 })
15084}
15085
15086fn ensure_vector_partition(connection: &mut Connection, dimension: u32) -> rusqlite::Result<()> {
15087 // 0.7.0 Pack 1 schema per dev/design/0.7.0-vector-quant-pack1.md D1/D2:
15088 // f32 `embedding` + binary-quant sibling `embedding_bin` + `source_type`
15089 // partition key + `kind` + `created_at`. The vec0 column type is
15090 // dim-parameterized, so the reshape lives here rather than in the
15091 // SQL-only migration framework — see fathomdb-schema migration step 9
15092 // and dev/plans/runs/0.7.0-PVQ-P1-IMPL-output.json for the deviation
15093 // from the design memo's "Choose (a)" guidance.
15094 //
15095 // Three paths:
15096 // (1) no vector_default -> CREATE at new shape.
15097 // (2) old single-column shape -> stage + drop + recreate at new shape
15098 // + repopulate with vec_quantize_binary.
15099 // (3) already new shape -> no-op.
15100 let existing_sql: Option<String> = connection
15101 .query_row(
15102 "SELECT sql FROM sqlite_master WHERE type='table' AND name=?1",
15103 [DEFAULT_VECTOR_PARTITION],
15104 |row| row.get::<_, String>(0),
15105 )
15106 .optional()?;
15107
15108 // Slice 10 / G10 — 3-way shape-sentinel (fixes the prior
15109 // `contains("embedding_bin")` no-op that hid the `status` column from
15110 // existing Pack-1 DBs):
15111 // `status` present -> Pack-2 (current) shape, no-op.
15112 // `embedding_bin` present -> Pack-1 -> stage + recreate + back-fill status.
15113 // neither -> legacy single-column -> migrate to current.
15114 match existing_sql {
15115 None => create_vector_partition(connection, dimension),
15116 Some(sql) if sql.contains("status") => Ok(()),
15117 Some(sql) if sql.contains("embedding_bin") => {
15118 migrate_vector_partition_pack1_to_pack2(connection, dimension)
15119 }
15120 Some(_) => migrate_vector_partition_to_pack1(connection, dimension),
15121 }
15122}
15123
15124/// The current (Pack-2) `vector_default` vec0 shape. Slice 10 / G10 adds a plain
15125/// `status TEXT` metadata column — **not** aux (`+status`): aux columns
15126/// hard-error under a KNN `WHERE`, and the G10 filter constrains `status` in the
15127/// phase-1 KNN statement. `status` ships NULL plumbing only (no population source
15128/// yet).
15129///
15130/// 0.8.20 Slice 15e — `attr_cols` are the declared-`filterable` attribute columns
15131/// (byte-safe `attr_<hex>` identifiers, see [`attr_vec0_column`]), each a PLAIN
15132/// `TEXT` metadata column (never aux `+`), appended after `status`. **When
15133/// `attr_cols` is empty the produced SQL is byte-identical to the shipped shape**
15134/// — every existing caller passes `&[]`, so no shipped behaviour changes.
15135fn vector_partition_create_sql(
15136 dimension: u32,
15137 if_not_exists: bool,
15138 attr_cols: &[String],
15139) -> String {
15140 let guard = if if_not_exists { "IF NOT EXISTS " } else { "" };
15141 let mut attrs = String::new();
15142 for col in attr_cols {
15143 attrs.push_str(&format!(",{col} TEXT"));
15144 }
15145 format!(
15146 "CREATE VIRTUAL TABLE {guard}{DEFAULT_VECTOR_PARTITION} USING vec0(\
15147 embedding float[{dimension}],\
15148 embedding_bin bit[{dimension}],\
15149 source_type TEXT partition key,\
15150 kind TEXT,\
15151 created_at INTEGER,\
15152 status TEXT{attrs}\
15153 )"
15154 )
15155}
15156
15157fn create_vector_partition(connection: &Connection, dimension: u32) -> rusqlite::Result<()> {
15158 connection.execute_batch(&vector_partition_create_sql(dimension, true, &[]))
15159}
15160
15161/// 0.8.20 Slice 15e — encode an arbitrary registry attribute NAME into a vec0-safe
15162/// column identifier: `attr_` + lowercase hex of the name's UTF-8 bytes.
15163///
15164/// vec0 rejects quoted column identifiers, and a Slice-15d-validated attribute
15165/// name may contain spaces / unicode / `-`, so the raw name cannot be a column
15166/// identifier. Hex is injective (so the map is reversible by
15167/// [`decode_attr_vec0_column`]), matches `^attr_[0-9a-f]+$`, and can never collide
15168/// with a built-in metadata column (`embedding`, `embedding_bin`, `source_type`,
15169/// `kind`, `created_at`, `status` — none carry the `attr_` prefix followed by an
15170/// even-length hex string of the name).
15171fn attr_vec0_column(name: &str) -> String {
15172 let mut s = String::from("attr_");
15173 for b in name.as_bytes() {
15174 s.push_str(&format!("{b:02x}"));
15175 }
15176 s
15177}
15178
15179/// 0.8.20 Slice 15e — inverse of [`attr_vec0_column`]. Returns the original
15180/// attribute name for an `attr_<hex>` column, or `None` if `col` is not a
15181/// well-formed encoded attribute column (so the built-in metadata columns and any
15182/// vec0 shadow columns are skipped when enumerating a live table's attribute set).
15183fn decode_attr_vec0_column(col: &str) -> Option<String> {
15184 let hex = col.strip_prefix("attr_")?;
15185 if hex.is_empty() || hex.len() % 2 != 0 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
15186 return None;
15187 }
15188 let mut bytes = Vec::with_capacity(hex.len() / 2);
15189 let raw = hex.as_bytes();
15190 let mut i = 0;
15191 while i < raw.len() {
15192 let hi = (raw[i] as char).to_digit(16)?;
15193 let lo = (raw[i + 1] as char).to_digit(16)?;
15194 bytes.push((hi * 16 + lo) as u8);
15195 i += 2;
15196 }
15197 String::from_utf8(bytes).ok()
15198}
15199
15200/// 0.8.20 Slice 15e — the DESIRED `attr_<hex>` columns implied by the durable
15201/// projection registry: one per `filterable` projection, sorted by attribute name
15202/// (⇒ sorted by column, since hex encoding preserves byte order). This is the
15203/// derived-cache source the reshape reconciles the live vec0 shape against.
15204fn desired_vector_attr_columns(conn: &Connection) -> rusqlite::Result<Vec<String>> {
15205 let registry = load_projection_registry(conn)?;
15206 let mut cols: Vec<String> = registry
15207 .iter()
15208 .filter(|(_, stored)| stored.roles.contains(&ProjectionRole::Filterable))
15209 .map(|(name, _)| attr_vec0_column(name))
15210 .collect();
15211 cols.sort();
15212 Ok(cols)
15213}
15214
15215/// 0.8.20 Slice 15e — the `attr_<hex>` columns actually present on the live
15216/// `vector_default` vec0 table, parsed from its `CREATE VIRTUAL TABLE` SQL and
15217/// sorted. Empty when the table is absent. Parsing the SQL (rather than a PRAGMA)
15218/// keeps this robust across vec0 versions and shadow-table layouts.
15219fn actual_vector_attr_columns(conn: &Connection) -> rusqlite::Result<Vec<String>> {
15220 let sql: Option<String> = conn
15221 .query_row(
15222 "SELECT sql FROM sqlite_master WHERE type='table' AND name=?1",
15223 [DEFAULT_VECTOR_PARTITION],
15224 |row| row.get::<_, String>(0),
15225 )
15226 .optional()?;
15227 let Some(sql) = sql else {
15228 return Ok(Vec::new());
15229 };
15230 let mut cols: Vec<String> = Vec::new();
15231 // Tokenize on any non-identifier byte; a token is an attribute column iff it
15232 // decodes as a well-formed `attr_<hex>` identifier.
15233 let mut token = String::new();
15234 let flush = |token: &mut String, cols: &mut Vec<String>| {
15235 if !token.is_empty() {
15236 if decode_attr_vec0_column(token).is_some() && !cols.contains(token) {
15237 cols.push(token.clone());
15238 }
15239 token.clear();
15240 }
15241 };
15242 for ch in sql.chars() {
15243 if ch.is_ascii_alphanumeric() || ch == '_' {
15244 token.push(ch);
15245 } else {
15246 flush(&mut token, &mut cols);
15247 }
15248 }
15249 flush(&mut token, &mut cols);
15250 cols.sort();
15251 Ok(cols)
15252}
15253
15254/// TC-76 (sqlite-vec issue **#99**) — **the** way to delete ONE `vector_default`
15255/// row by rowid. Every by-rowid vec0 delete in this crate goes through here.
15256///
15257/// # The upstream defect this works around
15258///
15259/// A vec0 plain-TEXT metadata column stores a 16-byte inline view per row: a
15260/// 4-byte length plus the first 12 bytes of the value
15261/// (`VEC0_METADATA_TEXT_VIEW_DATA_LENGTH`). A value longer than those 12 bytes
15262/// ALSO gets a row in the `<tbl>_metadatatext<NN>` shadow table.
15263///
15264/// In `sqlite-vec` `=0.1.7`, `vec0Update_Delete_ClearMetadata`
15265/// (`sqlite-vec.c:8888`) deletes that shadow row (`sqlite-vec.c:8934-8952`) and
15266/// then returns `sqlite3_step`'s `SQLITE_DONE` (101) verbatim — it never resets
15267/// `rc` to `SQLITE_OK`. `vec0Update_Delete` (`sqlite-vec.c:9027`) reads
15268/// `101 != SQLITE_OK` as a failure and aborts the entire `DELETE`. The
15269/// INSERT/UPDATE twin of that code (`sqlite-vec.c:8258-8320`) is saved by an
15270/// unconditional `rc = sqlite3_blob_close(...)` after its switch, which is why
15271/// only DELETE carries the defect — and why the neutralizing `UPDATE` below is a
15272/// sound workaround rather than the same bug by another name.
15273///
15274/// # Why FathomDB is exposed
15275///
15276/// `vector_default` carries three plain-TEXT metadata columns
15277/// ([`vector_partition_create_sql`]): `kind`, `status` and one `attr_<hex>` per
15278/// declared `filterable` projection. Only the `attr_*` VALUES are caller-supplied
15279/// and unbounded, and Slice 15e stores them marker-encoded (`\x01 || V`), so a
15280/// raw attribute value of **12 or more UTF-8 bytes** trips #99 and makes
15281/// `erase_source` / `purge` / edge supersession / the open-path orphan sweep fail
15282/// with [`EngineError::Storage`], leaving the row AND its shadowed value at rest.
15283/// `kind` is bounded to `resolve_source_type`'s locked vocabulary (max 9 bytes)
15284/// at every enrolment door via [`kind_is_vector_committable`], and `status` ships
15285/// the `''` sentinel — so neither needs neutralizing, and neither is touched.
15286///
15287/// # The workaround
15288///
15289/// Blank the `attr_*` columns FIRST. vec0's UPDATE path takes the `n <= 12 &&
15290/// prev_n > 12` branch (`sqlite-vec.c:8300-8319`), which deletes the shadow row
15291/// AND returns `SQLITE_OK`, then the DELETE no longer has an over-length value to
15292/// clear. Measured: the `embedding` bytes and the other metadata columns survive
15293/// the metadata-only UPDATE verbatim, and the `_metadatatext<NN>` row is gone —
15294/// so this is erasure-COMPLETE, not merely error-suppressing.
15295///
15296/// Pinned by `tests/tc76_vec0_long_metadata_delete.rs`, whose bare-vec0 test is
15297/// the tripwire: it fails once `sqlite-vec` ships a fix for #99, which is the
15298/// signal to delete the neutralize step.
15299///
15300/// A no-op UPDATE (no `attr_*` columns declared — every corpus before a
15301/// `filterable` projection exists) is skipped entirely, so the shipped
15302/// no-projection path issues the same single `DELETE` it always did.
15303fn delete_vector_partition_row(conn: &Connection, rowid: i64) -> rusqlite::Result<usize> {
15304 neutralize_vector_partition_attr_values(conn, Some(rowid))?;
15305 conn.execute(&format!("DELETE FROM {DEFAULT_VECTOR_PARTITION} WHERE rowid = ?1"), [rowid])
15306}
15307
15308/// TC-76 (sqlite-vec **#99**) — blank every `attr_<hex>` value on `vector_default`
15309/// (one row when `rowid` is `Some`, the whole table when `None`) so that a
15310/// following `DELETE` never has an over-length TEXT metadata value to clear. See
15311/// [`delete_vector_partition_row`] for the mechanism and the evidence.
15312///
15313/// A pure no-op when no `filterable` projection is declared — which is every
15314/// corpus that predates Slice 15e — so the shipped delete paths issue exactly the
15315/// statements they always did.
15316fn neutralize_vector_partition_attr_values(
15317 conn: &Connection,
15318 rowid: Option<i64>,
15319) -> rusqlite::Result<()> {
15320 let attr_cols = actual_vector_attr_columns(conn)?;
15321 if attr_cols.is_empty() {
15322 return Ok(());
15323 }
15324 // `attr_cols` are `^attr_[0-9a-f]+$` identifiers derived by
15325 // `attr_vec0_column`, never caller text: safe to interpolate.
15326 let sets = attr_cols.iter().map(|c| format!("{c}=''")).collect::<Vec<_>>().join(", ");
15327 match rowid {
15328 Some(rowid) => {
15329 conn.execute(
15330 &format!("UPDATE {DEFAULT_VECTOR_PARTITION} SET {sets} WHERE rowid = ?1"),
15331 [rowid],
15332 )?;
15333 }
15334 None => {
15335 conn.execute(&format!("UPDATE {DEFAULT_VECTOR_PARTITION} SET {sets}"), [])?;
15336 }
15337 }
15338 Ok(())
15339}
15340
15341/// 0.8.20 Slice 15e — reconcile the live `vector_default` attribute columns with
15342/// the registry's `filterable` set (TC-46: HITL-ratified NON-DESTRUCTIVE reshape,
15343/// following the shipped `migrate_vector_partition_pack1_to_pack2` precedent).
15344///
15345/// Diffs the DESIRED columns (from the registry) against the ACTUAL columns (on
15346/// the live table). When they already match — which is EVERY idempotent
15347/// re-registration and every boot re-derive that replays the same set — this is a
15348/// pure no-op: no reshape, no re-insert, vec0 untouched (so boot never silently
15349/// wipes a corpus). When they differ, performs ONE non-destructive reshape.
15350///
15351/// Returns `true` iff a reshape was performed. Runs the DDL directly on the passed
15352/// connection/transaction (no nested transaction), so a caller already inside a
15353/// write transaction (`configure_projections`) gets the reshape atomically with
15354/// its registry mutation. A no-op (and returns `false`) when `vector_default` does
15355/// not exist (a DB opened without an embedder).
15356fn reconcile_vector_attr_columns(conn: &Connection, dimension: u32) -> rusqlite::Result<bool> {
15357 let table_exists: bool = conn
15358 .query_row(
15359 "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1",
15360 [DEFAULT_VECTOR_PARTITION],
15361 |_| Ok(true),
15362 )
15363 .optional()?
15364 .unwrap_or(false);
15365 if !table_exists {
15366 return Ok(false);
15367 }
15368 let desired = desired_vector_attr_columns(conn)?;
15369 let actual = actual_vector_attr_columns(conn)?;
15370 if desired == actual {
15371 return Ok(false);
15372 }
15373 reshape_vector_partition_nondestructive(conn, dimension, &desired, &actual, false)?;
15374 Ok(true)
15375}
15376
15377/// Rebuild vec0 attribute metadata from the canonical EAV projection while
15378/// preserving the existing column set. A changed nested source can leave the
15379/// `attr_<hex>` schema untouched while changing every value behind that column.
15380fn refresh_vector_attr_values(conn: &Connection, dimension: u32) -> rusqlite::Result<()> {
15381 let desired = desired_vector_attr_columns(conn)?;
15382 let actual = actual_vector_attr_columns(conn)?;
15383 if desired != actual || !desired.is_empty() {
15384 reshape_vector_partition_nondestructive(conn, dimension, &desired, &actual, true)?;
15385 }
15386 Ok(())
15387}
15388
15389/// Refresh one reactivated node's vec0 metadata without reshaping the corpus.
15390///
15391/// Activation re-projects this node's canonical attributes after a source change
15392/// that could have happened while it was deleted. vec0 accepts metadata `UPDATE`s,
15393/// so only this row needs to be refreshed; a full partition reshape belongs to
15394/// registry shape/source reconciliation, not the ordinary lifecycle path.
15395fn refresh_vector_attr_values_for_row(
15396 conn: &Connection,
15397 rowid: i64,
15398 body: &str,
15399) -> rusqlite::Result<()> {
15400 let (cols_sql, _, mut values) = vector_attr_insert_fragments(conn, body, 1)?;
15401 if cols_sql.is_empty() {
15402 return Ok(());
15403 }
15404 let assignments = cols_sql
15405 .trim_start_matches(", ")
15406 .split(", ")
15407 .enumerate()
15408 .map(|(index, col)| format!("{col} = ?{}", index + 1))
15409 .collect::<Vec<_>>()
15410 .join(", ");
15411 values.push(rusqlite::types::Value::Integer(rowid));
15412 let rowid_index = values.len();
15413 conn.execute(
15414 &format!(
15415 "UPDATE {DEFAULT_VECTOR_PARTITION} SET {assignments} WHERE rowid = ?{rowid_index}"
15416 ),
15417 rusqlite::params_from_iter(values.iter()),
15418 )?;
15419 Ok(())
15420}
15421
15422/// 0.8.20 Slice 15e — the NON-DESTRUCTIVE reshape itself. Stages every live row
15423/// (base columns + all ACTUAL attribute columns), drops + recreates
15424/// `vector_default` at the DESIRED shape, then re-inserts each row.
15425///
15426/// THE FOUR LOAD-BEARING CONDITIONS (any one broken ⇒ silently wrong results):
15427/// 1. `rowid` is listed EXPLICITLY in the re-insert (a vec0 row maps to its node
15428/// by `rowid == write_cursor`; auto-assigned rowids would decouple every
15429/// embedding from its node);
15430/// 2. each attribute column is PLAIN `TEXT` metadata (via
15431/// [`vector_partition_create_sql`]), never a vec0 `aux`/`+` column (aux
15432/// hard-errors a filtered KNN);
15433/// 3. a DESIRED column with no ACTUAL predecessor back-fills each row from the
15434/// already-populated `canonical_attributes` (fix-1 finding 2) so a
15435/// pre-existing row whose body carries the attribute is immediately
15436/// filterable; the `''` sentinel (vec0 TEXT metadata is NOT-NULL-able) is
15437/// used ONLY where the attribute is genuinely absent, so an absent row
15438/// cleanly fails-to-match instead of erroring;
15439/// 4. `embedding_bin` is copied VERBATIM via `vec_bit(...)` — NOT re-quantized —
15440/// so old rows keep their (possibly mean-centered) bits and stay Hamming-
15441/// comparable to new rows.
15442///
15443/// Runs on `conn` directly (the caller owns the transaction). Transactional
15444/// atomicity + reader isolation are the caller's responsibility, exactly as for
15445/// `migrate_vector_partition_pack1_to_pack2`.
15446fn reshape_vector_partition_nondestructive(
15447 conn: &Connection,
15448 dimension: u32,
15449 desired_cols: &[String],
15450 actual_cols: &[String],
15451 refresh_values: bool,
15452) -> rusqlite::Result<()> {
15453 // Stage: base columns + every ACTUAL attribute column (so no at-rest value is
15454 // lost, even for a column being dropped).
15455 let mut stage_defs = String::new();
15456 let mut stage_names =
15457 String::from("rowid, embedding, embedding_bin, source_type, kind, created_at, status");
15458 for col in actual_cols {
15459 stage_defs.push_str(&format!(",\n {col} TEXT"));
15460 stage_names.push_str(&format!(", {col}"));
15461 }
15462 conn.execute_batch(&format!(
15463 "CREATE TABLE _fathomdb_vector_reshape_stage (
15464 rowid INTEGER PRIMARY KEY,
15465 embedding BLOB NOT NULL,
15466 embedding_bin BLOB NOT NULL,
15467 source_type TEXT,
15468 kind TEXT,
15469 created_at INTEGER,
15470 status TEXT{stage_defs}
15471 );
15472 INSERT INTO _fathomdb_vector_reshape_stage({stage_names})
15473 SELECT {stage_names} FROM {DEFAULT_VECTOR_PARTITION};
15474 DROP TABLE {DEFAULT_VECTOR_PARTITION};"
15475 ))?;
15476
15477 // Recreate at the DESIRED shape (plain TEXT attr columns — condition #2).
15478 conn.execute_batch(&vector_partition_create_sql(dimension, false, desired_cols))?;
15479
15480 // Re-insert. `rowid` explicit (condition #1); `vec_bit(embedding_bin)`
15481 // verbatim, never re-quantized (condition #4); `status` and each surviving
15482 // attribute column carried forward; each NEW desired column back-fills from
15483 // `canonical_attributes` (the `''` sentinel only where genuinely absent —
15484 // condition #3).
15485 let mut insert_cols =
15486 String::from("rowid, embedding, embedding_bin, source_type, kind, created_at, status");
15487 let mut select_exprs = String::from(
15488 "rowid, embedding, vec_bit(embedding_bin), source_type, kind, created_at, status",
15489 );
15490 for col in desired_cols {
15491 insert_cols.push_str(&format!(", {col}"));
15492 if actual_cols.iter().any(|a| a == col) && !refresh_values {
15493 // Surviving column — carry its value forward (never NULL: vec0 TEXT
15494 // metadata is `''`-sentinelled, but COALESCE defends the stage table).
15495 select_exprs.push_str(&format!(", COALESCE({col}, '')"));
15496 } else {
15497 // New column — back-fill from the ALREADY-populated `canonical_attributes`
15498 // (fix-1 finding 2 [P2]). `configure_projections` runs `backfill_attribute`
15499 // (which fills `canonical_attributes` from each active row's body) BEFORE
15500 // this reshape, so a pre-existing row whose body carries the attribute is
15501 // immediately filterable — no false negative until a re-embed. The `''`
15502 // sentinel (condition #3) is used ONLY where the attribute is genuinely
15503 // ABSENT for that row (no `canonical_attributes` row ⇒ COALESCE → '').
15504 // The EAV value equals the vec0 write-time value by construction (both go
15505 // through `extract_scalar_attribute`), so pre-existing and freshly-written
15506 // rows share one filter semantics.
15507 match decode_attr_vec0_column(col) {
15508 Some(name) => {
15509 // vec0/execute_batch takes no bind params; embed the decoded name
15510 // as a SQL string literal, escaping single quotes.
15511 //
15512 // fix-3 [P2] — a PRESENT row (a canonical_attributes row exists)
15513 // encodes its RAW `attr_value` as `\x01 || attr_value` (`char(1) ||
15514 // ca.attr_value`), matching the write-time vec0 encoding so a
15515 // pre-existing present-empty row (attr_value='') becomes the bare
15516 // marker, NOT `''`. An ABSENT row (no canonical_attributes row) is
15517 // the COALESCE default `''` (condition #3). `canonical_attributes`
15518 // itself stays RAW — only this vec0 column is encoded.
15519 let escaped = name.replace('\'', "''");
15520 select_exprs.push_str(&format!(
15521 ", COALESCE((SELECT char(1) || ca.attr_value FROM canonical_attributes ca \
15522 WHERE ca.write_cursor = _fathomdb_vector_reshape_stage.rowid \
15523 AND ca.attr_name = '{escaped}' LIMIT 1), '')"
15524 ));
15525 }
15526 // A desired column always decodes (built by `attr_vec0_column`); if it
15527 // somehow does not, fall back to the sentinel rather than panic.
15528 None => select_exprs.push_str(", ''"),
15529 }
15530 }
15531 }
15532 conn.execute_batch(&format!(
15533 "INSERT INTO {DEFAULT_VECTOR_PARTITION}({insert_cols})
15534 SELECT {select_exprs} FROM _fathomdb_vector_reshape_stage;
15535 DROP TABLE _fathomdb_vector_reshape_stage;"
15536 ))?;
15537 Ok(())
15538}
15539
15540/// Slice 10 / G10 — stage + recreate + back-fill upgrade of an existing
15541/// **Pack-1** `vector_default` (has `embedding_bin`, lacks `status`) to the
15542/// Pack-2 shape. The existing `embedding_bin` blob is preserved verbatim (it may
15543/// be mean-centered; re-quantizing from `embedding` would drop the centering),
15544/// and `status` back-fills NULL. Same transactional discipline as
15545/// `migrate_vector_partition_to_pack1`: a single `Connection::transaction()`;
15546/// reader handles are not opened until `ensure_vector_partition` returns, and
15547/// cross-process access is serialized by the sidecar lock, so readers never see
15548/// a partial reshape.
15549fn migrate_vector_partition_pack1_to_pack2(
15550 connection: &mut Connection,
15551 dimension: u32,
15552) -> rusqlite::Result<()> {
15553 let tx = connection.transaction()?;
15554 tx.execute_batch(
15555 "CREATE TABLE _fathomdb_vector_pack2_stage (
15556 rowid INTEGER PRIMARY KEY,
15557 embedding BLOB NOT NULL,
15558 embedding_bin BLOB NOT NULL,
15559 source_type TEXT,
15560 kind TEXT,
15561 created_at INTEGER
15562 );
15563 INSERT INTO _fathomdb_vector_pack2_stage(
15564 rowid, embedding, embedding_bin, source_type, kind, created_at
15565 )
15566 SELECT rowid, embedding, embedding_bin, source_type, kind, created_at
15567 FROM vector_default;
15568 DROP TABLE vector_default;",
15569 )?;
15570 tx.execute_batch(&vector_partition_create_sql(dimension, false, &[]))?;
15571 // `vec_bit(...)` re-tags the staged blob with the BIT subtype vec0's bit
15572 // column requires (a raw blob loses the subtype and fails the type check).
15573 // This preserves the existing (possibly mean-centered) bits verbatim — no
15574 // re-quantize, so centering survives the upgrade. `status` back-fills the
15575 // empty-string sentinel (vec0 TEXT metadata is NOT NULL-able; reserved-gap
15576 // candidate 13).
15577 tx.execute_batch(
15578 "INSERT INTO vector_default(
15579 rowid, embedding, embedding_bin, source_type, kind, created_at, status
15580 )
15581 SELECT rowid, embedding, vec_bit(embedding_bin), source_type, kind, created_at, ''
15582 FROM _fathomdb_vector_pack2_stage;
15583 DROP TABLE _fathomdb_vector_pack2_stage;",
15584 )?;
15585 tx.commit()
15586}
15587
15588/// SQL fragment implementing the D3 `kind -> source_type` map.
15589/// Used both by the Pack 1 reshape migration and by the drift-detection
15590/// unit test that pins it to [`resolve_source_type`].
15591const KIND_TO_SOURCE_TYPE_CASE_SQL: &str = "CASE s.kind
15592 WHEN 'email' THEN 'email'
15593 WHEN 'article' THEN 'article'
15594 WHEN 'paper' THEN 'paper'
15595 WHEN 'meeting' THEN 'meeting'
15596 WHEN 'note' THEN 'note'
15597 WHEN 'todo' THEN 'todo'
15598 WHEN 'doc' THEN 'article'
15599 ELSE 'article'
15600END";
15601
15602/// Pack 1 in-place reshape of `vector_default`. Stages the existing
15603/// f32 corpus + each row's `kind`, drops the old single-column vec0
15604/// table, recreates at the runtime `dimension` with the Pack 1
15605/// columns, then repopulates with SQL-side `vec_quantize_binary` +
15606/// the D3 `kind -> source_type` mapping. The preflight CHECK on
15607/// unknown kinds has already run as migration step 9 by the time we
15608/// get here.
15609///
15610/// Atomicity: the DROP+CREATE+repopulate sequence runs inside a
15611/// rusqlite `Connection::transaction()` (DEFERRED begin per rusqlite
15612/// `transaction.rs:417`). Cross-process serialization is provided by
15613/// the engine's sidecar `acquire_lock` at `open_with_migrations`
15614/// (`lib.rs:1127` area); reader handles are not opened until
15615/// `ensure_vector_partition` returns (`lib.rs:1241` area), so readers
15616/// never observe a partial reshape.
15617fn migrate_vector_partition_to_pack1(
15618 connection: &mut Connection,
15619 dimension: u32,
15620) -> rusqlite::Result<()> {
15621 let tx = connection.transaction()?;
15622 tx.execute_batch(
15623 "CREATE TABLE _fathomdb_vector_migration_v0_7_0 (
15624 rowid INTEGER PRIMARY KEY,
15625 embedding BLOB NOT NULL,
15626 kind TEXT NOT NULL
15627 );
15628 INSERT INTO _fathomdb_vector_migration_v0_7_0(rowid, embedding, kind)
15629 SELECT v.rowid, v.embedding, r.kind
15630 FROM vector_default v
15631 JOIN _fathomdb_vector_rows r ON r.rowid = v.rowid;
15632 DROP TABLE vector_default;",
15633 )?;
15634 // Slice 10 / G10 — recreate directly at the Pack-2 shape (adds `status`), so
15635 // a legacy single-column DB lands the current shape in one reshape.
15636 tx.execute_batch(&vector_partition_create_sql(dimension, false, &[]))?;
15637 // `status` back-fills the empty-string sentinel (vec0 TEXT metadata is NOT
15638 // NULL-able; reserved-gap candidate 13). Legacy single-column DBs predate
15639 // mean-centering, so re-quantizing from the un-centered `embedding` is
15640 // correct here.
15641 let repopulate_sql = format!(
15642 "INSERT INTO vector_default(
15643 rowid, embedding, embedding_bin, source_type, kind, created_at, status
15644 )
15645 SELECT
15646 s.rowid,
15647 s.embedding,
15648 vec_quantize_binary(s.embedding),
15649 {KIND_TO_SOURCE_TYPE_CASE_SQL},
15650 s.kind,
15651 strftime('%s', 'now'),
15652 ''
15653 FROM _fathomdb_vector_migration_v0_7_0 s;
15654 DROP TABLE _fathomdb_vector_migration_v0_7_0;"
15655 );
15656 tx.execute_batch(&repopulate_sql)?;
15657 tx.commit()
15658}
15659
15660fn encode_vector_blob(vector: &[f32]) -> Vec<u8> {
15661 vector.iter().flat_map(|value| value.to_le_bytes()).collect()
15662}
15663
15664fn decode_vector_blob(bytes: &[u8]) -> Vec<f32> {
15665 debug_assert_eq!(bytes.len() % 4, 0, "f32 BLOB length must be multiple of 4");
15666 bytes.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()
15667}
15668
15669/// EU-5a2 — does the live embedder identity request mean-centering?
15670/// Identity-name compare per EU-5a1's BGE_SMALL_EMBEDDER_NAME constant
15671/// (`dev/design/embedder.md` §0.6). NoopEmbedder returns `false`.
15672fn identity_requires_mean_centering(identity: &EmbedderIdentity) -> bool {
15673 identity.name == BGE_SMALL_EMBEDDER_NAME
15674}
15675
15676/// EU-5a2 — read the pinned mean vector from
15677/// `_fathomdb_embedder_profiles.mean_vec` for the default profile.
15678/// Returns `Ok(None)` when the column is NULL or the row is missing;
15679/// returns `Err(EngineError::Storage)` on dimension drift (the open-time
15680/// `check_embedder_profile` already fails closed for this, so a runtime
15681/// drift here would be an internal-inconsistency signal).
15682fn read_pinned_mean_vec(
15683 connection: &Connection,
15684 dimension: u32,
15685) -> Result<Option<Vec<f32>>, EngineError> {
15686 let bytes: Option<Vec<u8>> = connection
15687 .query_row(
15688 "SELECT mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
15689 [],
15690 |row| row.get::<_, Option<Vec<u8>>>(0),
15691 )
15692 .or_else(|err| match err {
15693 rusqlite::Error::QueryReturnedNoRows => Ok(None),
15694 other => Err(other),
15695 })
15696 .map_err(|_| EngineError::Storage)?;
15697 let Some(bytes) = bytes else { return Ok(None) };
15698 let expected_len = (dimension as usize).saturating_mul(4);
15699 if bytes.len() != expected_len {
15700 return Err(EngineError::Storage);
15701 }
15702 let mut out = Vec::with_capacity(dimension as usize);
15703 for chunk in bytes.chunks_exact(4) {
15704 let arr = [chunk[0], chunk[1], chunk[2], chunk[3]];
15705 out.push(f32::from_le_bytes(arr));
15706 }
15707 Ok(Some(out))
15708}
15709
15710/// EU-5a2 — pointwise `v - mean`. Length-checked debug-assert; caller
15711/// guarantees equal length via `read_pinned_mean_vec` + dimension check.
15712fn subtract_mean(v: &[f32], mean: &[f32]) -> Vec<f32> {
15713 debug_assert_eq!(v.len(), mean.len(), "subtract_mean dim mismatch");
15714 v.iter().zip(mean.iter()).map(|(a, b)| *a - *b).collect()
15715}
15716
15717/// 0.8.18 Slice 5 (#5 vector-equivalence probe) — parse the committed 45-probe
15718/// fixture into an ordered `Vec<&str>` (one probe per non-empty, non-`#`-comment
15719/// line). Order is stable so `probe_ordinal` is deterministic across opens.
15720fn vector_equivalence_probes() -> Vec<&'static str> {
15721 VECTOR_EQUIVALENCE_PROBE_FIXTURE
15722 .lines()
15723 .map(str::trim_end)
15724 .filter(|line| {
15725 let t = line.trim_start();
15726 !t.is_empty() && !t.starts_with('#')
15727 })
15728 .collect()
15729}
15730
15731/// 0.8.18 Slice 5 — outcome of the open-time #5 self-check.
15732struct VectorEquivalenceOutcome {
15733 dense_disabled: bool,
15734 reason: Option<String>,
15735}
15736
15737/// 0.8.18 Slice 5 — embed one probe under panic isolation. The probe runs at
15738/// open time on the writer connection BEFORE the projection workers spawn, so a
15739/// caller-supplied embedder that PANICS (or returns an error / a wrong-dimension
15740/// vector) must never wedge `Engine::open`. A panic/error/shape-mismatch yields
15741/// `None`; the CALLERS then fail-SAFE (fix-1 DEFECT #1) — a `None` at population
15742/// or check time means the vector arm cannot be established/verified, so dense is
15743/// REFUSED (`dense_disabled=true`), never silently served. `Engine::open` still
15744/// succeeds (no wedge; ADR-0.6.0 Invariant-5 posture, mirrored open-side).
15745fn probe_embed(embedder: &dyn Embedder, text: &str, dimension: usize) -> Option<Vec<f32>> {
15746 let embedded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| embedder.embed(text)));
15747 match embedded {
15748 Ok(Ok(vector)) if vector.len() == dimension => Some(vector),
15749 _ => None,
15750 }
15751}
15752
15753/// 0.8.18 Slice 5 (#5 vector-equivalence probe KEYSTONE) — the open-time
15754/// self-check. Per `dev/design/0.8.18-slice-0-vector-equivalence-publish-design.md`
15755/// §U1 + `dev/adr/ADR-0.8.18-vector-equivalence-self-check.md`.
15756///
15757/// Runs AFTER open-time mean-recovery/requantize + `ensure_vector_partition`
15758/// (U1-b) so it reads the FINAL live `mean_vec`. Two paths:
15759///
15760/// - **First vector-kind registration** (probe table empty): re-embed the 45
15761/// committed probes with the LIVE embedder and persist their **UN-centered
15762/// f32 reference vectors** + embedder identity (R-VEQ-1). Store f32 ONLY —
15763/// the P1 bits are NEVER persisted (U1-d). Returns `dense_disabled=false`.
15764/// - **Subsequent open** (probe table populated): re-embed the 45 probes and
15765/// assert BOTH dense-pipeline representations against the stored references:
15766/// **(P1)** the Phase-1 mean-centered `embedding_bin` sign-flip count via the
15767/// SAME `vec_quantize_binary(sign(x − mean_vec))` path as
15768/// `build_vector_phase1_sql` (floor = 0, exact); **(P2)** the un-centered
15769/// Phase-2 L2 (`vec_distance_l2` semantics) within `VECTOR_EQUIVALENCE_L2_EPSILON`.
15770/// Divergence beyond EITHER floor ⇒ `dense_disabled=true` (R-VEQ-2/3).
15771///
15772/// Mean-centering is gated by `identity_requires_mean_centering(identity)` ∧
15773/// `mean_pinned`, applied symmetrically to reference + reembed (un-centered
15774/// fallback otherwise; NoopEmbedder no-op) — R-VEQ-3c.
15775///
15776/// Fail-SAFE, never fail-open (0.8.18 Slice 5 fix-1, DEFECT #1): any inability to
15777/// RUN or VERIFY the probe — a probe embed that panics/errors/returns wrong-dim, a
15778/// malformed/missing reference row, an unreadable pinned mean, or a
15779/// `vec_quantize_binary`/L2 SQL failure — yields `dense_disabled=true` with a clear
15780/// reason (refuse the un-verifiable dense/fused arm; the text-only/FTS path still
15781/// serves). `Engine::open` still SUCCEEDS (never wedges on a panicking caller
15782/// embedder). The distinct-identity cross-vendor refusal (`check_embedder_profile`)
15783/// remains the PRIMARY gate; this probe is ADDITIVE-ONLY (R-VEQ-5), but on the
15784/// vector arm it fails CLOSED, not open — same-identity backend drift on an
15785/// un-verifiable arm is exactly what #5 must catch (R-VEQ-4 "loud typed refuse,
15786/// never silent").
15787fn run_vector_equivalence_probe(
15788 connection: &Connection,
15789 embedder: Option<&dyn Embedder>,
15790 identity: &EmbedderIdentity,
15791 mean_pinned: bool,
15792) -> VectorEquivalenceOutcome {
15793 let not_disabled = VectorEquivalenceOutcome { dense_disabled: false, reason: None };
15794
15795 // No live embedder ⇒ no dense arm to guard (EmbedderChoice::None). The probe
15796 // is inert; dense writes/queries already fail with EmbedderNotConfigured.
15797 let Some(embedder) = embedder else { return not_disabled };
15798
15799 // Gate: the probe only engages once the workspace has REGISTERED a vector
15800 // kind (`_fathomdb_vector_kinds` non-empty). A fresh workspace that has never
15801 // committed to vector indexing has no dense arm to guard yet, so the probe
15802 // does ZERO embed work at that open — this keeps `Engine::open` free of the
15803 // 45-probe re-embed on empty/vector-less workspaces (and inert for the
15804 // pathological single-session hang/panic embedder tests, which register their
15805 // kind AFTER open and never reopen).
15806 //
15807 // fix-1 DEFECT #4 — the baseline is established at OPEN, at the first open
15808 // where a vector kind already exists (population path below). This covers BOTH:
15809 // (b) the v18→v19 UPGRADE with pre-existing vector kinds: the baseline is
15810 // captured here, at the first v19 open, from the identity-matched
15811 // embedder (identity is already gated by `check_embedder_profile`, so the
15812 // baseline is the same *claimed* embedder; future backend drift is caught);
15813 // (a) a vector kind registered POST-OPEN in a prior session: the baseline is
15814 // captured at the NEXT open (this gate + population), again identity-gated.
15815 // It is deliberately NOT captured in the registering session's write path: a
15816 // write must NEVER block on the embedder (the async-projection invariant —
15817 // `ac_029_canonical_writes_complete_under_projection_stall` and the PR-9 embed
15818 // watchdog/thread-leak bounds), and 45 synchronous probe embeds there would
15819 // violate it and hang/degrade under a stalling embedder. Serving vector queries
15820 // in the registering session is SAFE regardless: the serving backend IS the
15821 // backend that built those vectors, so there is nothing to diverge from. The
15822 // residual — a same-*identity* backend that drifted between the registering
15823 // session and the next open is not retroactively caught — is IDENTICAL to the
15824 // accepted upgrade residual (R-VEQ-5 additive-only; U3 same-identity candle
15825 // CPU↔CUDA = 0/17280). See `dev/design/0.8.18-slice-5-vector-equivalence-probe.md`.
15826 let vector_kind_registered: bool = connection
15827 .query_row("SELECT EXISTS(SELECT 1 FROM _fathomdb_vector_kinds)", [], |r| r.get(0))
15828 .unwrap_or(false);
15829 if !vector_kind_registered {
15830 return not_disabled;
15831 }
15832
15833 match probe_populate_or_check(connection, embedder, identity, mean_pinned) {
15834 Ok(()) => not_disabled,
15835 Err(reason) => VectorEquivalenceOutcome { dense_disabled: true, reason: Some(reason) },
15836 }
15837}
15838
15839/// 0.8.18 Slice 5 — either PERSIST the baseline (probe table empty) or CHECK
15840/// against it (probe table populated). `Err(reason)` ⇒ refuse the dense arm
15841/// (`dense_disabled=true`); `Ok(())` ⇒ dense served. Fail-SAFE throughout.
15842fn probe_populate_or_check(
15843 connection: &Connection,
15844 embedder: &dyn Embedder,
15845 identity: &EmbedderIdentity,
15846 mean_pinned: bool,
15847) -> Result<(), String> {
15848 let probes = vector_equivalence_probes();
15849 if probes.is_empty() {
15850 // Fail-SAFE: the compiled-in probe fixture is empty ⇒ nothing to verify
15851 // the vector arm against. (Defensive; the fixture is drift-guarded
15852 // non-empty at 45 probes.)
15853 return Err(
15854 "vector-equivalence probe fixture is empty; cannot verify the dense arm".to_string()
15855 );
15856 }
15857
15858 let existing: i64 = connection
15859 .query_row("SELECT COUNT(*) FROM _fathomdb_embed_probe", [], |r| r.get(0))
15860 .map_err(|e| format!("could not read the probe reference table: {e}; cannot verify"))?;
15861
15862 if existing == 0 {
15863 // Populate, then CONFIRM the just-written baseline is complete before
15864 // enabling dense (fix-2 DEFECT #1 residual): a population that committed
15865 // a short/garbled set must never leave dense enabled on the same open.
15866 probe_populate_baseline(connection, embedder, identity, &probes)?;
15867 probe_check_against_baseline(connection, embedder, identity, mean_pinned, &probes)
15868 } else {
15869 probe_check_against_baseline(connection, embedder, identity, mean_pinned, &probes)
15870 }
15871}
15872
15873/// 0.8.18 Slice 5 — FIRST vector-kind registration: persist the 45 UN-centered
15874/// f32 reference vectors (R-VEQ-1; store f32 ONLY, never the P1 bits — U1-d).
15875/// Fail-SAFE (fix-1 DEFECT #1): if the embedder cannot produce EVERY reference
15876/// (panic/error/wrong-dim) no baseline can be established ⇒ `Err` (refuse dense).
15877/// The inserts run in a single transaction so a partial/mismatched set is NEVER
15878/// persisted (rolled back on any error).
15879fn probe_populate_baseline(
15880 connection: &Connection,
15881 embedder: &dyn Embedder,
15882 identity: &EmbedderIdentity,
15883 probes: &[&str],
15884) -> Result<(), String> {
15885 let dimension = identity.dimension as usize;
15886 // Embed ALL probes first; a single failure aborts population (store nothing).
15887 let mut rows: Vec<(i64, &str, Vec<f32>)> = Vec::with_capacity(probes.len());
15888 for (ordinal, probe) in probes.iter().enumerate() {
15889 match probe_embed(embedder, probe, dimension) {
15890 Some(vec) => rows.push((ordinal as i64, probe, vec)),
15891 None => {
15892 return Err(format!(
15893 "embedder failed to produce a reference vector for probe {ordinal}; \
15894 cannot establish a vector-equivalence baseline (dense arm refused)"
15895 ));
15896 }
15897 }
15898 }
15899 // Atomic insert — a partial reference set is never persisted (rollback on
15900 // any error, so a later open cleanly retries population).
15901 let tx = connection
15902 .unchecked_transaction()
15903 .map_err(|e| format!("could not open the probe-baseline transaction: {e}"))?;
15904 for (ordinal, probe, vec) in &rows {
15905 let blob = encode_vector_blob(vec);
15906 tx.execute(
15907 "INSERT OR REPLACE INTO _fathomdb_embed_probe(
15908 probe_ordinal, probe_text, reference_vec,
15909 embedder_name, embedder_revision, dim
15910 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
15911 params![ordinal, probe, blob, identity.name, identity.revision, identity.dimension],
15912 )
15913 .map_err(|e| format!("could not persist the probe baseline: {e}"))?;
15914 }
15915 tx.commit().map_err(|e| format!("could not commit the probe baseline: {e}"))?;
15916 Ok(())
15917}
15918
15919/// 0.8.20 Slice 22 (TC-68) — one row of the stored probe baseline:
15920/// `(probe_ordinal, probe_text, reference_vec, embedder_name, embedder_revision, dim)`.
15921type StoredProbeRow = (i64, String, Vec<u8>, String, String, i64);
15922
15923/// 0.8.20 Slice 22 (TC-68) — length-prefixed field feed for the verdict
15924/// fingerprint. The `u64` length prefix makes the concatenation UNAMBIGUOUS: two
15925/// different input tuples can never produce the same byte stream by sliding a
15926/// delimiter (e.g. name `"ab"` + revision `"c"` vs name `"a"` + revision `"bc"`).
15927fn hash_fingerprint_field(hasher: &mut Sha256, bytes: &[u8]) {
15928 hasher.update((bytes.len() as u64).to_le_bytes());
15929 hasher.update(bytes);
15930}
15931
15932/// 0.8.20 Slice 22 (TC-68) — the **embedder-identity fingerprint** the cached
15933/// equivalence verdict is keyed on: a SHA-256 over EVERY input the probe's verdict
15934/// depends on. Two opens sharing a fingerprint would, by construction, compute the
15935/// same P1/P2 answer, so the second may reuse the first's.
15936///
15937/// The inputs, and why each is load-bearing:
15938///
15939/// - **the recipe tag** ([`VECTOR_EQUIVALENCE_FINGERPRINT_RECIPE`]) — bumping it
15940/// invalidates every cached verdict in the field at once;
15941/// - **`identity.{name, revision, dimension}`** — the nominal embedder. This is
15942/// *defence in depth only*: `check_embedder_profile` already REFUSES the open
15943/// with `EmbedderIdentityMismatch`/`EmbedderDimensionMismatch` before the probe
15944/// is reached, so an identity change is never observed here in practice;
15945/// - **the live pinned `mean_vec`** (and whether centering is applied at all) —
15946/// this one is NOT optional. P1 quantizes through
15947/// `vec_quantize_binary(sign(x − mean_vec))`, so rewriting the pinned mean
15948/// changes the verdict *for the same embedder and the same baseline*. A
15949/// fingerprint over the identity triple alone would be stale by construction,
15950/// because open-time mean-recovery/requantize and the operator `recompute_mean`
15951/// verb both rewrite it;
15952/// - **the committed probe fixture** — a cached verdict computed over a different
15953/// probe set means nothing. (`vector_equivalence_probe_fixture_drift.rs` does
15954/// NOT make this redundant: it pins the engine's copy equal to the embedder
15955/// crate's copy — it guards COPY drift between two committed files, not the
15956/// fixture's content across releases. The stored-baseline completeness check
15957/// below does fail closed first on a fixture edit, so this input is belt-and-
15958/// braces rather than the only guard; it is one hash of a ~3 KB constant.);
15959/// - **both D4 floors** — a verdict that passed under a loose ε must not be
15960/// inherited by a build that tightened it;
15961/// - **the STORED baseline rows, reference blobs included** — the 0.8.18 fix-2
15962/// completeness check pins each row's shape (count, ordinal, text, blob LENGTH,
15963/// identity) but not the blob CONTENT, which the re-embed comparison used to
15964/// catch. Hashing the blobs keeps that external-tamper closure intact at
15965/// negligible cost (~69 KB of SHA-256 against 45 model invocations).
15966fn probe_verification_fingerprint(
15967 identity: &EmbedderIdentity,
15968 mean_vec: Option<&[f32]>,
15969 stored: &[StoredProbeRow],
15970) -> String {
15971 let mut hasher = Sha256::new();
15972 hash_fingerprint_field(&mut hasher, VECTOR_EQUIVALENCE_FINGERPRINT_RECIPE.as_bytes());
15973 hash_fingerprint_field(&mut hasher, identity.name.as_bytes());
15974 hash_fingerprint_field(&mut hasher, identity.revision.as_bytes());
15975 hash_fingerprint_field(&mut hasher, &identity.dimension.to_le_bytes());
15976 match mean_vec {
15977 Some(mean) => {
15978 hash_fingerprint_field(&mut hasher, b"mean-centered");
15979 hash_fingerprint_field(&mut hasher, &encode_vector_blob(mean));
15980 }
15981 None => hash_fingerprint_field(&mut hasher, b"un-centered"),
15982 }
15983 hash_fingerprint_field(&mut hasher, VECTOR_EQUIVALENCE_PROBE_FIXTURE.as_bytes());
15984 hash_fingerprint_field(&mut hasher, &VECTOR_EQUIVALENCE_P1_FLIP_FLOOR.to_le_bytes());
15985 hash_fingerprint_field(&mut hasher, &VECTOR_EQUIVALENCE_L2_EPSILON.to_le_bytes());
15986 hash_fingerprint_field(&mut hasher, &(stored.len() as u64).to_le_bytes());
15987 for (ordinal, probe_text, reference_vec, name, revision, dim) in stored {
15988 hash_fingerprint_field(&mut hasher, &ordinal.to_le_bytes());
15989 hash_fingerprint_field(&mut hasher, probe_text.as_bytes());
15990 hash_fingerprint_field(&mut hasher, reference_vec);
15991 hash_fingerprint_field(&mut hasher, name.as_bytes());
15992 hash_fingerprint_field(&mut hasher, revision.as_bytes());
15993 hash_fingerprint_field(&mut hasher, &dim.to_le_bytes());
15994 }
15995 hasher.finalize().iter().map(|b| format!("{b:02x}")).collect()
15996}
15997
15998/// 0.8.20 Slice 22 (TC-68) — is `fingerprint` the fingerprint under which the
15999/// probe last RAN and PASSED on this workspace?
16000///
16001/// Fail-SAFE against ACCIDENT (R-VEQ-4): **every** failure mode answers `false`,
16002/// which means "run the probe". A missing `_fathomdb_open_state` table, an absent
16003/// row, a non-TEXT value, a truncated or garbled value, a stale fingerprint, any
16004/// SQL error — none of them can be mistaken for a pass.
16005///
16006/// # What a `true` does and does not mean (fix-1, codex §9 round 2 [P1])
16007///
16008/// `true` means: **the fingerprint inputs are unchanged since *some* engine
16009/// recorded a pass.** It does NOT mean "this engine verified this backend", and it
16010/// cannot: the fingerprint is a SHA-256 over deterministic, publicly derivable DB
16011/// and build inputs, so an actor with write access to the file can compute the
16012/// current digest and write it here, skipping the 45-probe verification. This
16013/// marker is not — and cannot be — an authenticated attestation; an embedded
16014/// local-first engine holds no secret with which to authenticate one, and a salt
16015/// would be readable by the same actor.
16016///
16017/// The same actor also defeats the same arm through the **pre-slice** path, by
16018/// re-baselining `_fathomdb_embed_probe`'s `reference_vec` blobs to their drifted
16019/// backend's own output — the probe then runs in full and verifies the drifted
16020/// backend against itself. Measured by
16021/// `tests/tc68_probe_fingerprint_cache.rs::a_forged_stored_baseline_defeats_the_probe_even_when_it_fully_runs`
16022/// (marker deleted, all 45 embeds performed, dense still enabled), with the
16023/// un-forged control caught.
16024///
16025/// **That is the same actor, NOT the same cost, and fix-2 struck the claim that it
16026/// was.** Forging this marker needs only a publicly computable digest — usually the
16027/// value already sitting in the row. Re-baselining additionally needs the target
16028/// backend's 45 exact embeddings, encoded into every row. **So the cache IS a
16029/// cheaper bypass** for a writer of the database file.
16030///
16031/// What bounds it is the ruled residual, not this marker. A same-identity backend
16032/// drift moves no fingerprint input, so a marker recorded by an **honest** earlier
16033/// open already skips the probe and already serves the drifted backend, with no
16034/// forgery anywhere
16035/// (`residual_same_identity_backend_drift_is_not_caught_on_a_cached_open`). Forgery
16036/// adds capability only on an open where no valid marker exists for the *current*
16037/// fingerprint — and a digest is valid only for the state it was computed over, so
16038/// it stops working at the next change to any fingerprint input.
16039///
16040/// The equivalence probe is a **correctness self-check against backend drift, not
16041/// tamper evidence**; `dense_disabled` is not a tamper signal. Threat model, with
16042/// the concession and the bound: §8.4/§8.5 of
16043/// `dev/design/0.8.20-tc68-equivalence-probe-fingerprint-cache.md`.
16044fn probe_verification_is_cached(connection: &Connection, fingerprint: &str) -> bool {
16045 connection
16046 .query_row(
16047 "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
16048 [VECTOR_EQUIVALENCE_VERDICT_CACHE_KEY],
16049 |row| row.get::<_, String>(0),
16050 )
16051 .map(|cached| cached == fingerprint)
16052 .unwrap_or(false)
16053}
16054
16055/// 0.8.20 Slice 22 (TC-68) — record that the probe RAN and PASSED under
16056/// `fingerprint`.
16057///
16058/// A write failure is deliberately SWALLOWED rather than turned into a verdict
16059/// failure. The arm has just been verified, so refusing dense because a marker
16060/// could not be persisted (read-only file, disk full) would be a false refusal;
16061/// and the consequence of the missing marker is simply that the next open re-runs
16062/// the probe — more work, never less. That is the fail-safe direction.
16063fn record_probe_verification(connection: &Connection, fingerprint: &str) {
16064 let _ = connection.execute(
16065 "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
16066 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
16067 params![VECTOR_EQUIVALENCE_VERDICT_CACHE_KEY, fingerprint],
16068 );
16069}
16070
16071/// 0.8.20 Slice 22 (TC-68) — drop any cached verdict.
16072///
16073/// Called on EVERY failure path of the check, so a workspace that could not be
16074/// verified never carries a marker a later open might match. A failing verdict is
16075/// therefore never cached: the probe re-runs each open until it passes again.
16076fn clear_probe_verification(connection: &Connection) {
16077 let _ = connection.execute(
16078 "DELETE FROM _fathomdb_open_state WHERE key = ?1",
16079 [VECTOR_EQUIVALENCE_VERDICT_CACHE_KEY],
16080 );
16081}
16082
16083/// 0.8.18 Slice 5 — SUBSEQUENT open: re-embed the 45 probes and assert BOTH
16084/// dense-pipeline representations against the stored references — **(P1)** the
16085/// mean-centered `embedding_bin` sign-flip count (floor 0, exact) and **(P2)** the
16086/// un-centered Phase-2 L2 (within `VECTOR_EQUIVALENCE_L2_EPSILON`).
16087///
16088/// 0.8.20 Slice 22 (TC-68) — this wrapper adds the failure half of the verdict
16089/// cache: ANY `Err` from the inner check drops the cached marker, so a workspace
16090/// that could not be verified never leaves a stale "verified" marker behind for a
16091/// later open to match. (A failing verdict is never *written*; this also clears a
16092/// marker left by an earlier, passing open whose fingerprint has since changed.)
16093fn probe_check_against_baseline(
16094 connection: &Connection,
16095 embedder: &dyn Embedder,
16096 identity: &EmbedderIdentity,
16097 mean_pinned: bool,
16098 probes: &[&str],
16099) -> Result<(), String> {
16100 let outcome =
16101 probe_check_against_baseline_inner(connection, embedder, identity, mean_pinned, probes);
16102 if outcome.is_err() {
16103 clear_probe_verification(connection);
16104 }
16105 outcome
16106}
16107
16108/// 0.8.18 Slice 5 — the check proper. Fail-SAFE (fix-1 DEFECT #1): a probe embed
16109/// that panics/errors/returns wrong-dim, a malformed/missing reference row, an
16110/// unreadable pinned mean, or a `vec_quantize_binary`/L2 SQL failure each ⇒ `Err`
16111/// (cannot verify ⇒ refuse dense), never a silent skip-and-serve.
16112///
16113/// fix-2 (DEFECT #1 residual): BEFORE the divergence check, the STORED baseline is
16114/// validated to be EXACTLY the committed probe set — the expected row count, a
16115/// contiguous 0-based `probe_ordinal` per committed probe, each `probe_text` equal
16116/// to the committed fixture text at that ordinal, each `reference_vec` a well-formed
16117/// `4 * dim` f32 blob, and the stored embedder identity/dim matching the current
16118/// one. This closes the partial-baseline / external-tamper fail-open (a 44-of-45
16119/// table, or a re-attributed/mangled row, previously verified only the rows present
16120/// or re-embedded a tampered `probe_text` against itself). Any mismatch ⇒ `Err`.
16121///
16122/// 0.8.20 Slice 22 (TC-68) — the 45 re-embeds are CACHED against
16123/// [`probe_verification_fingerprint`]. Note WHERE the cache check sits: after the
16124/// mean resolution and after the fix-2 completeness validation, before the
16125/// re-embed loop. That split is deliberate — everything cheap keeps running on
16126/// EVERY open (so a short, re-attributed, mangled or fixture-mismatched baseline
16127/// still fails closed immediately), and only the expensive part, the 45 model
16128/// invocations, is skipped. The residual this buys is recorded in
16129/// `dev/design/0.8.20-tc68-equivalence-probe-fingerprint-cache.md`.
16130fn probe_check_against_baseline_inner(
16131 connection: &Connection,
16132 embedder: &dyn Embedder,
16133 identity: &EmbedderIdentity,
16134 mean_pinned: bool,
16135 probes: &[&str],
16136) -> Result<(), String> {
16137 let dimension = identity.dimension as usize;
16138
16139 // Resolve the live mean. Fail-SAFE: if centering is required + pinned but the
16140 // mean cannot be read, we cannot reproduce `embedding_bin` ⇒ refuse (P1
16141 // un-verifiable). NoopEmbedder / no-pin ⇒ un-centered on BOTH sides (R-VEQ-3c).
16142 let mean_vec = if identity_requires_mean_centering(identity) && mean_pinned {
16143 match read_pinned_mean_vec(connection, identity.dimension) {
16144 Ok(Some(mean)) => Some(mean),
16145 Ok(None) => {
16146 return Err("mean-centering is required and pinned but mean_vec is absent; \
16147 cannot verify P1 (dense arm refused)"
16148 .to_string());
16149 }
16150 Err(_) => {
16151 return Err(
16152 "could not read the pinned mean_vec; cannot verify P1 (dense arm refused)"
16153 .to_string(),
16154 );
16155 }
16156 }
16157 } else {
16158 None
16159 };
16160
16161 let mut stmt = connection
16162 .prepare(
16163 "SELECT probe_ordinal, probe_text, reference_vec, embedder_name, embedder_revision, dim \
16164 FROM _fathomdb_embed_probe ORDER BY probe_ordinal",
16165 )
16166 .map_err(|e| format!("could not read the stored probe references: {e}; cannot verify"))?;
16167 let stored: Vec<StoredProbeRow> = stmt
16168 .query_map([], |row| {
16169 Ok((
16170 row.get::<_, i64>(0)?,
16171 row.get::<_, String>(1)?,
16172 row.get::<_, Vec<u8>>(2)?,
16173 row.get::<_, String>(3)?,
16174 row.get::<_, String>(4)?,
16175 row.get::<_, i64>(5)?,
16176 ))
16177 })
16178 .and_then(|rows| rows.collect::<rusqlite::Result<Vec<_>>>())
16179 .map_err(|e| format!("could not read the stored probe references: {e}; cannot verify"))?;
16180
16181 // fix-2 (DEFECT #1 residual) — COMPLETENESS validation of the STORED baseline.
16182 // `COUNT(*) > 0` is NOT proof of a complete, trustworthy baseline: a partially
16183 // populated or externally-tampered probe table (44 of 45 rows, a gap/dupe in the
16184 // ordinals, a mangled reference blob, a mismatched probe_text, or a foreign
16185 // embedder identity) is UNVERIFIABLE stored state. The prior code re-embedded
16186 // the STORED probe_text and compared it to its OWN reference, so a tampered
16187 // probe_text verified against itself and a short table verified only the rows
16188 // present — both fail-OPEN. Atomic population stops the ENGINE from writing a
16189 // partial set; this closes external corruption, a manual edit, and a future
16190 // migration bug the engine did not author. Any mismatch ⇒ fail CLOSED (dense
16191 // refused); the text-only/FTS path still serves. The stored baseline must be
16192 // EXACTLY the committed probe set, in order, under the current identity.
16193 if stored.len() != probes.len() {
16194 return Err(format!(
16195 "the probe reference table has {} rows but the committed fixture defines {}; \
16196 the stored baseline is incomplete or corrupt — cannot verify the dense arm (refused)",
16197 stored.len(),
16198 probes.len()
16199 ));
16200 }
16201 for (idx, (ordinal, probe_text, ref_blob, name, revision, dim)) in stored.iter().enumerate() {
16202 // Contiguous 0-based ordinals, one per committed probe (no gaps/dupes).
16203 if *ordinal != idx as i64 {
16204 return Err(format!(
16205 "probe reference ordinals are non-contiguous (row {idx} carries ordinal {ordinal}); \
16206 the stored baseline is corrupt — cannot verify the dense arm (refused)"
16207 ));
16208 }
16209 // The stored text MUST be the committed fixture text at this ordinal —
16210 // otherwise a tampered probe_text re-embeds and verifies against ITSELF,
16211 // masking drift (the exact fail-open this fix closes).
16212 if probe_text != probes[idx] {
16213 return Err(format!(
16214 "probe reference {ordinal} text does not match the committed fixture; \
16215 the stored baseline is tampered or corrupt — cannot verify the dense arm (refused)"
16216 ));
16217 }
16218 // Well-formed f32[dim] reference (4*dim little-endian bytes).
16219 if ref_blob.len() != dimension * 4 {
16220 return Err(format!(
16221 "probe reference {ordinal} is malformed (len {} != {}); \
16222 cannot verify the dense arm (refused)",
16223 ref_blob.len(),
16224 dimension * 4
16225 ));
16226 }
16227 // The stored embedder identity/dim must match the CURRENT expected identity
16228 // (defence-in-depth beyond `check_embedder_profile`: catches a baseline row
16229 // re-attributed to a foreign embedder by external edit/migration).
16230 if *dim != identity.dimension as i64
16231 || name != &identity.name
16232 || revision != &identity.revision
16233 {
16234 return Err(format!(
16235 "probe reference {ordinal} was captured under embedder {name}/{revision}/dim={dim} \
16236 but the current embedder is {}/{}/dim={}; the stored baseline does not match — \
16237 cannot verify the dense arm (refused)",
16238 identity.name, identity.revision, identity.dimension
16239 ));
16240 }
16241 }
16242
16243 // 0.8.20 Slice 22 (TC-68) — the CACHE gate. Everything above this line ran on
16244 // this open and still fails closed; everything below it is the 45 model
16245 // invocations that made `Engine::open` cost a flat 45 embeds FOREVER (measured
16246 // at `94bb33ef`: 0 with no enrolled kind, 90 on the one-time population open,
16247 // 45 on every open thereafter — independent of the enrolled-kind count, since
16248 // the probe gate is an `EXISTS` and the body never iterates kinds).
16249 //
16250 // If the probe already RAN and PASSED under this exact fingerprint, re-running
16251 // it is a pure re-computation of a known answer, so the verdict is reused.
16252 // Fail-SAFE: `probe_verification_is_cached` answers `false` for every failure
16253 // mode — missing table, absent row, garbled value, SQL error — so an
16254 // unreadable cache RUNS the probe, it never short-circuits to trusting it.
16255 let fingerprint = probe_verification_fingerprint(identity, mean_vec.as_deref(), &stored);
16256 if probe_verification_is_cached(connection, &fingerprint) {
16257 return Ok(());
16258 }
16259
16260 let mut total_flips: u64 = 0;
16261 let mut max_l2: f32 = 0.0;
16262 let mut worst_probe: Option<String> = None;
16263
16264 for (ordinal, probe_text, ref_blob, _, _, _) in &stored {
16265 let reference = decode_vector_blob(ref_blob);
16266 let reembed = probe_embed(embedder, probe_text, dimension).ok_or_else(|| {
16267 format!(
16268 "embedder failed/panicked re-embedding probe {ordinal}; \
16269 cannot verify the dense arm (refused)"
16270 )
16271 })?;
16272
16273 // (P2) un-centered L2 — `vec_distance_l2(embedding, vec_f32(query))`.
16274 let l2 = l2_distance(&reembed, &reference);
16275 if l2 > max_l2 {
16276 max_l2 = l2;
16277 worst_probe = Some(probe_text.clone());
16278 }
16279
16280 // (P1) mean-centered Phase-1 flip count — same
16281 // `vec_quantize_binary(sign(x − mean_vec))` path as build_vector_phase1_sql.
16282 let (ref_c, reembed_c) = match &mean_vec {
16283 Some(mean) => (subtract_mean(&reference, mean), subtract_mean(&reembed, mean)),
16284 None => (reference.clone(), reembed.clone()),
16285 };
16286 let ref_bits = quantize_binary_via_sql(connection, &ref_c).ok_or_else(|| {
16287 format!("vec_quantize_binary SQL failed for probe {ordinal}; cannot verify P1")
16288 })?;
16289 let reembed_bits = quantize_binary_via_sql(connection, &reembed_c).ok_or_else(|| {
16290 format!("vec_quantize_binary SQL failed for probe {ordinal}; cannot verify P1")
16291 })?;
16292 total_flips = total_flips.saturating_add(hamming_bytes(&ref_bits, &reembed_bits));
16293 }
16294
16295 let p1_tripped = total_flips > VECTOR_EQUIVALENCE_P1_FLIP_FLOOR;
16296 let p2_tripped = max_l2 > VECTOR_EQUIVALENCE_L2_EPSILON;
16297 if p1_tripped || p2_tripped {
16298 let probe_hint = worst_probe.as_deref().unwrap_or("<unknown>");
16299 return Err(format!(
16300 "P1 mean-centered embedding_bin flips={total_flips} (floor={VECTOR_EQUIVALENCE_P1_FLIP_FLOOR}), \
16301 P2 max un-centered L2={max_l2:.3e} (epsilon={VECTOR_EQUIVALENCE_L2_EPSILON:.3e}); \
16302 worst probe {probe_hint:?}"
16303 ));
16304 }
16305
16306 // 0.8.20 Slice 22 (TC-68) — the probe RAN and PASSED; record the fingerprint
16307 // so the next open with identical inputs need not repeat it. Only a verdict
16308 // this engine reached itself is ever written (the failure paths above return
16309 // early, and the wrapper clears any prior marker on them).
16310 record_probe_verification(connection, &fingerprint);
16311 Ok(())
16312}
16313
16314/// 0.8.18 Slice 5 — un-centered Euclidean (L2) distance, matching the
16315/// `vec_distance_l2` semantics used by the Phase-2 rerank.
16316fn l2_distance(a: &[f32], b: &[f32]) -> f32 {
16317 a.iter().zip(b.iter()).map(|(x, y)| (x - y) * (x - y)).sum::<f32>().sqrt()
16318}
16319
16320/// 0.8.18 Slice 5 — produce the packed 1-bit `embedding_bin` blob for a (possibly
16321/// mean-centered) f32 vector via the SAME SQL `vec_quantize_binary` the production
16322/// Phase-1 path uses, so the probe's bits are byte-equal to the engine's
16323/// `embedding_bin` production. `None` on any SQL/serialization error.
16324fn quantize_binary_via_sql(connection: &Connection, vector: &[f32]) -> Option<Vec<u8>> {
16325 let json = serde_json::to_string(vector).ok()?;
16326 connection
16327 .query_row("SELECT vec_quantize_binary(vec_f32(?1))", [json], |row| {
16328 row.get::<_, Vec<u8>>(0)
16329 })
16330 .ok()
16331}
16332
16333/// 0.8.18 Slice 5 — Hamming distance (differing bit count) between two equal-length
16334/// packed bit blobs. Unequal lengths ⇒ count every bit of the length delta as
16335/// differing (a shape divergence is a divergence).
16336fn hamming_bytes(a: &[u8], b: &[u8]) -> u64 {
16337 let common = a.len().min(b.len());
16338 let mut flips: u64 = 0;
16339 for i in 0..common {
16340 flips += u64::from((a[i] ^ b[i]).count_ones());
16341 }
16342 let extra = a.len().abs_diff(b.len());
16343 flips + (extra as u64) * 8
16344}
16345
16346/// Maps the writer-facing `kind` value to the locked Pack 1
16347/// `source_type` partition-key vocabulary. Must stay in lockstep with
16348/// the CASE WHEN inlined in migration step 9
16349/// (`fathomdb-schema/src/lib.rs`); the drift-detection unit test in
16350/// this module's `tests` mod enforces that. Per
16351/// `dev/design/0.7.0-vector-quant-pack1.md` D3.
16352fn resolve_source_type(kind: &str) -> Result<&'static str, EngineError> {
16353 Ok(match kind {
16354 "email" => "email",
16355 "article" => "article",
16356 "paper" => "paper",
16357 "meeting" => "meeting",
16358 "note" => "note",
16359 "todo" => "todo",
16360 // Synthetic AC-013 test fixture; coerced so the 6-value HITL lock holds.
16361 "doc" => "article",
16362 // G11 (Slice 15) — edge-body projection; separate `source_type` partition
16363 // key distinguishes edge vectors from node vectors in `vector_default`.
16364 "edge_fact" => "edge_fact",
16365 _ => return Err(EngineError::Storage),
16366 })
16367}
16368
16369/// 0.8.20 Slice 20c fix-2 (codex §9 [P1]) — **can the vector writer COMMIT a row
16370/// of this kind?** The ONE definition of the vector pipeline's kind domain, shared
16371/// by every enrolment path.
16372///
16373/// [`commit_projection_outcomes`] resolves `kind -> source_type` through
16374/// [`resolve_source_type`] and returns `Err` for anything outside its locked
16375/// vocabulary — *before* it records the row's terminal. `PreparedWrite::Node`, by
16376/// contrast, accepts ANY non-empty `kind` (`validate_write` constrains the body,
16377/// the identity and the validity window, never the kind against that vocabulary),
16378/// so a corpus can legitimately hold e.g. an `"invoice"` node.
16379///
16380/// Enrolling such a kind is therefore a permanent LIVENESS WEDGE for the whole
16381/// workspace: the scheduler picks the row up, the commit fails, no terminal is
16382/// ever written, the scanner re-enqueues it forever, `drain` burns its entire
16383/// timeout into [`EngineError::Scheduler`] and `dense_readiness` sticks on
16384/// `embedding` — starving the rows whose kinds ARE commit-able along with it.
16385///
16386/// So enrolment is RESTRICTED to this predicate rather than the vector writer
16387/// being taught arbitrary kinds (which would reach into `resolve_source_type`'s
16388/// locked Pack-1 partition-key semantics — `dev/design/0.7.0-vector-quant-pack1.md`
16389/// D3, a HITL lock). A non-commit-able kind simply gets NO dense arm, which is
16390/// precisely its pre-slice status quo; it is deliberately **not** a new typed
16391/// error and adds no governed surface.
16392///
16393/// It DELEGATES to `resolve_source_type` instead of restating the list. A
16394/// hand-copied second vocabulary is the TC-56 defect shape (a mirror that silently
16395/// drifts from its original), and here the drift would be silent in the worst
16396/// direction: a kind added to `resolve_source_type` but missing from a copied
16397/// filter would just never be embedded.
16398fn kind_is_vector_committable(kind: &str) -> bool {
16399 resolve_source_type(kind).is_ok()
16400}
16401
16402/// G11 (Slice 15) — derive a stable hex-encoded sha256 logical_id from a
16403/// `(kind, name)` pair. Both inputs are lowercased before hashing so that
16404/// entity identity is case-insensitive (`"Alice"` == `"alice"`). The
16405/// canonical form is `sha256("<kind>:<name>")` — identical to the
16406/// ADR-0.8.1-byo-llm derivation rule.
16407///
16408/// fix-34 [P1]: because `:` is the delimiter, a `:` in `kind` would let the
16409/// split point move and collide two distinct `(kind, name)` pairs onto one
16410/// identity (e.g. `("a:b","c")` and `("a","b:c")` both hash `"a:b:c"`),
16411/// silently dropping one entity via batch dedup / G0 supersession. An empty
16412/// `name` collapses every name-less entity of a kind onto `sha256("<kind>:")`.
16413/// We reject both at the boundary; this preserves the ADR derivation rule
16414/// (a colon-free `kind` makes the first `:` an unambiguous delimiter, so a `:`
16415/// in `name` stays safe — edge keys deliberately rely on that).
16416fn derive_logical_id(kind: &str, name: &str) -> Result<String, EngineError> {
16417 if kind.contains(':') || name.is_empty() {
16418 return Err(EngineError::Extractor);
16419 }
16420 let input = format!("{}:{}", kind.to_lowercase(), name.to_lowercase());
16421 let mut hasher = Sha256::new();
16422 hasher.update(input.as_bytes());
16423 // digest 0.11 returns `hybrid_array::Array`, which (unlike the old
16424 // `GenericArray`) does not implement `LowerHex`. Format the bytes
16425 // explicitly — byte-identical lowercase, zero-padded hex to the prior
16426 // `{:x}` rendering, preserving the load-bearing logical-id derivation.
16427 Ok(hasher.finalize().iter().map(|b| format!("{b:02x}")).collect())
16428}
16429
16430/// Cause-A (0.8.11.2) / C-2 (0.8.19, TC-8) — derive the typed **stable hit-id**
16431/// ([`IdSpace`]) carried on [`SearchHit::id`] for cross-session real-gold keying.
16432///
16433/// The stable id is the active canonical node's `logical_id` — the post-G0
16434/// supersession-stable identity, preserved across re-projection/re-ingest by the
16435/// tombstone-then-insert contract (whereas the engine-internal `write_cursor` is
16436/// reassigned on every re-ingest). When `logical_id` is NULL — the doc-seeded
16437/// node case, the *dominant* corpus hit type today — we fall back to a content
16438/// hash of the body so doc hits still carry a re-ingest-survivable key.
16439///
16440/// The result is a typed [`IdSpace`]; its `to_prefixed()` reproduces the pre-C-2
16441/// `stable_id` string byte-for-byte so real-gold keying is a no-op:
16442/// - [`IdSpace::logical`] (`"l:<logical_id>"`) — entities + edges (graph-arm,
16443/// vector-node, and edge hits when `logical_id` is present);
16444/// - [`IdSpace::content`] (`"h:<sha256(body)>"`) — doc nodes with NULL
16445/// `logical_id`, and any branch that cannot cheaply resolve a `logical_id`.
16446///
16447/// Behaviour-neutral: the value never participates in ranking/scoring (same
16448/// additive posture as `source_id` / `ce_score`).
16449fn derive_stable_id(logical_id: Option<&str>, body: &str) -> IdSpace {
16450 match logical_id {
16451 Some(lid) if !lid.is_empty() => IdSpace::logical(lid),
16452 _ => {
16453 let mut hasher = Sha256::new();
16454 hasher.update(body.as_bytes());
16455 IdSpace::content(
16456 hasher.finalize().iter().map(|b| format!("{b:02x}")).collect::<String>(),
16457 )
16458 }
16459 }
16460}
16461
16462/// fix-34 [P2]: dedup a batch of [`PreparedWrite`]s by `logical_id`, keeping the
16463/// first occurrence. Shared by the entity and edge arms of the BYO-LLM ingest
16464/// path so a harness that returns the same node/edge twice in one response does
16465/// not write a row that immediately supersedes its sibling.
16466///
16467/// **TC-32 (0.8.20) — single-provenance entity dedupe is INTENTIONAL and
16468/// ACCEPTED.** Because dedupe keeps the FIRST occurrence, same-name entities
16469/// collapse onto one `logical_id` row that carries only the FIRST document's
16470/// `source_id`; erasing a later document therefore does not remove the shared
16471/// entity row. The HITL has ruled this acceptable for now and explicitly
16472/// declined a multi-source-provenance model. Tracked as TC-32 — do not "fix"
16473/// this by changing dedupe behaviour without a fresh decision.
16474fn dedup_prepared_by_logical_id(batch: Vec<PreparedWrite>) -> Vec<PreparedWrite> {
16475 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
16476 batch
16477 .into_iter()
16478 .filter(|w| match w {
16479 PreparedWrite::Node { logical_id: Some(id), .. }
16480 | PreparedWrite::Edge { logical_id: Some(id), .. } => seen.insert(id.clone()),
16481 _ => true,
16482 })
16483 .collect()
16484}
16485
16486/// 0.8.6 Slice 5 (ADR-0.8.6) — the family of caller-supplied provider tasks that
16487/// ride the one NDJSON-over-stdio transport. Each task maps to a wire protocol
16488/// string `fathomdb.<task>.v1` and a task discriminator name. `Extract` shipped
16489/// in 0.8.6; `Consolidate` (0.8.12 Slice 15, OPP-2) is the SECOND consumer of
16490/// this one transport — it adds only a variant, a payload, and an `EngineError`
16491/// leaf, WITHOUT a second handshake or a second transport.
16492#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16493enum ProviderTask {
16494 Extract,
16495 /// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — consolidation / recency provider.
16496 Consolidate,
16497}
16498
16499impl ProviderTask {
16500 /// The wire task discriminator, e.g. `"extract"`. Used for `supported_tasks`
16501 /// negotiation and as the request envelope `type`.
16502 fn name(self) -> &'static str {
16503 match self {
16504 ProviderTask::Extract => "extract",
16505 ProviderTask::Consolidate => "consolidate",
16506 }
16507 }
16508
16509 /// The protocol string FathomDB sends in `hello`/requests and requires in
16510 /// `ready`. For `Extract` this is the UNCHANGED `fathomdb.extract.v1` —
16511 /// byte-identical back-compat for existing ELPS harnesses (ADR-0.8.6 §2.1).
16512 /// For `Consolidate` it is `fathomdb.consolidate.v1` (ADR-0.8.12 §2).
16513 fn protocol(self) -> &'static str {
16514 match self {
16515 ProviderTask::Extract => "fathomdb.extract.v1",
16516 ProviderTask::Consolidate => "fathomdb.consolidate.v1",
16517 }
16518 }
16519}
16520
16521/// 0.8.6 Slice 5 (ADR-0.8.6) — an open provider transport session: the spawned
16522/// caller subprocess, the buffered stdin writer, the detached stdout-drain
16523/// channel, the bounded-recv timeout, and the negotiated handshake state
16524/// (`model` provenance + `max_docs_per_request`). One session serves one task
16525/// family; the `request`/framing is identical across tasks. `Drop` reaps the
16526/// child (sends stdin EOF via the writer field's own drop, then kill/wait),
16527/// replacing the prior explicit outer kill/wait.
16528struct ProviderSession {
16529 task: ProviderTask,
16530 child: std::process::Child,
16531 writer: std::io::BufWriter<std::process::ChildStdin>,
16532 line_rx: Receiver<std::io::Result<String>>,
16533 io_timeout: Duration,
16534 /// `ready.model`, recorded as output-row provenance (`extractor_model_id`).
16535 model: Option<String>,
16536 max_docs_per_request: usize,
16537}
16538
16539impl Drop for ProviderSession {
16540 fn drop(&mut self) {
16541 // The detached stdout-drain thread exits when the child's stdout closes;
16542 // kill() guarantees that even for a child that ignores stdin EOF. The
16543 // `writer` field drops after this (declaration order) sending EOF too.
16544 let _ = self.child.kill();
16545 let _ = self.child.wait();
16546 }
16547}
16548
16549impl ProviderSession {
16550 /// Run the `hello` → `ready` handshake and `supported_tasks` negotiation.
16551 /// Validates protocol + schema_version (fix-23 [P2]); rejects a zero
16552 /// `max_docs_per_request` (fix-1 [P2]); and, when the harness advertises
16553 /// `supported_tasks`, refuses to proceed unless this session's task is in it.
16554 /// When `supported_tasks` is absent, the harness is assumed to serve the
16555 /// requested task (back-compat: existing extract-only harnesses unchanged).
16556 fn handshake(&mut self) -> Result<(), EngineError> {
16557 let protocol = self.task.protocol();
16558 let hello = serde_json::json!({
16559 "protocol": protocol,
16560 "type": "hello",
16561 "schema_version": 1,
16562 });
16563 let hello_line = serde_json::to_string(&hello).map_err(|_| EngineError::Extractor)?;
16564 writeln!(self.writer, "{hello_line}").map_err(|_| EngineError::Extractor)?;
16565 self.writer.flush().map_err(|_| EngineError::Extractor)?;
16566
16567 let line = recv_extractor_line(&self.line_rx, self.io_timeout)?;
16568 let ready: Value = serde_json::from_str(line.trim()).map_err(|_| EngineError::Extractor)?;
16569 // fix-23 [P2]: validate protocol + schema_version in the ready message per ADR.
16570 if ready.get("type").and_then(|v| v.as_str()) != Some("ready")
16571 || ready.get("protocol").and_then(|v| v.as_str()) != Some(protocol)
16572 || ready.get("schema_version").and_then(|v| v.as_u64()) != Some(1)
16573 {
16574 return Err(EngineError::Extractor);
16575 }
16576
16577 // 0.8.6 Slice 5 (ADR-0.8.6 §2.2): additive, optional `supported_tasks`
16578 // negotiation. If present, the harness must advertise this session's task
16579 // or FathomDB refuses to dispatch it. If absent, default to "serves the
16580 // requested task" so extract-only harnesses keep working unchanged.
16581 if let Some(supported) = ready.get("supported_tasks").and_then(|v| v.as_array()) {
16582 let task_name = self.task.name();
16583 let advertised = supported.iter().any(|t| t.as_str() == Some(task_name));
16584 if !advertised {
16585 return Err(EngineError::Extractor);
16586 }
16587 }
16588
16589 self.model = ready.get("model").and_then(|v| v.as_str()).map(|s| s.to_string());
16590 let max_docs =
16591 ready.get("max_docs_per_request").and_then(|v| v.as_u64()).unwrap_or(8) as usize;
16592 // fix-1 [P2]: reject zero max_docs_per_request to prevent chunks(0) panic.
16593 if max_docs == 0 {
16594 return Err(EngineError::Extractor);
16595 }
16596 self.max_docs_per_request = max_docs;
16597 Ok(())
16598 }
16599
16600 /// Send one framed request for this session's task and receive its matching
16601 /// response. `payload` carries the task-specific fields; the envelope keys
16602 /// (`protocol`, `type`, `request_id`) are added here. The response must have
16603 /// `type == "result"` and a matching `request_id` (fix-24 [P2]); anything
16604 /// else (error, wrong id, missing type) is a protocol fault. For `Extract`
16605 /// the serialized request bytes are identical to the pre-0.8.6 path (serde_json
16606 /// serializes map keys sorted, independent of insertion order).
16607 fn request(
16608 &mut self,
16609 request_id: &str,
16610 payload: Vec<(String, Value)>,
16611 ) -> Result<Value, EngineError> {
16612 let mut req = serde_json::Map::new();
16613 req.insert("protocol".to_string(), Value::from(self.task.protocol()));
16614 req.insert("type".to_string(), Value::from(self.task.name()));
16615 req.insert("request_id".to_string(), Value::from(request_id));
16616 for (k, v) in payload {
16617 req.insert(k, v);
16618 }
16619 let req_line =
16620 serde_json::to_string(&Value::Object(req)).map_err(|_| EngineError::Extractor)?;
16621 writeln!(self.writer, "{req_line}").map_err(|_| EngineError::Extractor)?;
16622 self.writer.flush().map_err(|_| EngineError::Extractor)?;
16623
16624 let result_line = recv_extractor_line(&self.line_rx, self.io_timeout)?;
16625 let result: Value =
16626 serde_json::from_str(result_line.trim()).map_err(|_| EngineError::Extractor)?;
16627 let resp_type = result.get("type").and_then(|v| v.as_str());
16628 let resp_id = result.get("request_id").and_then(|v| v.as_str());
16629 if resp_type != Some("result") || resp_id != Some(request_id) {
16630 return Err(EngineError::Extractor);
16631 }
16632 Ok(result)
16633 }
16634}
16635
16636/// fix-35 [P2]: BYO-LLM extractor I/O timeout. Defaults to 300s to accommodate
16637/// slow LLM harnesses; override (in milliseconds) via
16638/// `FATHOMDB_EXTRACTOR_TIMEOUT_MS` (tests use this to exercise the hung-harness
16639/// path quickly).
16640fn extractor_io_timeout() -> Duration {
16641 std::env::var("FATHOMDB_EXTRACTOR_TIMEOUT_MS")
16642 .ok()
16643 .and_then(|s| s.parse::<u64>().ok())
16644 .map(Duration::from_millis)
16645 .unwrap_or_else(|| Duration::from_secs(300))
16646}
16647
16648/// fix-35 [P1/P2]: receive one line from the stdout reader thread, bounded by
16649/// `timeout`. A timeout, a closed channel (reader thread ended / child EOF), or
16650/// an underlying io error all map to [`EngineError::Extractor`].
16651fn recv_extractor_line(
16652 rx: &Receiver<std::io::Result<String>>,
16653 timeout: Duration,
16654) -> Result<String, EngineError> {
16655 match rx.recv_timeout(timeout) {
16656 Ok(Ok(line)) => Ok(line),
16657 _ => Err(EngineError::Extractor),
16658 }
16659}
16660
16661fn map_runtime_embedder_error(err: RuntimeEmbedderError) -> EngineError {
16662 match err {
16663 RuntimeEmbedderError::Failed { .. } | RuntimeEmbedderError::Timeout => {
16664 EngineError::Embedder
16665 }
16666 }
16667}
16668
16669fn default_embedder_identity() -> EmbedderIdentity {
16670 EmbedderIdentity::new(
16671 DEFAULT_EMBEDDER_NAME,
16672 DEFAULT_EMBEDDER_REVISION,
16673 DEFAULT_EMBEDDER_DIMENSION,
16674 )
16675}
16676
16677fn check_embedder_profile(
16678 connection: &Connection,
16679 supplied: &EmbedderIdentity,
16680) -> Result<bool, EngineOpenError> {
16681 // Returns `true` iff `_fathomdb_embedder_profiles.mean_vec IS NOT NULL`
16682 // for the default profile (and its byte length matches `4 * dimension`
16683 // per `dev/design/embedder.md` §0.2). EU-5a2: column lands in step 10.
16684 let mut statement = match connection.prepare(
16685 "SELECT name, revision, dimension, mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
16686 ) {
16687 Ok(statement) => statement,
16688 Err(_) => return Ok(false),
16689 };
16690 let mut rows = statement.query([]).map_err(|_| {
16691 EngineOpenError::Corruption(CorruptionDetail {
16692 kind: CorruptionKind::EmbedderIdentityDrift,
16693 stage: OpenStage::EmbedderIdentity,
16694 locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
16695 recovery_hint: RecoveryHint {
16696 code: "E_CORRUPT_EMBEDDER_IDENTITY",
16697 doc_anchor: "design/recovery.md#embedder-identity-drift",
16698 },
16699 })
16700 })?;
16701
16702 let Some(row) = rows.next().map_err(|_| {
16703 EngineOpenError::Corruption(CorruptionDetail {
16704 kind: CorruptionKind::EmbedderIdentityDrift,
16705 stage: OpenStage::EmbedderIdentity,
16706 locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
16707 recovery_hint: RecoveryHint {
16708 code: "E_CORRUPT_EMBEDDER_IDENTITY",
16709 doc_anchor: "design/recovery.md#embedder-identity-drift",
16710 },
16711 })
16712 })?
16713 else {
16714 connection
16715 .execute(
16716 "INSERT INTO _fathomdb_embedder_profiles(profile, name, revision, dimension)
16717 VALUES(?1, ?2, ?3, ?4)",
16718 params![
16719 DEFAULT_VECTOR_PROFILE,
16720 supplied.name,
16721 supplied.revision,
16722 supplied.dimension
16723 ],
16724 )
16725 .map_err(|_| EngineOpenError::Io {
16726 message: "could not persist embedder profile".to_string(),
16727 })?;
16728 return Ok(false);
16729 };
16730
16731 let stored_name = row.get::<_, String>(0).map_err(|_| {
16732 EngineOpenError::Corruption(CorruptionDetail {
16733 kind: CorruptionKind::EmbedderIdentityDrift,
16734 stage: OpenStage::EmbedderIdentity,
16735 locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
16736 recovery_hint: RecoveryHint {
16737 code: "E_CORRUPT_EMBEDDER_IDENTITY",
16738 doc_anchor: "design/recovery.md#embedder-identity-drift",
16739 },
16740 })
16741 })?;
16742 let stored_revision = row.get::<_, String>(1).map_err(|_| {
16743 EngineOpenError::Corruption(CorruptionDetail {
16744 kind: CorruptionKind::EmbedderIdentityDrift,
16745 stage: OpenStage::EmbedderIdentity,
16746 locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
16747 recovery_hint: RecoveryHint {
16748 code: "E_CORRUPT_EMBEDDER_IDENTITY",
16749 doc_anchor: "design/recovery.md#embedder-identity-drift",
16750 },
16751 })
16752 })?;
16753 let dimension = row.get::<_, u32>(2).map_err(|_| {
16754 EngineOpenError::Corruption(CorruptionDetail {
16755 kind: CorruptionKind::EmbedderIdentityDrift,
16756 stage: OpenStage::EmbedderIdentity,
16757 locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
16758 recovery_hint: RecoveryHint {
16759 code: "E_CORRUPT_EMBEDDER_IDENTITY",
16760 doc_anchor: "design/recovery.md#embedder-identity-drift",
16761 },
16762 })
16763 })?;
16764
16765 let stored = EmbedderIdentity::new(stored_name, stored_revision, dimension);
16766
16767 if stored.name != supplied.name || stored.revision != supplied.revision {
16768 return Err(EngineOpenError::EmbedderIdentityMismatch {
16769 stored,
16770 supplied: supplied.clone(),
16771 });
16772 }
16773 if dimension != supplied.dimension {
16774 return Err(EngineOpenError::EmbedderDimensionMismatch {
16775 stored: dimension,
16776 supplied: supplied.dimension,
16777 });
16778 }
16779
16780 // EU-5a2 / `dev/design/embedder.md` §0.2 invariant: if `mean_vec` is
16781 // populated, byte length MUST equal `4 * dimension`. Debug builds
16782 // assert; release builds fail closed via EmbedderIdentityMismatch
16783 // (the same fail-closed channel the rest of profile drift takes).
16784 let mean_vec: Option<Vec<u8>> = row.get::<_, Option<Vec<u8>>>(3).map_err(|_| {
16785 EngineOpenError::Corruption(CorruptionDetail {
16786 kind: CorruptionKind::EmbedderIdentityDrift,
16787 stage: OpenStage::EmbedderIdentity,
16788 locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
16789 recovery_hint: RecoveryHint {
16790 code: "E_CORRUPT_EMBEDDER_IDENTITY",
16791 doc_anchor: "design/recovery.md#embedder-identity-drift",
16792 },
16793 })
16794 })?;
16795 let pinned = match mean_vec {
16796 Some(bytes) => {
16797 let expected_len = (dimension as usize).saturating_mul(4);
16798 // `dev/design/embedder.md` §0.2 invariant: when populated,
16799 // `mean_vec` byte length MUST equal `4 * dimension`. Fail
16800 // closed via the existing identity-drift channel in both
16801 // debug and release builds — tests deliberately poke
16802 // malformed values to exercise this branch.
16803 if bytes.len() != expected_len {
16804 return Err(EngineOpenError::EmbedderIdentityMismatch {
16805 stored,
16806 supplied: supplied.clone(),
16807 });
16808 }
16809 true
16810 }
16811 None => false,
16812 };
16813
16814 Ok(pinned)
16815}
16816
16817#[derive(Clone, Debug, Eq, PartialEq)]
16818enum WritePlan {
16819 Node,
16820 Edge,
16821 AppendOnlyLog,
16822 LatestState,
16823 AdminSchema,
16824}
16825
16826fn validate_batch(
16827 connection: &Connection,
16828 batch: &[PreparedWrite],
16829) -> Result<Vec<WritePlan>, EngineError> {
16830 batch.iter().map(|write| validate_write(connection, write)).collect()
16831}
16832
16833fn collect_projection_jobs(
16834 connection: &Connection,
16835 batch: &[PreparedWrite],
16836) -> Result<Vec<ProjectionJob>, EngineError> {
16837 let mut jobs = Vec::new();
16838 for write in batch {
16839 if let PreparedWrite::Node { kind, body, .. } = write {
16840 // 0.8.20 Slice 20c — this probe decides whether `notify_new_work` is
16841 // called, so `Engine::enrol_batch_vector_kinds` MUST already have run
16842 // on this batch: a kind enrolled after this point would be enqueued in
16843 // the database with the dispatcher left asleep on
16844 // `pending_scan == false`, and because `drain` is a passive barrier
16845 // (C4 rider: never a trigger) the next `drain` would burn its ENTIRE
16846 // timeout and return `EngineError::Scheduler` on work ready to run.
16847 if kind_is_vector_indexed(connection, kind)? {
16848 jobs.push(ProjectionJob { cursor: 0, kind: kind.clone(), body: body.clone() });
16849 }
16850 }
16851 }
16852 Ok(jobs)
16853}
16854
16855fn validate_write(
16856 connection: &Connection,
16857 write: &PreparedWrite,
16858) -> Result<WritePlan, EngineError> {
16859 match write {
16860 PreparedWrite::Node { kind, body, logical_id, valid_from, valid_until, .. } => {
16861 if kind.trim().is_empty() || body.trim().is_empty() {
16862 return Err(EngineError::WriteValidation);
16863 }
16864 // 0.8.20 Slice 15b (TC-34) — the validity window is HALF-OPEN
16865 // `[valid_from, valid_until)`, so a pair with `from >= until` selects
16866 // no instant at all: the row would be written but no default read
16867 // could ever return it. Silently accepting that is a trap, so it is a
16868 // typed refusal.
16869 //
16870 // 0.8.20 Slice 22 (R-20-VC) — **decision #18, SETTLED: one family.**
16871 // This site used to return `EngineError::InvalidArgument { msg }`
16872 // carrying both bounds, which made `validate_write` — ONE function —
16873 // reject across TWO error families, so the same `write` call raised
16874 // `InvalidArgumentError` for an inverted window and
16875 // `WriteValidationError` for a non-integer bound. `dev/design/errors.md`
16876 // (status: locked) defines `WriteValidationError` as "malformed typed
16877 // write shape" / "the submitted typed write is malformed **before**
16878 // schema-sensitive payload checks run" — which is exactly this
16879 // boundary — so the code now agrees with the taxonomy of record.
16880 // `InvalidArgument` stays the family for caller-argument rejections
16881 // OUTSIDE this boundary (see the errors.md 2026-07-28 amendment).
16882 //
16883 // **The cost, stated:** `WriteValidation` is a UNIT variant and both
16884 // bindings map it to a fixed message-less string, so the offending
16885 // bounds are no longer recoverable from the error. That is a breaking
16886 // behaviour change on a published surface (CHANGELOG 0.8.20) and it is
16887 // the diagnostic the prior split existed to preserve. Restoring it
16888 // needs a message-carrying `WriteValidation { msg }`, which is a
16889 // cross-cutting change across every engine + binding raise site and
16890 // both binding payload shapes — its own slice, not this one.
16891 //
16892 // Only the PAIR can be empty. A one-sided window is unbounded on the
16893 // missing side and can never be empty, so it is never refused.
16894 if let (Some(from), Some(until)) = (valid_from, valid_until) {
16895 if from >= until {
16896 return Err(EngineError::WriteValidation);
16897 }
16898 }
16899 // R-20-E3: `source_id` needs no emptiness check here — `SourceId`
16900 // cannot hold an empty or reserved id, so the check has moved from
16901 // this branch into the type's constructor.
16902 // G0 — an explicit logical_id must be non-empty (NULL/None is the
16903 // legacy default; an empty string is never a valid identity).
16904 // Also reject char(30) = \x1e (ASCII RS), which is the BFS cycle-guard
16905 // delimiter; allowing it would corrupt the visited-path substring test.
16906 if let Some(logical_id) = logical_id {
16907 if logical_id.is_empty() || logical_id.contains('\x1e') {
16908 return Err(EngineError::WriteValidation);
16909 }
16910 }
16911 Ok(WritePlan::Node)
16912 }
16913 PreparedWrite::Edge { kind, from, to, logical_id, t_valid, t_invalid, .. } => {
16914 if kind.trim().is_empty() || from.trim().is_empty() || to.trim().is_empty() {
16915 return Err(EngineError::WriteValidation);
16916 }
16917 // Reject char(30) in from/to: these become from_id/to_id in canonical_edges
16918 // and appear in BFS visited strings — an \x1e there would corrupt the guard.
16919 if from.contains('\x1e') || to.contains('\x1e') {
16920 return Err(EngineError::WriteValidation);
16921 }
16922 // R-20-E3: see the Node branch — emptiness is a `SourceId` invariant.
16923 if let Some(logical_id) = logical_id {
16924 if logical_id.is_empty() || logical_id.contains('\x1e') {
16925 return Err(EngineError::WriteValidation);
16926 }
16927 }
16928 // TC-33 fix-1 (codex §9 P2) — an epoch SQLite cannot render to
16929 // ISO-8601 must be UNSTORABLE. The governed integer surface is the
16930 // only way to reach one (inbound ISO normalisation maxes at year
16931 // 9999), so this write boundary is where it is stopped, before it
16932 // can render to a silent `null` on the consolidation wire and
16933 // resurrect an invalidated edge. Structural primary layer; the
16934 // render site keeps a defensive hard-assert as the backstop.
16935 reject_unrenderable_edge_epoch("t_valid", *t_valid)?;
16936 reject_unrenderable_edge_epoch("t_invalid", *t_invalid)?;
16937 Ok(WritePlan::Edge)
16938 }
16939 PreparedWrite::AdminSchema { name, kind, schema_json, retention_json } => {
16940 if name.trim().is_empty()
16941 || !matches!(kind.as_str(), "append_only_log" | "latest_state")
16942 || serde_json::from_str::<Value>(schema_json).is_err()
16943 || serde_json::from_str::<Value>(retention_json).is_err()
16944 || contains_external_ref(schema_json)
16945 {
16946 return Err(EngineError::SchemaValidation);
16947 }
16948 Ok(WritePlan::AdminSchema)
16949 }
16950 PreparedWrite::OpStore { collection, record_key, schema_id, body } => {
16951 if collection.trim().is_empty() || record_key.trim().is_empty() {
16952 return Err(EngineError::WriteValidation);
16953 }
16954 let (kind, schema_json) = collection_metadata(connection, collection)?;
16955 if let Some(schema_id) = schema_id {
16956 if schema_id != collection {
16957 return Err(EngineError::SchemaValidation);
16958 }
16959 validate_payload(&schema_json, body)?;
16960 } else if serde_json::from_str::<Value>(body).is_err() {
16961 return Err(EngineError::SchemaValidation);
16962 }
16963
16964 match kind.as_str() {
16965 "append_only_log" => Ok(WritePlan::AppendOnlyLog),
16966 "latest_state" => Ok(WritePlan::LatestState),
16967 _ => Err(EngineError::OpStore),
16968 }
16969 }
16970 }
16971}
16972
16973fn collection_metadata(
16974 connection: &Connection,
16975 collection: &str,
16976) -> Result<(String, String), EngineError> {
16977 connection
16978 .query_row(
16979 "SELECT kind, schema_json FROM operational_collections WHERE name = ?1",
16980 [collection],
16981 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
16982 )
16983 .map_err(|_| EngineError::OpStore)
16984}
16985
16986fn validate_payload(schema_json: &str, body: &str) -> Result<(), EngineError> {
16987 let schema =
16988 serde_json::from_str::<Value>(schema_json).map_err(|_| EngineError::SchemaValidation)?;
16989 let payload = serde_json::from_str::<Value>(body).map_err(|_| EngineError::SchemaValidation)?;
16990
16991 let compiled = JSONSchema::compile(&schema).map_err(|_| EngineError::SchemaValidation)?;
16992 compiled.validate(&payload).map_err(|_| EngineError::SchemaValidation)?;
16993
16994 Ok(())
16995}
16996
16997fn contains_external_ref(schema_json: &str) -> bool {
16998 let Ok(value) = serde_json::from_str::<Value>(schema_json) else {
16999 return false;
17000 };
17001 value_contains_external_ref(&value)
17002}
17003
17004fn value_contains_external_ref(value: &Value) -> bool {
17005 match value {
17006 Value::Object(object) => object.iter().any(|(key, value)| {
17007 if key == "$ref" {
17008 return value.as_str().is_some_and(|uri| !uri.starts_with('#'));
17009 }
17010 value_contains_external_ref(value)
17011 }),
17012 Value::Array(values) => values.iter().any(value_contains_external_ref),
17013 _ => false,
17014 }
17015}
17016
17017// fix-30 [P2]: helpers to collect active edge write_cursors BEFORE a supersession
17018// UPDATE so the callers can prune stale vector_default rows.
17019fn prior_edge_cursors_by_logical_id(
17020 tx: &rusqlite::Transaction<'_>,
17021 logical_id: &str,
17022) -> rusqlite::Result<Vec<i64>> {
17023 let mut s = tx.prepare_cached(
17024 "SELECT write_cursor FROM canonical_edges \
17025 WHERE logical_id = ?1 AND superseded_at IS NULL",
17026 )?;
17027 let rows = s.query_map(params![logical_id], |r| r.get(0))?;
17028 rows.collect()
17029}
17030
17031/// 0.8.20 Slice 15d fix-1 finding 2 [P2] — the active (non-superseded) NODE
17032/// cursors for a `logical_id`, collected BEFORE the tombstone-then-insert
17033/// supersession UPDATE so the caller can purge the about-to-be-superseded row's
17034/// row-owned attribute projections. Mirrors [`prior_edge_cursors_by_logical_id`].
17035/// The partial-unique-active index means this is at most one cursor; a `Vec`
17036/// keeps it robust and symmetric with the edge path.
17037fn prior_node_cursors_by_logical_id(
17038 tx: &rusqlite::Transaction<'_>,
17039 logical_id: &str,
17040) -> rusqlite::Result<Vec<i64>> {
17041 let mut s = tx.prepare_cached(
17042 "SELECT write_cursor FROM canonical_nodes \
17043 WHERE logical_id = ?1 AND superseded_at IS NULL",
17044 )?;
17045 let rows = s.query_map(params![logical_id], |r| r.get(0))?;
17046 rows.collect()
17047}
17048
17049fn prior_edge_cursors_by_triple(
17050 tx: &rusqlite::Transaction<'_>,
17051 from: &str,
17052 to: &str,
17053 kind: &str,
17054) -> rusqlite::Result<Vec<i64>> {
17055 let mut s = tx.prepare_cached(
17056 "SELECT write_cursor FROM canonical_edges \
17057 WHERE from_id = ?1 AND to_id = ?2 AND kind = ?3 AND superseded_at IS NULL",
17058 )?;
17059 let rows = s.query_map(params![from, to, kind], |r| r.get(0))?;
17060 rows.collect()
17061}
17062
17063/// EXP-S (0.8.14 Slice 5, D2) — the set of coexisting indexes a `row_kind`
17064/// projects into. `fts` = the FTS index (`search_index`), written SYNCHRONOUSLY
17065/// in the write transaction; `vector` = the vec0 vector index, written
17066/// ASYNCHRONOUSLY by the projection worker pool (and additionally gated per
17067/// doc-type `kind` by [`kind_is_vector_indexed`]).
17068#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17069struct IndexTargetSet {
17070 fts: bool,
17071 vector: bool,
17072}
17073
17074/// 0.8.20 Slice 5a (R-20-E1) — the class a row-owned projection table belongs
17075/// to, so the four maintenance sites can each truncate exactly the subset they
17076/// own without re-deriving a hand-rolled table list.
17077///
17078/// - `NodeFts` — same-txn lexical projection of a canonical NODE body.
17079/// - `EdgeFts` — same-txn lexical projection of a canonical EDGE body.
17080/// - `Vector` — the async vec0 materialization (written by the embed worker,
17081/// not by the write path — see [`project_canonical_node_row`]).
17082/// - `Readiness` — the terminal-cursor bookkeeping that lets
17083/// `advance_projection_cursor` walk past a row.
17084#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17085enum ProjectionClass {
17086 NodeFts,
17087 EdgeFts,
17088 Vector,
17089 Readiness,
17090 /// 0.8.20 Slice 15d (R-20-EAV) — the EAV attribute store (`filterable` +
17091 /// the value-at-rest for `searchable`). Same-transaction, row-owned.
17092 Attribute,
17093 /// 0.8.20 Slice 15d (R-20-EAV) — the property-FTS5 shadow of attribute
17094 /// values (`searchable→FTS`). Same-transaction, row-owned.
17095 PropertyFts,
17096}
17097
17098/// 0.8.20 Slice 5a (R-20-E1) — one ROW-OWNED projection table: a shadow whose
17099/// rows are 1:1 with a canonical row's `write_cursor` and therefore MUST die
17100/// with that row.
17101#[derive(Clone, Copy, Debug)]
17102struct RowOwnedProjection {
17103 /// Table name. `'static` and never caller-derived: safe to interpolate.
17104 table: &'static str,
17105 /// The column carrying the owning canonical row's `write_cursor`. For the
17106 /// vec0 table this is `rowid` — vec0 rowid IS the write_cursor (see the
17107 /// `_fathomdb_vector_rows.write_cursor UNIQUE` identity).
17108 cursor_column: &'static str,
17109 class: ProjectionClass,
17110}
17111
17112/// 0.8.20 Slice 5a (R-20-E1) — **the** registry of row-owned projections.
17113///
17114/// Every table here is 1:1 with a canonical `write_cursor` and is erased by
17115/// [`erase_row_projections`] whenever that canonical row is erased. Adding a
17116/// projection table WITHOUT registering it here re-opens the defect this slice
17117/// closes (`search_index_v2` was written by one site and deleted by one site,
17118/// out of five that maintain projections — so `excise_source` left the erased
17119/// body on disk in a content-storing FTS5 table). The `guard_row_owned_registry`
17120/// unit test introspects `sqlite_master` and fails if a `write_cursor`-keyed
17121/// table is missing from this list.
17122///
17123/// **NOT here, deliberately (design v5 §1.1): `_fathomdb_projection_state`.**
17124/// That table is KIND-owned — keyed by `kind`, holding a per-kind enqueue
17125/// watermark. Erasing one row must NOT rewind a whole kind's watermark, so it
17126/// must never be deleted per-cursor. A rebuild resets it deliberately; erasure
17127/// leaves it alone.
17128const ROW_OWNED_PROJECTIONS: &[RowOwnedProjection] = &[
17129 RowOwnedProjection {
17130 table: "search_index",
17131 cursor_column: "write_cursor",
17132 class: ProjectionClass::NodeFts,
17133 },
17134 RowOwnedProjection {
17135 table: "search_index_v2",
17136 cursor_column: "write_cursor",
17137 class: ProjectionClass::NodeFts,
17138 },
17139 RowOwnedProjection {
17140 table: "search_index_edges",
17141 cursor_column: "write_cursor",
17142 class: ProjectionClass::EdgeFts,
17143 },
17144 RowOwnedProjection {
17145 table: "vector_default",
17146 cursor_column: "rowid",
17147 class: ProjectionClass::Vector,
17148 },
17149 RowOwnedProjection {
17150 table: "_fathomdb_vector_rows",
17151 cursor_column: "write_cursor",
17152 class: ProjectionClass::Vector,
17153 },
17154 RowOwnedProjection {
17155 table: "_fathomdb_projection_terminal",
17156 cursor_column: "write_cursor",
17157 class: ProjectionClass::Readiness,
17158 },
17159 // 0.8.20 Slice 15d (R-20-EAV) — the EAV attribute store and its property-FTS
17160 // shadow both hold declared attribute VALUES at rest (potential PII), keyed
17161 // 1:1 with the owning node's write_cursor. They MUST be reachable by
17162 // `purge`/`excise_source`: registering them here is what makes
17163 // `erase_row_projections` delete them without a hand-rolled list (an
17164 // unregistered content-storing table is exactly the `search_index_v2` leak
17165 // class this registry closes). The `guard_row_owned_registry` unit test
17166 // FAILS if either is left unregistered.
17167 RowOwnedProjection {
17168 table: "canonical_attributes",
17169 cursor_column: "write_cursor",
17170 class: ProjectionClass::Attribute,
17171 },
17172 RowOwnedProjection {
17173 table: "property_search_index",
17174 cursor_column: "write_cursor",
17175 class: ProjectionClass::PropertyFts,
17176 },
17177];
17178
17179/// 0.8.20 Slice 5a (R-20-E1) — erase EVERY row-owned projection for one
17180/// canonical `write_cursor`. Returns the number of shadow rows deleted.
17181///
17182/// This is the single erasure primitive: `purge_inner` and `excise_source_inner`
17183/// both call it, so a new projection table becomes erasable by registering it in
17184/// [`ROW_OWNED_PROJECTIONS`] — not by remembering to patch two hand-rolled
17185/// delete lists (the omission that left erased bodies in `search_index_v2`).
17186fn erase_row_projections(tx: &Connection, write_cursor: i64) -> rusqlite::Result<u64> {
17187 let mut deleted: u64 = 0;
17188 for projection in ROW_OWNED_PROJECTIONS {
17189 deleted =
17190 saturating_add_u64(deleted, delete_row_owned_projection(tx, projection, write_cursor)?);
17191 }
17192 Ok(deleted)
17193}
17194
17195/// TC-76 — delete one row-owned projection's rows for one `write_cursor`. The vec0
17196/// partition is routed through [`delete_vector_partition_row`] (sqlite-vec `#99`
17197/// makes a naked `DELETE` fail whenever a TEXT metadata value spills the 12-byte
17198/// inline view); every other table is the plain registry-driven statement.
17199fn delete_row_owned_projection(
17200 tx: &Connection,
17201 projection: &RowOwnedProjection,
17202 write_cursor: i64,
17203) -> rusqlite::Result<usize> {
17204 if projection.table == DEFAULT_VECTOR_PARTITION {
17205 return delete_vector_partition_row(tx, write_cursor);
17206 }
17207 let sql = format!("DELETE FROM {} WHERE {} = ?1", projection.table, projection.cursor_column);
17208 tx.execute(&sql, [write_cursor])
17209}
17210
17211fn saturating_add_u64(acc: u64, n: usize) -> u64 {
17212 acc.saturating_add(n as u64)
17213}
17214
17215/// 0.8.20 Slice 15d fix-1 finding 2 [P2] — purge the row-owned projections in
17216/// `classes` for ONE canonical `write_cursor`. Same registry-driven mechanism as
17217/// [`erase_row_projections`] (iterate [`ROW_OWNED_PROJECTIONS`], delete by the
17218/// declared cursor column) but scoped to a class SUBSET, so the write path can
17219/// drop a SUPERSEDED node's `Attribute` + `PropertyFts` rows — making the at-rest
17220/// property projection active-only — WITHOUT touching the `NodeFts`/`Vector`
17221/// shadows, whose stale rows the node read path already excludes by joining
17222/// `canonical_nodes WHERE superseded_at IS NULL`. Consistent with the erasure
17223/// model: an unregistered table is unreachable here, exactly as with erasure.
17224fn purge_row_projections_for_cursor_in(
17225 tx: &Connection,
17226 write_cursor: i64,
17227 classes: &[ProjectionClass],
17228) -> rusqlite::Result<u64> {
17229 let mut deleted: u64 = 0;
17230 for projection in ROW_OWNED_PROJECTIONS.iter().filter(|p| classes.contains(&p.class)) {
17231 deleted =
17232 saturating_add_u64(deleted, delete_row_owned_projection(tx, projection, write_cursor)?);
17233 }
17234 Ok(deleted)
17235}
17236
17237/// 0.8.20 Slice 5a (R-20-E1) — truncate the row-owned projections in `classes`.
17238/// Returns the number of shadow rows deleted.
17239fn truncate_row_projections_in(
17240 tx: &Connection,
17241 classes: &[ProjectionClass],
17242) -> rusqlite::Result<u64> {
17243 let mut deleted: u64 = 0;
17244 for projection in ROW_OWNED_PROJECTIONS.iter().filter(|p| classes.contains(&p.class)) {
17245 if projection.table == DEFAULT_VECTOR_PARTITION {
17246 // TC-76 (sqlite-vec `#99`) — an unqualified `DELETE FROM vector_default`
17247 // still dispatches vec0's per-row delete, so it fails on the FIRST row
17248 // whose TEXT metadata spills the 12-byte inline view. Blank the whole
17249 // column first.
17250 neutralize_vector_partition_attr_values(tx, None)?;
17251 }
17252 let sql = format!("DELETE FROM {}", projection.table);
17253 deleted = deleted.saturating_add(tx.execute(&sql, [])? as u64);
17254 }
17255 Ok(deleted)
17256}
17257
17258/// 0.8.20 Slice 5a (R-20-E1) — truncate EVERY row-owned projection (the full
17259/// `rebuild_projections` invalidation). Kind-owned watermark state
17260/// (`_fathomdb_projection_state`) is deliberately untouched; the rebuild resets
17261/// readiness by rewinding the projection cursor instead.
17262#[cfg(feature = "operator")]
17263fn truncate_all_row_projections(tx: &Connection) -> rusqlite::Result<u64> {
17264 truncate_row_projections_in(
17265 tx,
17266 &[
17267 ProjectionClass::NodeFts,
17268 ProjectionClass::EdgeFts,
17269 ProjectionClass::Vector,
17270 ProjectionClass::Readiness,
17271 ProjectionClass::Attribute,
17272 ProjectionClass::PropertyFts,
17273 ],
17274 )
17275}
17276
17277/// 0.8.20 Slice 5a (R-20-E1) — which half of a projector's work a call site
17278/// wants. The projectors are TOTAL (they own every row-owned projection for a
17279/// canonical row); the pass selects the subset a replay site is rebuilding, so
17280/// no call site re-implements projection SQL inline.
17281#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17282enum ProjectionPass {
17283 /// The write path: same-txn FTS **and** async vector enqueue / readiness
17284 /// termination.
17285 Write,
17286 /// Lexical replay only (the open-path tokenizer reproject). Readiness and
17287 /// vector state are already correct and must not be perturbed.
17288 FtsOnly,
17289 /// Readiness + async-vector replay only (`rebuild_vec0`, i.e. a rebuild with
17290 /// `include_fts = false`): the FTS shadows are not being rebuilt in this
17291 /// pass, so they must not be written.
17292 ///
17293 /// Only the `operator` rebuild seam constructs this pass, so the DEFAULT
17294 /// (recovery-clean) build sees it as unconstructed — same gate rationale as
17295 /// the operator methods themselves (feature = gate, not delete).
17296 #[cfg_attr(not(feature = "operator"), allow(dead_code))]
17297 VectorOnly,
17298}
17299
17300impl ProjectionPass {
17301 fn writes_fts(self) -> bool {
17302 matches!(self, ProjectionPass::Write | ProjectionPass::FtsOnly)
17303 }
17304
17305 fn writes_vector_state(self) -> bool {
17306 matches!(self, ProjectionPass::Write | ProjectionPass::VectorOnly)
17307 }
17308
17309 /// 0.8.20 Slice 15d (R-20-EAV) — whether this pass (re)projects the declared
17310 /// attribute set into the EAV store + property-FTS. Only the full `Write`
17311 /// pass does: the `FtsOnly` tokenizer-upgrade reproject predates step 24 (no
17312 /// registry/attribute tables exist at that migration point, so it must not
17313 /// touch them), and `VectorOnly` rebuilds only the async vector shadows. The
17314 /// operator FTS rebuild uses `Write`, so a full `rebuild_projections`
17315 /// re-derives attributes cleanly after `truncate_all_row_projections` clears
17316 /// the two attribute classes.
17317 fn writes_attributes(self) -> bool {
17318 matches!(self, ProjectionPass::Write)
17319 }
17320}
17321
17322/// 0.8.20 Slice 15d (R-20-PR) — the on-disk registry row for one declared
17323/// projection, read back from `_fathomdb_projection_registry`.
17324///
17325/// **On-disk encoding of the optional sub-objects.** The `fts_tokenizer` column
17326/// is tri-valued: SQL `NULL` = no `fts` sub-object; empty string `""` = `fts`
17327/// present with the engine-default tokenizer; a non-empty string = `fts` with a
17328/// custom tokenizer. This is what lets `searchable→FTS with default tokenizer`
17329/// be distinguished durably from `searchable` with no FTS sub-target. `vector`
17330/// mirrors it with an explicit `vector_declared` bit plus a nullable
17331/// `vector_embedder`.
17332#[derive(Clone, Debug, Eq, PartialEq)]
17333struct StoredProjection {
17334 roles: BTreeSet<ProjectionRole>,
17335 fts_present: bool,
17336 /// `Some(custom)` custom tokenizer; `None` = engine default (only
17337 /// meaningful when `fts_present`).
17338 fts_tokenizer: Option<String>,
17339 vector_declared: bool,
17340 vector_embedder: Option<String>,
17341 source: Option<Vec<String>>,
17342}
17343
17344impl StoredProjection {
17345 /// True iff the declared roles want the attribute VALUE stored at rest in
17346 /// the EAV store: `filterable` (the value IS the filter target) or
17347 /// `searchable` (the value is the retrievable meaning, and Slice 20's vector
17348 /// embed will read it from here). `rankable`-only wants no value at rest.
17349 fn wants_eav(&self) -> bool {
17350 self.roles.contains(&ProjectionRole::Filterable)
17351 || self.roles.contains(&ProjectionRole::Searchable)
17352 }
17353
17354 /// True iff a `searchable→FTS` property-FTS row should be written: the
17355 /// `searchable` role AND an `fts` sub-object.
17356 fn wants_property_fts(&self) -> bool {
17357 self.roles.contains(&ProjectionRole::Searchable) && self.fts_present
17358 }
17359
17360 /// 0.8.20 Slice 21c (ledger `TC-71`) — **THE `searchable→vector` predicate.**
17361 /// True iff this declaration puts the attribute on the dense arm: the
17362 /// `searchable` role AND a `vector` sub-object. The exact analogue of
17363 /// [`StoredProjection::wants_property_fts`], for the same reason — the
17364 /// sub-object SELECTS a sub-target of `searchable`; it does not confer one.
17365 ///
17366 /// [`vector_projection_declared`] — the corpus-wide predicate gating all
17367 /// three enrolment paths (declare-time backfill, its drop inverse, and the
17368 /// write path's late enrolment) — routes through this so no call site can
17369 /// re-derive the rule and drift. Before it existed, that predicate keyed off
17370 /// `vector_declared` ALONE, so `{roles: [filterable], vector: {}}` — the
17371 /// combination Slice 15d documented as inert-but-round-trippable — enrolled
17372 /// node kinds, backfilled the corpus and made every later write enqueue an
17373 /// embedding in any session with a live embedder.
17374 ///
17375 /// **0.8.20 Slice 23 (`R-20-SV`):** that combination is no longer DECLARABLE
17376 /// — [`apply_projection_config`] rejects it as an invalid spec. This
17377 /// predicate still governs, because the shape survives at rest in every
17378 /// database that declared it while the engine accepted it, and it is read
17379 /// from the registry, not from a caller's spec.
17380 ///
17381 /// **Deliberately NOT the same as [`StoredProjection::has_deferred`]**, which
17382 /// keys off `vector_declared` alone and must keep doing so: that one feeds
17383 /// `ProjectionDelta.deferred`, a REPORTING field, and the round-trip contract
17384 /// wants a stored-but-unbuilt `vector` sub-object reported however it was
17385 /// declared. TC-71 changes what the engine DOES, not what it says.
17386 fn wants_vector(&self) -> bool {
17387 self.roles.contains(&ProjectionRole::Searchable) && self.vector_declared
17388 }
17389
17390 /// The `fts_tokenizer` column value: `None` (SQL NULL) when no `fts`
17391 /// sub-object, else the custom tokenizer or `""` for engine-default.
17392 fn fts_column(&self) -> Option<String> {
17393 if self.fts_present {
17394 Some(self.fts_tokenizer.clone().unwrap_or_default())
17395 } else {
17396 None
17397 }
17398 }
17399
17400 /// Build from the public [`ProjectionSpec`].
17401 ///
17402 /// 0.8.20 Slice 20 (R-20-DR) — note what is DELIBERATELY not read here:
17403 /// `spec.vector.dense_readiness`. Readiness is engine-set READ METADATA, not
17404 /// part of the declaration, so it never reaches the durable registry. That
17405 /// is what makes a caller-supplied value INERT (the engine always reports the
17406 /// derived truth) and what keeps it out of the destructive-change diff — a
17407 /// readiness difference can never look like a projection change.
17408 fn from_spec(spec: &ProjectionSpec) -> Self {
17409 StoredProjection {
17410 roles: spec.roles.clone(),
17411 fts_present: spec.fts.is_some(),
17412 fts_tokenizer: spec
17413 .fts
17414 .as_ref()
17415 .and_then(|f| f.tokenizer.clone())
17416 .filter(|t| !t.is_empty()),
17417 vector_declared: spec.vector.is_some(),
17418 vector_embedder: spec
17419 .vector
17420 .as_ref()
17421 .and_then(|v| v.embedder.clone())
17422 .filter(|e| !e.is_empty()),
17423 source: spec.source.clone(),
17424 }
17425 }
17426
17427 /// Reconstruct the public [`ProjectionSpec`] for `read_projections`.
17428 fn to_spec(&self, name: &str) -> ProjectionSpec {
17429 ProjectionSpec {
17430 name: name.to_string(),
17431 roles: self.roles.clone(),
17432 fts: if self.fts_present {
17433 Some(ProjectionFts { tokenizer: self.fts_tokenizer.clone() })
17434 } else {
17435 None
17436 },
17437 vector: if self.vector_declared {
17438 // 0.8.20 Slice 20 (R-20-DR) — the registry knows nothing about
17439 // readiness (it is DERIVED, never stored), so the durable shape
17440 // reconstructs with `dense_readiness: None`.
17441 // [`Engine::read_projections`] fills it from
17442 // [`derive_dense_readiness`] on the way out.
17443 Some(ProjectionVector {
17444 embedder: self.vector_embedder.clone(),
17445 dense_readiness: None,
17446 })
17447 } else {
17448 None
17449 },
17450 source: self.source.clone(),
17451 }
17452 }
17453
17454 /// The set of ROLE spellings this declaration DEFERS rather than builds:
17455 /// `rankable` (F9 not live) and, since 15d builds no embedding, the
17456 /// `searchable→vector` sub-target. Used to populate `ProjectionDelta.deferred`.
17457 fn has_deferred(&self) -> bool {
17458 self.roles.contains(&ProjectionRole::Rankable) || self.vector_declared
17459 }
17460}
17461
17462/// 0.8.20 Slice 15d (R-20-PR) — is `name` a well-formed attribute name?
17463///
17464/// Establishes the invariant "a name that `configure_projections` ACCEPTS must be
17465/// POPULATABLE": the write-path extraction compiles the SQLite JSON path
17466/// `$."<name>"` (double-quoted key). A name must therefore round-trip through
17467/// that quoted-key form unchanged. Rejects:
17468/// - empty;
17469/// - a double-quote `"` (would terminate the quoted key early → malformed path,
17470/// ERRORing inside the write transaction);
17471/// - a BACKSLASH `\` (fix-4 finding 1 [P2]): SQLite treats `\` as an escape
17472/// introducer inside the double-quoted JSON-path key, so a body key literally
17473/// containing `\` (e.g. `a\b`) is NOT matched by `$."a\b"`. Pre-fix the name
17474/// was accepted yet the attribute silently NEVER populated
17475/// `canonical_attributes` — an accept-then-never-populate footgun. Rejecting
17476/// it keeps the accept ⟹ works contract (mirrors the TC-33 hard-reject
17477/// philosophy);
17478/// - any ASCII control char (incl. NUL): not a safe/legible key spelling and
17479/// not reliably matchable through the quoted-key form.
17480///
17481/// Projection names are app-declared identifiers, so this charset restriction is
17482/// a legitimate contract. Caller-supplied, so it is validated at
17483/// `configure_projections` time (spec names AND the `drop` list).
17484fn is_valid_attribute_name(name: &str) -> bool {
17485 !name.is_empty()
17486 && !name.contains('"')
17487 && !name.contains('\\')
17488 && !name.chars().any(|c| c.is_control())
17489}
17490
17491/// 0.8.20 Slice 15d — the SQLite JSON path that extracts attribute `name` from a
17492/// node body. `name` is pre-validated by [`is_valid_attribute_name`]; the path
17493/// is bound as a PARAMETER (never interpolated into SQL), so this is not an
17494/// injection surface even before that validation.
17495fn attribute_json_path(name: &str) -> String {
17496 format!("$.\"{name}\"")
17497}
17498
17499/// SQLite JSON path for one declared projection. A declared source is an ordered
17500/// list of literal object-member names; it is never a caller-provided JSONPath.
17501/// Every segment is validated before persistence, and the resulting path is
17502/// always bound as a parameter rather than interpolated into SQL.
17503fn projection_json_path(name: &str, stored: &StoredProjection) -> String {
17504 match &stored.source {
17505 None => attribute_json_path(name),
17506 Some(segments) => {
17507 let mut path = String::from("$");
17508 for segment in segments {
17509 path.push_str(".\"");
17510 path.push_str(segment);
17511 path.push('"');
17512 }
17513 path
17514 }
17515 }
17516}
17517
17518/// Refuse a source path that cannot be safely represented as SQLite quoted
17519/// member selectors. Empty paths would select the whole body rather than one
17520/// member and therefore do not meet the scalar-projection contract.
17521fn is_valid_projection_source(source: &[String]) -> bool {
17522 !source.is_empty() && source.iter().all(|segment| is_valid_attribute_name(segment))
17523}
17524
17525/// Reject only nested-source object/array terminals. Legacy top-level
17526/// projections retain their shipped skip-composite behaviour for compatibility.
17527fn nested_projection_terminal_is_composite(
17528 conn: &Connection,
17529 body: &str,
17530 name: &str,
17531 stored: &StoredProjection,
17532) -> rusqlite::Result<bool> {
17533 if stored.source.is_none() {
17534 return Ok(false);
17535 }
17536 let path = projection_json_path(name, stored);
17537 let terminal: Option<String> = conn.query_row(
17538 "SELECT CASE WHEN json_valid(?1) THEN json_type(?1, ?2) END",
17539 params![body, path],
17540 |row| row.get(0),
17541 )?;
17542 Ok(matches!(terminal.as_deref(), Some("object") | Some("array")))
17543}
17544
17545/// Validate nested source terminals across the active rows a declaration would
17546/// backfill. The caller owns the transaction, so `WriteValidation` aborts it
17547/// rather than leaving a partly reconfigured registry.
17548fn validate_projection_source_backfill(
17549 conn: &Connection,
17550 name: &str,
17551 stored: &StoredProjection,
17552) -> Result<(), EngineError> {
17553 if stored.source.is_none() {
17554 return Ok(());
17555 }
17556 let mut stmt = conn
17557 .prepare(
17558 "SELECT body FROM canonical_nodes
17559 WHERE superseded_at IS NULL AND state = 'active'",
17560 )
17561 .map_err(|_| EngineError::Storage)?;
17562 let bodies =
17563 stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|_| EngineError::Storage)?;
17564 for body in bodies {
17565 let body = body.map_err(|_| EngineError::Storage)?;
17566 if nested_projection_terminal_is_composite(conn, &body, name, stored)
17567 .map_err(|_| EngineError::Storage)?
17568 {
17569 return Err(EngineError::WriteValidation);
17570 }
17571 }
17572 Ok(())
17573}
17574
17575/// Validate every nested source present in a normal node write before the write
17576/// transaction starts. This keeps an object/array terminal in the existing
17577/// `WriteValidation` family and guarantees the whole batch is rejected.
17578fn validate_nested_projection_sources_for_write(
17579 conn: &Connection,
17580 batch: &[PreparedWrite],
17581) -> Result<(), EngineError> {
17582 let registry = load_projection_registry(conn).map_err(|_| EngineError::Storage)?;
17583 if registry.values().all(|stored| stored.source.is_none()) {
17584 return Ok(());
17585 }
17586 for write in batch {
17587 let PreparedWrite::Node { body, .. } = write else { continue };
17588 validate_nested_projection_sources_for_body_in_registry(conn, body, ®istry)?;
17589 }
17590 Ok(())
17591}
17592
17593fn validate_nested_projection_sources_for_body(
17594 conn: &Connection,
17595 body: &str,
17596) -> Result<(), EngineError> {
17597 let registry = load_projection_registry(conn).map_err(|_| EngineError::Storage)?;
17598 validate_nested_projection_sources_for_body_in_registry(conn, body, ®istry)
17599}
17600
17601/// Validate one body against a registry snapshot owned by the caller. Batch
17602/// writes load this snapshot once, keeping validation proportional to bodies
17603/// plus declarations rather than repeating registry I/O for every row.
17604fn validate_nested_projection_sources_for_body_in_registry(
17605 conn: &Connection,
17606 body: &str,
17607 registry: &BTreeMap<String, StoredProjection>,
17608) -> Result<(), EngineError> {
17609 for (name, stored) in registry {
17610 if nested_projection_terminal_is_composite(conn, body, name, stored)
17611 .map_err(|_| EngineError::Storage)?
17612 {
17613 return Err(EngineError::WriteValidation);
17614 }
17615 }
17616 Ok(())
17617}
17618
17619/// 0.8.20 Slice 15d (R-20-PR) — load the durable projection registry
17620/// (`_fathomdb_projection_registry`) into a name→[`StoredProjection`] map. This
17621/// is the derived-cache source (Q5) that boot re-derive and every
17622/// `configure_projections` diff read.
17623fn load_projection_registry(
17624 conn: &Connection,
17625) -> rusqlite::Result<BTreeMap<String, StoredProjection>> {
17626 let mut out = BTreeMap::new();
17627 // The registry table is created by schema step 24; a DB migrated to a
17628 // pre-24 head (e.g. a compatibility/partial-migration test open) does not
17629 // have it. Absent ⇒ no projections declared ⇒ empty registry, not an error.
17630 // This keeps boot re-derive and the write-path attribute projector safe on
17631 // every pre-24 schema.
17632 let table_exists: bool = conn
17633 .query_row(
17634 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_fathomdb_projection_registry'",
17635 [],
17636 |_| Ok(true),
17637 )
17638 .optional()?
17639 .unwrap_or(false);
17640 if !table_exists {
17641 return Ok(out);
17642 }
17643 let mut stmt = conn.prepare(
17644 "SELECT name, roles, fts_tokenizer, vector_embedder, vector_declared, source
17645 FROM _fathomdb_projection_registry",
17646 )?;
17647 let rows = stmt.query_map([], |row| {
17648 let name: String = row.get(0)?;
17649 let roles_json: String = row.get(1)?;
17650 let fts_tokenizer: Option<String> = row.get(2)?;
17651 let vector_embedder: Option<String> = row.get(3)?;
17652 let vector_declared: i64 = row.get(4)?;
17653 let source_json: Option<String> = row.get(5)?;
17654 Ok((name, roles_json, fts_tokenizer, vector_embedder, vector_declared, source_json))
17655 })?;
17656 for row in rows {
17657 let (name, roles_json, fts_col, vector_embedder, vector_declared, source_json) = row?;
17658 let roles: BTreeSet<ProjectionRole> = parse_roles_json(&roles_json);
17659 let fts_present = fts_col.is_some();
17660 let fts_tokenizer = fts_col.filter(|t| !t.is_empty());
17661 let source = source_json
17662 .map(|encoded| serde_json::from_str::<Vec<String>>(&encoded))
17663 .transpose()
17664 .map_err(|err| {
17665 rusqlite::Error::FromSqlConversionFailure(
17666 5,
17667 rusqlite::types::Type::Text,
17668 Box::new(err),
17669 )
17670 })?;
17671 out.insert(
17672 name,
17673 StoredProjection {
17674 roles,
17675 fts_present,
17676 fts_tokenizer,
17677 vector_declared: vector_declared != 0,
17678 vector_embedder,
17679 source,
17680 },
17681 );
17682 }
17683 Ok(out)
17684}
17685
17686/// Roles are persisted as a compact, sorted, comma-separated list (set
17687/// semantics; order-independent). Unknown tokens are ignored (forward-compat).
17688fn parse_roles_json(s: &str) -> BTreeSet<ProjectionRole> {
17689 s.split(',').filter_map(|t| ProjectionRole::from_str_opt(t.trim())).collect()
17690}
17691
17692fn roles_to_storage(roles: &BTreeSet<ProjectionRole>) -> String {
17693 roles.iter().map(|r| r.as_str()).collect::<Vec<_>>().join(",")
17694}
17695
17696/// 0.8.20 Slice 15d (R-20-PR) — write/overwrite one registry row.
17697fn persist_projection_row(
17698 tx: &Connection,
17699 name: &str,
17700 stored: &StoredProjection,
17701) -> rusqlite::Result<()> {
17702 let source = stored
17703 .source
17704 .as_ref()
17705 .map(serde_json::to_string)
17706 .transpose()
17707 .map_err(|err| rusqlite::Error::ToSqlConversionFailure(Box::new(err)))?;
17708 tx.execute(
17709 "INSERT INTO _fathomdb_projection_registry
17710 (name, roles, fts_tokenizer, vector_embedder, vector_declared, source)
17711 VALUES(?1, ?2, ?3, ?4, ?5, ?6)
17712 ON CONFLICT(name) DO UPDATE SET
17713 roles = excluded.roles,
17714 fts_tokenizer = excluded.fts_tokenizer,
17715 vector_embedder = excluded.vector_embedder,
17716 vector_declared = excluded.vector_declared,
17717 source = excluded.source",
17718 params![
17719 name,
17720 roles_to_storage(&stored.roles),
17721 stored.fts_column(),
17722 stored.vector_embedder,
17723 i64::from(stored.vector_declared),
17724 source,
17725 ],
17726 )?;
17727 Ok(())
17728}
17729
17730/// 0.8.20 Slice 15d (R-20-PR) — delete one registry row.
17731fn remove_projection_row(tx: &Connection, name: &str) -> rusqlite::Result<()> {
17732 tx.execute("DELETE FROM _fathomdb_projection_registry WHERE name = ?1", params![name])?;
17733 Ok(())
17734}
17735
17736/// 0.8.20 Slice 15d (R-20-EAV) — delete every EAV + property-FTS row for one
17737/// attribute `name` (all owning nodes). The idempotent-rebuild primitive: a
17738/// changed or dropped projection clears its rows before (re)backfill.
17739fn clear_attribute_projection(tx: &Connection, name: &str) -> rusqlite::Result<()> {
17740 tx.execute("DELETE FROM property_search_index WHERE attr_name = ?1", params![name])?;
17741 tx.execute("DELETE FROM canonical_attributes WHERE attr_name = ?1", params![name])?;
17742 Ok(())
17743}
17744
17745/// 0.8.20 Slice 15d (R-20-EAV) — project ONE attribute value for ONE node row
17746/// into the EAV store and (if `searchable→FTS`) the property-FTS shadow. Skips a
17747/// NULL/absent extraction (an absent attribute means no row, so a `filterable`
17748/// equality simply never matches it — correct). Shared by the write path and
17749/// the backfill so they cannot drift.
17750fn project_one_attribute(
17751 tx: &Connection,
17752 cursor: i64,
17753 body: &str,
17754 name: &str,
17755 stored: &StoredProjection,
17756) -> rusqlite::Result<()> {
17757 if !stored.wants_eav() {
17758 return Ok(());
17759 }
17760 // json_extract over a non-JSON body would error; guard with json_valid so a
17761 // plain-text body simply yields no attribute rows. The canonical scalar
17762 // extraction is shared with the Slice-15e vec0 pre-KNN column via
17763 // [`extract_scalar_attribute`], so the EAV value and the `attr_<hex>` value
17764 // are IDENTICAL by construction.
17765 //
17766 // fix-1 finding 1 [P2] — project EVERY JSON scalar type, not just strings.
17767 // The prior form read the extraction as `Option<String>`; for a JSON number
17768 // or bool, `json_extract` returns an INTEGER/REAL, the `get::<Option<String>>`
17769 // conversion FAILED, and `.unwrap_or(None)` silently treated the attribute as
17770 // absent — so a numeric/boolean filterable value never projected. We now
17771 // render a single canonical TEXT form per JSON type, keyed on `json_type` so
17772 // the stored value is deterministic and the SAME value flows to BOTH
17773 // `canonical_attributes` and `property_search_index` (consistency by
17774 // construction — one `value` binding below):
17775 // - string -> the text verbatim
17776 // - integer -> decimal text (CAST AS TEXT); e.g. 3 -> "3"
17777 // - real -> decimal text (CAST AS TEXT); e.g. 3.5 -> "3.5"
17778 // - true -> "true", false -> "false" (preserve the JSON literal, NOT the
17779 // SQLite `1`/`0` that a bare `CAST(json_extract(...) AS TEXT)`
17780 // would yield — so a bool filter matches the value the caller
17781 // wrote, and "true" never collides with the number 1).
17782 // - null / absent path -> SQL NULL -> no row (an absent attribute correctly
17783 // never matches a `filterable` equality).
17784 // - object / array -> DELIBERATELY SKIPPED (SQL NULL -> no row): a composite
17785 // value is not a scalar filter/FTS target in 15d; projecting its
17786 // raw JSON text would be a footgun (nested-field filtering is the
17787 // >=0.9.x multi-field work). Skipping is deliberate, not an
17788 // accidental type-conversion drop — no scalar type is dropped.
17789 let Some(value) = extract_scalar_attribute(tx, body, name, stored)? else {
17790 return Ok(());
17791 };
17792 tx.execute(
17793 "INSERT INTO canonical_attributes(write_cursor, attr_name, attr_value)
17794 VALUES(?1, ?2, ?3)",
17795 params![cursor, name, value],
17796 )?;
17797 if stored.wants_property_fts() {
17798 tx.execute(
17799 "INSERT INTO property_search_index(attr_value, attr_name, write_cursor)
17800 VALUES(?1, ?2, ?3)",
17801 params![value, name, cursor],
17802 )?;
17803 }
17804 Ok(())
17805}
17806
17807/// 0.8.20 Slice 15e fix-3 [P2] — the leading marker byte that the vec0
17808/// `attr_<hex>` FILTER column prepends to every PRESENT scalar value, so that
17809/// PRESENT and ABSENT are DISJOINT in a NOT-NULL TEXT column.
17810///
17811/// The `''` empty-string sentinel used to mean BOTH "attribute absent" AND
17812/// "attribute present with value `''`", so a `status == ""` equality filter
17813/// false-matched every absent row. vec0 TEXT metadata is NOT-NULL-able (TC-46
17814/// condition #3), so absent cannot be `NULL`; instead absent stays `''` and every
17815/// PRESENT value `V` is encoded `enc(V) = "\x01" || V`. This is injective and
17816/// non-empty for ALL `V` (including `V=""`, whose encoding is the bare marker),
17817/// so `attr_<hex> = enc("")` matches present-empty but NEVER the `''`-absent rows.
17818///
17819/// This encoding is CONFINED to the vec0 filter column and the filter-value
17820/// lowering ([`vector_filter_values`]). `property_search_index` (the searchable→FTS
17821/// projection) and `canonical_attributes.attr_value` keep the RAW value — the FTS
17822/// arm distinguishes absent from present-empty by canonical_attributes row
17823/// EXISTENCE instead (see [`hit_attributes_pass_filter`]).
17824const ATTR_VEC0_PRESENT_MARKER: char = '\u{1}';
17825
17826/// 0.8.20 Slice 15e fix-3 — encode a PRESENT scalar value for the vec0 filter
17827/// column / filter-value lowering (see [`ATTR_VEC0_PRESENT_MARKER`]). ABSENT is
17828/// NOT encoded (it stays the bare `''` sentinel), so this is only ever called on a
17829/// value known to be present.
17830fn encode_attr_vec0_present(value: &str) -> String {
17831 let mut s = String::with_capacity(value.len() + 1);
17832 s.push(ATTR_VEC0_PRESENT_MARKER);
17833 s.push_str(value);
17834 s
17835}
17836
17837/// 0.8.20 Slice 15e — extract the canonical TEXT form of attribute `name` from a
17838/// node `body`, using the SAME `json_type` CASE as [`project_one_attribute`] so
17839/// the vec0 pre-KNN `attr_<hex>` column value equals the EAV
17840/// `canonical_attributes` value (consistency by construction — a `filterable`
17841/// filter routed pre-KNN sees exactly what the EAV path stored). Returns `None`
17842/// for an absent / null / object / array / non-JSON extraction (⇒ the `''`
17843/// sentinel at the vec0 column, ⇒ fail-to-match).
17844fn extract_scalar_attribute(
17845 conn: &Connection,
17846 body: &str,
17847 name: &str,
17848 stored: &StoredProjection,
17849) -> rusqlite::Result<Option<String>> {
17850 let path = projection_json_path(name, stored);
17851 let value: Option<String> = conn
17852 .query_row(
17853 "SELECT CASE WHEN json_valid(?1) THEN
17854 CASE json_type(?1, ?2)
17855 WHEN 'true' THEN 'true'
17856 WHEN 'false' THEN 'false'
17857 WHEN 'null' THEN NULL
17858 WHEN 'object' THEN NULL
17859 WHEN 'array' THEN NULL
17860 ELSE CAST(json_extract(?1, ?2) AS TEXT)
17861 END
17862 END",
17863 params![body, path],
17864 |row| row.get::<_, Option<String>>(0),
17865 )
17866 .unwrap_or(None);
17867 Ok(value)
17868}
17869
17870/// 0.8.20 Slice 15e — for a node `body`, build the `, attr_<hex>` column suffix,
17871/// the `, ?N` placeholder suffix (numbered from `start_idx`), and the bound TEXT
17872/// values for EVERY attribute column CURRENTLY on the live `vector_default` (read
17873/// from the table's own SQL, so the INSERT always matches the table shape exactly —
17874/// vec0 rejects a partial-column INSERT). Each value is the body's canonical
17875/// scalar extraction, or the `''` sentinel when absent. Returns empty fragments
17876/// (and no values) when the table has no attribute columns, so the INSERT stays
17877/// byte-identical to the shipped statement.
17878fn vector_attr_insert_fragments(
17879 conn: &Connection,
17880 body: &str,
17881 start_idx: usize,
17882) -> rusqlite::Result<(String, String, Vec<rusqlite::types::Value>)> {
17883 let cols = actual_vector_attr_columns(conn)?;
17884 let registry = load_projection_registry(conn)?;
17885 let mut col_sql = String::new();
17886 let mut ph_sql = String::new();
17887 let mut values: Vec<rusqlite::types::Value> = Vec::new();
17888 for (i, col) in cols.iter().enumerate() {
17889 let name = decode_attr_vec0_column(col).unwrap_or_default();
17890 // fix-3 [P2] — a PRESENT scalar value is encoded `\x01 || V` so it is
17891 // DISJOINT from the `''`-absent sentinel (present-empty ⇒ the bare marker,
17892 // never `''`). Absent stays the bare `''` sentinel.
17893 let scalar = match registry.get(&name) {
17894 Some(stored) => extract_scalar_attribute(conn, body, &name, stored)?,
17895 None => None,
17896 };
17897 let value = match scalar {
17898 Some(v) => encode_attr_vec0_present(&v),
17899 None => String::new(),
17900 };
17901 col_sql.push_str(&format!(", {col}"));
17902 ph_sql.push_str(&format!(", ?{}", start_idx + i));
17903 values.push(rusqlite::types::Value::Text(value));
17904 }
17905 Ok((col_sql, ph_sql, values))
17906}
17907
17908/// 0.8.20 Slice 15d (R-20-EAV) — the write-path attribute projector: for a
17909/// just-inserted node, project EVERY declared attribute (reading the live
17910/// registry from `tx`). Same-transaction, so the node is filter/FTS-retrievable
17911/// on commit. A no-op when the registry is empty (the pre-`configure_projections`
17912/// default), so it costs one empty-table scan per node and is behaviour-neutral
17913/// until a projection is declared.
17914fn project_node_attributes(tx: &Connection, cursor: i64, body: &str) -> rusqlite::Result<()> {
17915 let registry = load_projection_registry(tx)?;
17916 for (name, stored) in ®istry {
17917 project_one_attribute(tx, cursor, body, name, stored)?;
17918 }
17919 Ok(())
17920}
17921
17922/// 0.8.20 Slice 15d (R-20-PR) — backfill ONE attribute across every ACTIVE,
17923/// non-superseded canonical node. Called by `configure_projections` when a
17924/// projection is added/changed (after `clear_attribute_projection`), and by boot
17925/// re-derive. Idempotent when paired with the clear.
17926fn backfill_attribute(
17927 tx: &Connection,
17928 name: &str,
17929 stored: &StoredProjection,
17930) -> rusqlite::Result<()> {
17931 if !stored.wants_eav() {
17932 return Ok(());
17933 }
17934 let rows: Vec<(i64, String)> = {
17935 let mut stmt = tx.prepare(
17936 "SELECT write_cursor, body FROM canonical_nodes
17937 WHERE superseded_at IS NULL AND state = 'active'",
17938 )?;
17939 let collected = stmt
17940 .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)))?
17941 .collect::<rusqlite::Result<Vec<_>>>()?;
17942 collected
17943 };
17944 for (cursor, body) in rows {
17945 project_one_attribute(tx, cursor, &body, name, stored)?;
17946 }
17947 Ok(())
17948}
17949
17950/// 0.8.20 Slice 15d (R-20-PR) — is `desired` an INCOMPATIBLE/DESTRUCTIVE change
17951/// to a live `existing` projection? A destructive change discards an
17952/// expensive-to-rebuild resource and so REQUIRES an explicit `drop` (C3): a role
17953/// REMOVAL, dropping the `fts`/`vector` sub-target, or changing the tokenizer /
17954/// embedder. Purely ADDITIVE changes (adding a role, adding an `fts`/`vector`
17955/// sub-object) are non-destructive and applied in place.
17956fn is_destructive_projection_change(
17957 existing: &StoredProjection,
17958 desired: &StoredProjection,
17959) -> bool {
17960 if existing.roles.iter().any(|r| !desired.roles.contains(r)) {
17961 return true;
17962 }
17963 if existing.fts_present
17964 && (!desired.fts_present || existing.fts_tokenizer != desired.fts_tokenizer)
17965 {
17966 return true;
17967 }
17968 if existing.vector_declared
17969 && (!desired.vector_declared || existing.vector_embedder != desired.vector_embedder)
17970 {
17971 return true;
17972 }
17973 if existing.source != desired.source {
17974 return true;
17975 }
17976 false
17977}
17978
17979/// Human-readable summary of the destructive delta, surfaced in
17980/// [`EngineError::ProjectionDestructive`] so the caller sees WHAT it must drop.
17981fn describe_projection_delta(existing: &StoredProjection, desired: &StoredProjection) -> String {
17982 let mut parts: Vec<String> = Vec::new();
17983 for r in &existing.roles {
17984 if !desired.roles.contains(r) {
17985 parts.push(format!("role '{}' removed", r.as_str()));
17986 }
17987 }
17988 if existing.fts_present && !desired.fts_present {
17989 parts.push("fts sub-target removed".to_string());
17990 } else if existing.fts_present && existing.fts_tokenizer != desired.fts_tokenizer {
17991 parts.push("fts tokenizer changed".to_string());
17992 }
17993 if existing.vector_declared && !desired.vector_declared {
17994 parts.push("vector sub-target removed".to_string());
17995 } else if existing.vector_declared && existing.vector_embedder != desired.vector_embedder {
17996 parts.push("vector embedder changed".to_string());
17997 }
17998 if existing.source != desired.source {
17999 parts.push("source path changed".to_string());
18000 }
18001 if parts.is_empty() {
18002 "incompatible change".to_string()
18003 } else {
18004 parts.join("; ")
18005 }
18006}
18007
18008/// 0.8.20 Slice 15d (R-20-PR) — the declarative, idempotent diff+backfill apply
18009/// that backs [`Engine::configure_projections`]. Runs inside the caller's write
18010/// transaction `tx`. Order: apply `drop`s first (so a drop+re-declare in one
18011/// call rebuilds fresh), then diff each spec. Idempotent re-registration diffs to
18012/// an empty delta (`unchanged`). A destructive change without an explicit drop is
18013/// refused with [`EngineError::ProjectionDestructive`].
18014///
18015/// 0.8.20 Slice 20c (R-20-DR remainder) — returns `(delta, enqueued_backfill)`.
18016/// The second member is `true` iff [`enqueue_declared_vector_backfill`] put
18017/// deferred embed work on the queue, in which case the CALLER must
18018/// `notify_new_work()` after committing (the flag cannot ride on
18019/// [`ProjectionDelta`]: that is the caller-facing diff, and this is a runtime
18020/// signal, not part of the declaration's result).
18021///
18022/// 0.8.20 Slice 20c fix-1 (codex §9 [P2]) — and the symmetric inverse: a call
18023/// that removes the LAST `searchable→vector` declaration un-enrols the node kinds
18024/// the forward path enrolled ([`unenrol_registry_vector_node_kinds`]), on this
18025/// same transaction. It deletes no embedding.
18026///
18027/// 0.8.20 Slice 21 fix-1 (codex §9 round 1 [P2]) — and, beside that transition, a
18028/// state-keyed RECONCILIATION ([`registry_governs_an_inert_dense_arm`]) so that a
18029/// database already carrying an inert enrolment from before the Slice-21c role
18030/// gate is healed by any `configure_projections` call, not only by a
18031/// searchable-vector-to-none transition it may never perform. The boot arm is
18032/// [`reconcile_inert_vector_enrolments_on_boot`].
18033fn apply_projection_config(
18034 tx: &Connection,
18035 specs: &[ProjectionSpec],
18036 drop: &[String],
18037 dense_arm_live: bool,
18038) -> Result<(ProjectionDelta, bool), EngineError> {
18039 // Validate up-front so a bad name aborts before any write.
18040 //
18041 // fix-6 finding [P2] — REJECT a duplicate projection `name` within `specs`
18042 // (and a duplicate entry within `drop`) up front. The diff loop below diffs
18043 // every spec against the ONE pre-loop registry snapshot, so a name repeated
18044 // in `specs` diffed the SECOND spec against state that never saw the first
18045 // spec's just-persisted row: on a fresh DB `[status(searchable+fts),
18046 // status(rankable-only)]` reported `built=[status]` in the delta while the
18047 // registry ended rankable-only (which builds nothing) — the returned delta
18048 // DIVERGED from the persisted registry, breaking the fix-4 "accept ⟹ correct"
18049 // contract. A duplicate `drop` entry likewise reported the drop twice though
18050 // the row was removed once. A single request naming the same projection twice
18051 // is ambiguous/malformed, so we refuse it (rejection, not last-wins coalesce)
18052 // — a rejected request is a total no-op, keeping the registry and delta
18053 // consistent with the accepted input. A name that appears in BOTH `specs` and
18054 // `drop` is NOT a duplicate: that is the documented drop-then-rebuild-fresh
18055 // pattern (drops apply first, then the fresh spec builds), so it is allowed.
18056 let mut seen_spec_names: BTreeSet<&str> = BTreeSet::new();
18057 for spec in specs {
18058 if !is_valid_attribute_name(&spec.name) {
18059 return Err(EngineError::InvalidArgument {
18060 msg: format!("invalid projection attribute name: {:?}", spec.name),
18061 });
18062 }
18063 if spec.roles.is_empty() {
18064 return Err(EngineError::InvalidArgument {
18065 msg: format!("projection '{}' declares no roles", spec.name),
18066 });
18067 }
18068 if let Some(source) = &spec.source {
18069 if !is_valid_projection_source(source) {
18070 return Err(EngineError::InvalidArgument {
18071 msg: format!("invalid projection source path for {:?}", spec.name),
18072 });
18073 }
18074 }
18075 if !seen_spec_names.insert(spec.name.as_str()) {
18076 return Err(EngineError::InvalidArgument {
18077 msg: format!("duplicate projection name in one request: '{}'", spec.name),
18078 });
18079 }
18080 }
18081 let mut seen_drop_names: BTreeSet<&str> = BTreeSet::new();
18082 for name in drop {
18083 if !is_valid_attribute_name(name) {
18084 return Err(EngineError::InvalidArgument {
18085 msg: format!("invalid projection drop name: {name:?}"),
18086 });
18087 }
18088 if !seen_drop_names.insert(name.as_str()) {
18089 return Err(EngineError::InvalidArgument {
18090 msg: format!("duplicate projection drop in one request: '{name}'"),
18091 });
18092 }
18093 }
18094
18095 // 0.8.20 Slice 23 (`R-20-SV`) — REJECT an `fts` or `vector` sub-object
18096 // declared WITHOUT the `searchable` role.
18097 //
18098 // HITL ruling 2026-07-24 (`dev/plans/plan-0.8.20.md` §11 item 4, option (b)):
18099 // *"it is a meaningless config; fail-fast matches the hard-reject philosophy,
18100 // and additive strictness is safe pre-1.0"*, to be implemented "at the next
18101 // `configure_projections` slice". This OVERTURNS the shipped 15d fix-4
18102 // position, which accepted the shape because it round-tripped faithfully.
18103 //
18104 // WHY it is meaningless: `searchable→FTS` and `searchable→vector` are TIER
18105 // LABELS, not roles ([`ProjectionRole`] has exactly three members). The
18106 // sub-objects SELECT a sub-target of `searchable`; they do not CONFER one —
18107 // both build predicates ([`StoredProjection::wants_property_fts`] and
18108 // [`StoredProjection::wants_vector`]) are conjunctions with
18109 // `roles.contains(Searchable)`. So without the role the declaration builds no
18110 // property-FTS, enrols no kind and embeds nothing: it names a sub-target of a
18111 // projection that does not exist. The reject is therefore keyed on the
18112 // ABSENCE of `searchable` and on nothing else — `filterable` / `rankable` are
18113 // orthogonal axes that neither supply nor substitute for it.
18114 //
18115 // FAMILY: [`EngineError::WriteValidation`], per decision #18 (0.8.20 Slice 22)
18116 // — the write-SHAPE boundary is ONE family, and this is a shape rejection.
18117 // Deliberately a SEPARATE loop from the name checks above: those are NAME
18118 // rejections that keep `InvalidArgument { msg }` because the message naming
18119 // the offending value is the caller's only handle on it. `dev/design/errors.md`
18120 // ("Validation boundary") states that split; keeping the two loops apart keeps
18121 // the split visible in the code and this change one-line-reversible.
18122 //
18123 // KNOWN COST (TC-95/TC-98, HITL-deferred): `WriteValidation` is a UNIT
18124 // variant, so this refusal cannot name WHICH spec in `specs` was invalid —
18125 // strictly worse than the name rejections above. Recorded, not worked around.
18126 for spec in specs {
18127 if spec.roles.contains(&ProjectionRole::Searchable) {
18128 continue;
18129 }
18130 if spec.fts.is_some() || spec.vector.is_some() {
18131 return Err(EngineError::WriteValidation);
18132 }
18133 }
18134
18135 // A destructive source change retains the normal drop-first error precedence.
18136 // Check the pre-drop registry before inspecting a proposed source's backfill
18137 // rows; otherwise a composite at that source could mask ProjectionDestructive.
18138 let pre_drop = load_projection_registry(tx).map_err(|_| EngineError::Storage)?;
18139 let mut refresh_vector_attributes = false;
18140 for spec in specs {
18141 let desired = StoredProjection::from_spec(spec);
18142 if let Some(existing) = pre_drop.get(&spec.name) {
18143 let replacing = drop.iter().any(|name| name == &spec.name);
18144 if !replacing && is_destructive_projection_change(existing, &desired) {
18145 return Err(EngineError::ProjectionDestructive {
18146 name: spec.name.clone(),
18147 delta: describe_projection_delta(existing, &desired),
18148 });
18149 }
18150 if replacing
18151 && existing.source != desired.source
18152 && desired.roles.contains(&ProjectionRole::Filterable)
18153 {
18154 refresh_vector_attributes = true;
18155 }
18156 }
18157 }
18158
18159 // A declared nested source is scalar-only. Validate the complete backfill
18160 // set before any registry mutation so a composite terminal rolls the whole
18161 // configuration request back with the existing write-validation family.
18162 for spec in specs {
18163 let desired = StoredProjection::from_spec(spec);
18164 validate_projection_source_backfill(tx, &spec.name, &desired)?;
18165 }
18166
18167 let mut delta = ProjectionDelta::default();
18168
18169 // 0.8.20 Slice 20c fix-1 (codex §9 [P2]) — snapshot "is the dense arm
18170 // declared?" BEFORE any registry mutation. Together with the same read taken
18171 // after them it identifies the ONE transition that owns the inverse of this
18172 // slice's enrolment: declared -> not-declared. See
18173 // [`unenrol_registry_vector_node_kinds`] for why the inverse is keyed to that
18174 // TRANSITION rather than to the bare post-state.
18175 let vector_declared_before =
18176 vector_projection_declared(tx).map_err(|_| EngineError::Storage)?;
18177
18178 // (1) Explicit drops. Omission never drops (C3); only this list does.
18179 let before_drop = load_projection_registry(tx).map_err(|_| EngineError::Storage)?;
18180 for name in drop {
18181 if before_drop.contains_key(name) {
18182 clear_attribute_projection(tx, name).map_err(|_| EngineError::Storage)?;
18183 remove_projection_row(tx, name).map_err(|_| EngineError::Storage)?;
18184 delta.dropped.push(name.clone());
18185 }
18186 // dropping an absent projection is an idempotent no-op, not an error.
18187 }
18188
18189 // (2) Diff each spec against the post-drop registry.
18190 let current = load_projection_registry(tx).map_err(|_| EngineError::Storage)?;
18191 for spec in specs {
18192 let desired = StoredProjection::from_spec(spec);
18193 match current.get(&spec.name) {
18194 Some(existing) if existing == &desired => {
18195 // Idempotent re-registration — no-op (the keystone acceptance).
18196 }
18197 Some(existing) => {
18198 if is_destructive_projection_change(existing, &desired) {
18199 return Err(EngineError::ProjectionDestructive {
18200 name: spec.name.clone(),
18201 delta: describe_projection_delta(existing, &desired),
18202 });
18203 }
18204 persist_projection_row(tx, &spec.name, &desired)
18205 .map_err(|_| EngineError::Storage)?;
18206 clear_attribute_projection(tx, &spec.name).map_err(|_| EngineError::Storage)?;
18207 backfill_attribute(tx, &spec.name, &desired).map_err(|_| EngineError::Storage)?;
18208 if desired.wants_eav() {
18209 delta.built.push(spec.name.clone());
18210 }
18211 // 0.8.20 Slice 15e fix-2 finding 2 [P2] — this arm ONLY runs when
18212 // the registry row actually CHANGED (`existing != desired`), so the
18213 // delta MUST reflect that change; otherwise an accepted mutation
18214 // reports `unchanged = true` — a no-op lie to SDK callers. The prior
18215 // `&& !existing.has_deferred()` guard suppressed a deferred-ONLY
18216 // change (e.g. `rankable` → `rankable + vector`, which builds no EAV
18217 // so `built` stays empty): the row persisted but `delta` came back
18218 // empty. Mirror the fresh-registration push (`if
18219 // desired.has_deferred()`). Every valid non-empty spec has
18220 // `wants_eav()` OR `has_deferred()`, so on a real change at least one
18221 // of `built`/`deferred` is now populated ⇒ `unchanged` can never be
18222 // `true` on a persisted change. A genuine no-op (identical spec)
18223 // takes the idempotent arm above and is untouched.
18224 if desired.has_deferred() {
18225 delta.deferred.push(spec.name.clone());
18226 }
18227 }
18228 None => {
18229 persist_projection_row(tx, &spec.name, &desired)
18230 .map_err(|_| EngineError::Storage)?;
18231 clear_attribute_projection(tx, &spec.name).map_err(|_| EngineError::Storage)?;
18232 backfill_attribute(tx, &spec.name, &desired).map_err(|_| EngineError::Storage)?;
18233 if desired.wants_eav() {
18234 delta.built.push(spec.name.clone());
18235 }
18236 if desired.has_deferred() {
18237 delta.deferred.push(spec.name.clone());
18238 }
18239 }
18240 }
18241 }
18242
18243 // 0.8.20 Slice 15e — after the registry mutations, reconcile the live vec0
18244 // shape with the (possibly changed) `filterable` set: a NON-DESTRUCTIVE
18245 // reshape adds/removes the `attr_<hex>` pre-KNN columns preserving every
18246 // row's embedding (TC-46, HITL Option 1). Runs on the caller's write
18247 // transaction, so the reshape commits atomically with the registry row. On an
18248 // idempotent re-registration the desired set equals the live set, so this is a
18249 // no-op (vec0 untouched) and `delta.unchanged` above is unaffected. Skipped
18250 // when there is no embedder profile (⇒ no `vector_default` to reshape).
18251 if let Ok(dimension) = default_profile_dimension(tx) {
18252 if refresh_vector_attributes {
18253 refresh_vector_attr_values(tx, dimension).map_err(|_| EngineError::Storage)?;
18254 } else {
18255 reconcile_vector_attr_columns(tx, dimension).map_err(|_| EngineError::Storage)?;
18256 }
18257 }
18258
18259 delta.unchanged =
18260 delta.built.is_empty() && delta.dropped.is_empty() && delta.deferred.is_empty();
18261
18262 // 0.8.20 Slice 20c (R-20-DR remainder) — THE C4 RIDER. Everything above has
18263 // only *persisted* the `searchable→vector` declaration and pushed its name
18264 // onto `delta.deferred`. Acknowledging deferred work and then dropping it on
18265 // the floor is what made `drain` a FALSE-READY barrier; this call is where the
18266 // deferred work is actually enqueued onto the runtime `drain` waits on.
18267 //
18268 // fix-1 (codex §9 [P2]) — and its SYMMETRIC INVERSE, on the same
18269 // transaction. If this call removed the last `searchable→vector` declaration,
18270 // un-enrol the node kinds the forward path enrols; otherwise enrolment is a
18271 // one-way door and the write path keeps embedding for a projection the
18272 // registry no longer declares.
18273 let vector_declared_after = vector_projection_declared(tx).map_err(|_| EngineError::Storage)?;
18274 let enqueued = if vector_declared_after {
18275 // 0.8.20 Slice 22 (R-20-VC / TC-67) — THE REPORT. Scoped to a live dense
18276 // -arm declaration (with no `searchable→vector` projection there is
18277 // nothing for a kind to be unsupported FOR, so reporting would be noise
18278 // on every `filterable`/FTS-only call), but deliberately OUTSIDE the
18279 // `dense_arm_live` gate below — see [`unsupported_vector_kinds`].
18280 //
18281 // Placed AFTER `delta.unchanged` is computed, and it does not feed it:
18282 // this is a STATE report, not a diff, so an idempotent re-apply still
18283 // carries it (that is also the documented refresh path for the
18284 // declare-time residual).
18285 delta.vector_unsupported_kinds =
18286 unsupported_vector_kinds(tx).map_err(|_| EngineError::Storage)?;
18287 if dense_arm_live {
18288 enqueue_declared_vector_backfill(tx).map_err(|_| EngineError::Storage)?
18289 } else {
18290 false
18291 }
18292 } else {
18293 // fix-1 (codex §9 round 1 [P2], ledger `TC-71`) — the transition arm is
18294 // KEPT as-is and a state-keyed reconciliation is added BESIDE it; neither
18295 // subsumes the other. The transition fires when this very call removed the
18296 // last dense-arm declaration, including the case where it removed the last
18297 // `vector` sub-object with it (which leaves
18298 // `registry_governs_an_inert_dense_arm` false). The reconciliation covers
18299 // the ALREADY-AFFECTED database whose user calls `configure_projections`
18300 // again with anything at all: there `before` is already `false`, so the
18301 // transition arm is inert and the inert enrolment used to survive
18302 // indefinitely. `||` short-circuits, so the transition case pays nothing
18303 // extra; the other case pays two cached `EXISTS` probes on a governed call,
18304 // never on the hot write path.
18305 if vector_declared_before
18306 || registry_governs_an_inert_dense_arm(tx).map_err(|_| EngineError::Storage)?
18307 {
18308 unenrol_registry_vector_node_kinds(tx).map_err(|_| EngineError::Storage)?;
18309 }
18310 false
18311 };
18312 Ok((delta, enqueued))
18313}
18314
18315/// 0.8.20 Slice 20c fix-1 (codex §9 [P2] "Stop embedding after vector projection
18316/// drops") — **the inverse of [`enqueue_declared_vector_backfill`]'s enrolment.**
18317///
18318/// Slice 20c gave `_fathomdb_vector_kinds` its first governed-call-reachable
18319/// writer for a NODE kind (before it, the only one was the `#[doc(hidden)]`
18320/// `configure_vector_kind_for_test` hook). Forward without reverse is the defect:
18321/// after `drop`ping the last `searchable→vector` declaration,
18322/// [`project_canonical_node_row`]'s `kind_is_vector_indexed` gate and
18323/// [`connection_has_pending_projection_work`] both still see the enrolment, so
18324/// subsequent writes keep enqueueing embeds and `drain` keeps waiting on work for
18325/// a projection [`Engine::read_projections`] no longer reports.
18326///
18327/// # It DELETES NO EMBEDDING — that is the point
18328///
18329/// The shipped drop arm ([`clear_attribute_projection`] +
18330/// [`remove_projection_row`]) has never touched vec0, `_fathomdb_vector_rows` or
18331/// `_fathomdb_vector_kinds`, so "vectors already at rest survive a drop" is
18332/// ALREADY the shipped contract. Removing one registry row PRESERVES it; deleting
18333/// embeddings would be the destructive delta, and is not done here.
18334///
18335/// # Why keyed to the TRANSITION, not to the bare post-state
18336///
18337/// The rule is "this call removed the last vector declaration"
18338/// (`declared_before && !declared_after`), not "no vector declaration exists
18339/// now". A workspace can hold enrolments this registry never made — the test hook
18340/// does exactly that, and several shipped suites enrol a kind through it and then
18341/// declare an unrelated `filterable`-only projection (e.g.
18342/// `slice15e_prekn_filterable`). Firing on the bare post-state would un-enrol
18343/// those and silently kill a dense arm the registry never owned. In production
18344/// the two readings coincide: before this slice
18345/// `production_vector_kind_surface=[]`, so a node kind can only be enrolled
18346/// because a `searchable→vector` declaration existed.
18347///
18348/// It is still STATE-keyed, not delta-keyed: both members are reads of the
18349/// registry, never "was this spec new". Re-applying the same drop finds
18350/// `declared_before == false` and is a total no-op, and nothing re-enrols it
18351/// ([`Engine::enrol_vector_kind_if_declared`] is gated on
18352/// [`vector_projection_declared`]).
18353///
18354/// # 0.8.20 Slice 21 fix-1 — a SECOND, narrower authorisation now exists
18355///
18356/// The reasoning above is why the bare post-state cannot authorise this DELETE,
18357/// and it still stands. What it does not cover is a database that ran the
18358/// PRE-Slice-21c code and enrolled node kinds off a `{filterable, vector}`
18359/// declaration: there the registry DID own the enrolment, and no transition will
18360/// ever fire for it. [`registry_governs_an_inert_dense_arm`] adds exactly that
18361/// case — positively conditioned on the registry existing AND declaring a
18362/// `vector` sub-object AND declaring no `searchable→vector` projection, which is
18363/// strictly narrower than the bare post-state and in particular excludes every
18364/// workspace whose enrolment the registry never made. Its callers are
18365/// [`reconcile_inert_vector_enrolments_on_boot`] and the drop arm of
18366/// [`apply_projection_config`].
18367///
18368/// # `'edge_fact'` is excluded, deliberately
18369///
18370/// [`project_canonical_edge_row`] (G11) auto-registers `'edge_fact'` off the
18371/// presence of an edge BODY, unconditionally and independently of the projection
18372/// registry. That lifecycle predates this slice and is not the registry's to end,
18373/// so a node-projection drop must not take the edge dense arm down with it.
18374///
18375/// # What it deliberately does NOT do
18376///
18377/// It touches no `_fathomdb_projection_terminal` row and no readiness watermark.
18378/// A row enqueued-but-not-yet-embedded when the drop lands keeps its absent
18379/// terminal, which pins the watermark below it — harmless, because both the
18380/// scheduler and the pending-work probe join `_fathomdb_vector_kinds` and so no
18381/// longer see it, and it is precisely what lets a later RE-declaration pick the
18382/// row up again instead of stranding it.
18383fn unenrol_registry_vector_node_kinds(tx: &Connection) -> rusqlite::Result<()> {
18384 tx.execute("DELETE FROM _fathomdb_vector_kinds WHERE kind <> 'edge_fact'", [])?;
18385 Ok(())
18386}
18387
18388/// 0.8.20 Slice 21 fix-1 (codex §9 round 1 `[P2]`, ledger `TC-71`) — **does the
18389/// registry GOVERN the dense arm while declaring none?** The narrow,
18390/// positively-conditioned predicate that authorises
18391/// [`unenrol_registry_vector_node_kinds`] on a bare STATE rather than on the
18392/// `declared_before && !declared_after` transition.
18393///
18394/// # Why a state-keyed authorisation exists at all
18395///
18396/// Slice 21c gated the dense arm on the `searchable` ROLE, which closes the three
18397/// FORWARD doors. It cannot reach a database that already ran the old code: those
18398/// node kinds are already in `_fathomdb_vector_kinds`, and
18399///
18400/// - [`Engine::vector_kind_needs_enrolment`] returns early the moment
18401/// [`kind_is_vector_indexed`] is true, so it never consults the new role-aware
18402/// predicate for an EXISTING registration; and
18403/// - [`project_canonical_node_row`] gates the embed enqueue solely on registry
18404/// membership (deliberately — that is the hot write path, and the decision is
18405/// meant to live upstream).
18406///
18407/// So without this, upgrading does not actually stop the billable, unexpected
18408/// embeddings for exactly the population TC-71 was raised for — the finding's
18409/// whole harm survives the fix unless the user happens to perform a
18410/// searchable-vector-to-none transition later.
18411///
18412/// # THE TRAP: why it is not `!vector_projection_declared`
18413///
18414/// [`vector_projection_declared`] answers `false` when the registry table is
18415/// ABSENT (pre-step-24) or merely EMPTY — which is every LEGACY database, many of
18416/// which have a legitimately working dense arm enrolled by other means (the
18417/// `#[doc(hidden)]` `configure_vector_kind_for_test` hook is one; before this
18418/// slice `production_vector_kind_surface=[]`, but a workspace is not obliged to
18419/// have reached its enrolment through the registry). Un-enrolling on that bare
18420/// negative would silently switch vector search OFF for all of them — a far worse
18421/// regression than TC-71 itself. So the rule is POSITIVE on all three counts:
18422///
18423/// 1. `_fathomdb_projection_registry` EXISTS; **and**
18424/// 2. at least one row carries a `vector` sub-object (`vector_declared = 1`) —
18425/// someone actually asked for a dense arm through the registry, which is
18426/// precisely what identifies the affected population; **and**
18427/// 3. NO projection satisfies [`StoredProjection::wants_vector`], i.e. none of
18428/// them is `searchable`.
18429///
18430/// Condition 2 is the load-bearing one. It leaves untouched a registry-governed
18431/// database that declares no `vector` sub-object at all but holds enrolments from
18432/// a pre-registry era (`slice15e_prekn_filterable` is exactly that shape). Being
18433/// conservative here is the correct direction: never destroy a working dense arm.
18434///
18435/// Conditions 1+2 are the SAME two `prepare_cached` `EXISTS` probes
18436/// [`vector_projection_declared`] opens with, so a workspace that never declared
18437/// a `vector` sub-object — the overwhelmingly common shape — pays nothing beyond
18438/// them and never reaches the typed [`load_projection_registry`] read. Condition 3
18439/// is delegated to [`vector_projection_declared`] verbatim rather than re-derived,
18440/// so the authorisation and the gate cannot drift.
18441fn registry_governs_an_inert_dense_arm(conn: &Connection) -> rusqlite::Result<bool> {
18442 // (1) the registry must EXIST. A pre-step-24 database has no registry at all
18443 // and is therefore not registry-governed — hands off.
18444 let table_exists: bool = conn
18445 .prepare_cached(
18446 "SELECT EXISTS(
18447 SELECT 1 FROM sqlite_master
18448 WHERE type = 'table' AND name = '_fathomdb_projection_registry'
18449 )",
18450 )?
18451 .query_row([], |row| row.get(0))?;
18452 if !table_exists {
18453 return Ok(false);
18454 }
18455 // (2) …and it must actually DECLARE a `vector` sub-object somewhere. An empty
18456 // or vector-less registry governs no dense arm, so any enrolment present came
18457 // from outside it and is not ours to remove.
18458 let any_vector_subobject: bool = conn
18459 .prepare_cached(
18460 "SELECT EXISTS(SELECT 1 FROM _fathomdb_projection_registry WHERE vector_declared = 1)",
18461 )?
18462 .query_row([], |row| row.get(0))?;
18463 if !any_vector_subobject {
18464 return Ok(false);
18465 }
18466 // (3) …while declaring no `searchable→vector` projection. THE predicate,
18467 // reused, so this can never disagree with the gate the write path applies.
18468 Ok(!vector_projection_declared(conn)?)
18469}
18470
18471/// 0.8.20 Slice 21 fix-1 (codex §9 round 1 `[P2]`) — the BOOT arm of the
18472/// reconciliation: on every open, bring an already-enrolled inert vector kind
18473/// into agreement with the role-aware decision, so an affected database
18474/// self-heals without the user calling anything. Returns `true` iff it un-enrolled
18475/// something.
18476///
18477/// Authorised by [`registry_governs_an_inert_dense_arm`] (read that for the trap
18478/// this must not fall into), and performed by
18479/// [`unenrol_registry_vector_node_kinds`] — the SAME writer the drop inverse uses,
18480/// so `'edge_fact'` is excluded (G11 auto-registers it off the presence of an edge
18481/// body, independently of the projection registry) and **no embedding is deleted**.
18482///
18483/// # It mirrors the drop inverse exactly, because that inverse does nothing else
18484///
18485/// `apply_projection_config`'s drop arm is a single call to
18486/// [`unenrol_registry_vector_node_kinds`]: no terminal record is touched, no
18487/// readiness watermark is rewound, no row is un-stranded, and nothing is notified
18488/// (it returns `enqueued = false`). So leaving the database in "the state a drop
18489/// transition would have left it in" is exactly that one `DELETE`, and there is
18490/// no second half to mirror.
18491///
18492/// # Cheap when there is nothing to do, and idempotent
18493///
18494/// A workspace with no `vector` sub-object pays only the two cached `EXISTS`
18495/// probes the authorisation opens with. When the authorisation DOES fire, a third
18496/// cached `EXISTS` checks whether any node kind is actually enrolled, so the
18497/// steady state after the first healing open is a pure READ — no write
18498/// transaction, no `DELETE`, nothing to oscillate. `DELETE … WHERE kind <>
18499/// 'edge_fact'` is a single statement, hence atomic on its own; no explicit
18500/// transaction is opened around it.
18501///
18502/// # Placement
18503///
18504/// Runs inside `open_locked` on the writer connection, single-threaded, before
18505/// readers and the projection workers spawn — alongside the other boot
18506/// reconciliations ([`rederive_projections_on_boot`],
18507/// [`reconcile_vector_attr_columns`]) and therefore BEFORE
18508/// [`run_vector_equivalence_probe`], which is deliberate: on a database whose only
18509/// enrolment was the inert one, reconciling first leaves `_fathomdb_vector_kinds`
18510/// empty, so the probe correctly finds no dense arm to guard and the healing open
18511/// spends no embed calls at all.
18512///
18513/// # Not a data migration
18514///
18515/// It removes a registration row inside ONE live database to match that
18516/// database's own declarations. It converts no row across a version step; the
18517/// reconciliation itself introduces no migration.
18518fn reconcile_inert_vector_enrolments_on_boot(conn: &Connection) -> rusqlite::Result<bool> {
18519 if !registry_governs_an_inert_dense_arm(conn)? {
18520 return Ok(false);
18521 }
18522 // Nothing enrolled beyond the G11 edge arm ⇒ nothing to do. Keeps the steady
18523 // state a pure read instead of a no-op write transaction on every open.
18524 let any_node_kind: bool = conn
18525 .prepare_cached(
18526 "SELECT EXISTS(SELECT 1 FROM _fathomdb_vector_kinds WHERE kind <> 'edge_fact')",
18527 )?
18528 .query_row([], |row| row.get(0))?;
18529 if !any_node_kind {
18530 return Ok(false);
18531 }
18532 unenrol_registry_vector_node_kinds(conn)?;
18533 Ok(true)
18534}
18535
18536/// 0.8.20 Slice 20c (R-20-DR remainder) — is ANY `searchable→vector` projection
18537/// declared in the durable registry?
18538///
18539/// This is the corpus-wide "the dense arm is live" predicate. It is corpus-wide
18540/// rather than per-attribute for the same reason [`derive_dense_readiness`] is:
18541/// Slice 15d persists the `searchable→vector` sub-object but defers building any
18542/// per-attribute embedding, so every declared vector projection is served by the
18543/// ONE engine vector pipeline. When per-attribute embedding lands, this is where
18544/// the scoping goes — the same seam as readiness.
18545///
18546/// Safe on a pre-step-24 schema (the registry table is created by step 24): an
18547/// absent table means nothing is declared, not an error. Mirrors the guard in
18548/// [`load_projection_registry`], and uses `prepare_cached` because the write path
18549/// calls this once per un-registered-kind row.
18550///
18551/// # 0.8.20 Slice 21c (ledger `TC-71`) — it requires the `searchable` ROLE
18552///
18553/// This used to answer `EXISTS(… WHERE vector_declared = 1)`, reading the stored
18554/// `vector` sub-object and never the `roles` column. But the sub-object SELECTS
18555/// a sub-target of `searchable`; it does not confer one (exactly as `fts` does
18556/// not — see [`StoredProjection::wants_property_fts`]). So
18557/// `{roles: [filterable], vector: {}}`, which Slice 15d documents as
18558/// inert-but-round-trippable, turned the dense arm ON in any session with a live
18559/// embedder: it enrolled node kinds, backfilled the corpus, and made every later
18560/// write of those kinds enqueue an embedding. Wasted embed work and unexpected
18561/// vectors at rest for a projection meant to do nothing. The answer now comes
18562/// from [`StoredProjection::wants_vector`], the ONE predicate, so the three
18563/// gated paths cannot drift.
18564///
18565/// **This flips the forward AND inverse arms of [`apply_projection_config`] at
18566/// once**, which is a real semantic consequence and not an accident: demoting
18567/// the last `{searchable, vector}` projection to `{filterable, vector}` (or
18568/// dropping it while an inert `{filterable, vector}` sibling survives) now reads
18569/// `declared → not-declared` and therefore UN-ENROLS, where before the surviving
18570/// `vector_declared = 1` row masked the transition and the write path kept
18571/// embedding. Pinned in `tests/slice21c_vector_role_gate.rs`.
18572///
18573/// # Why the cheap `EXISTS` survives as a pre-filter
18574///
18575/// The write path calls this once per un-registered-kind row, and the
18576/// overwhelmingly common shape is a workspace that declared no `vector`
18577/// sub-object at all. `EXISTS(… vector_declared = 1)` is a NECESSARY condition
18578/// for [`StoredProjection::wants_vector`], so keeping it as a fast negative
18579/// leaves that workspace paying exactly the two cached `EXISTS` probes it paid
18580/// before — no typed load, no `BTreeMap`, no uncached `prepare`. Only a
18581/// workspace that HAS a `vector` sub-object somewhere pays the
18582/// [`load_projection_registry`] read, and there the registry is a handful of
18583/// app-declared rows; in the ordinary `searchable→vector` case the kind is
18584/// enrolled after the first probe and `kind_is_vector_indexed` short-circuits
18585/// this call entirely from then on.
18586fn vector_projection_declared(conn: &Connection) -> rusqlite::Result<bool> {
18587 let table_exists: bool = conn
18588 .prepare_cached(
18589 "SELECT EXISTS(
18590 SELECT 1 FROM sqlite_master
18591 WHERE type = 'table' AND name = '_fathomdb_projection_registry'
18592 )",
18593 )?
18594 .query_row([], |row| row.get(0))?;
18595 if !table_exists {
18596 return Ok(false);
18597 }
18598 // Fast negative: no `vector` sub-object anywhere ⇒ certainly no dense arm.
18599 let any_vector_subobject: bool = conn
18600 .prepare_cached(
18601 "SELECT EXISTS(SELECT 1 FROM _fathomdb_projection_registry WHERE vector_declared = 1)",
18602 )?
18603 .query_row([], |row| row.get(0))?;
18604 if !any_vector_subobject {
18605 return Ok(false);
18606 }
18607 // `roles` is persisted as a comma-joined sorted string, so it is not a
18608 // trustworthy SQL predicate (a `LIKE` would match a forward-compat token that
18609 // merely CONTAINS a role spelling). Answer through the typed registry and the
18610 // ONE predicate instead.
18611 Ok(load_projection_registry(conn)?.values().any(StoredProjection::wants_vector))
18612}
18613
18614/// 0.8.20 Slice 20c (R-20-DR remainder) — enrol `kind` in the vector pipeline.
18615///
18616/// `INSERT OR IGNORE`, so it is idempotent and never disturbs an existing
18617/// registration's `profile`/`created_at`. Same statement shape the G11 edge path
18618/// uses for `'edge_fact'` ([`project_canonical_edge_row`]).
18619fn register_vector_kind(tx: &Connection, kind: &str) -> rusqlite::Result<()> {
18620 tx.execute(
18621 "INSERT OR IGNORE INTO _fathomdb_vector_kinds(kind, profile, created_at)
18622 VALUES(?1, ?2, 0)",
18623 params![kind, DEFAULT_VECTOR_PROFILE],
18624 )?;
18625 Ok(())
18626}
18627
18628/// 0.8.20 Slice 20c (R-20-DR remainder) — **the flush barrier's enqueue half**
18629/// (`api-surface.md` **C4** rider: `drain` is a barrier, not a trigger, so
18630/// deferred/backfill rows must be enqueued on the same projection runtime `drain`
18631/// waits on).
18632///
18633/// Runs on the caller's `configure_projections` write transaction, AFTER the
18634/// registry mutations, so the enrolment + re-enqueue commit atomically with the
18635/// declaration that caused them. Returns `true` iff work was enqueued — the
18636/// caller must then `notify_new_work()` (after the commit; the dispatcher opens
18637/// its own connection).
18638///
18639/// # The defect this closes
18640///
18641/// `project_canonical_node_row` writes a PERMANENT `'up_to_date'` terminal for
18642/// any row whose kind was not vector-registered *at write time*, and before this
18643/// slice NOTHING but the `#[doc(hidden)]` test hook ever registered a node kind
18644/// (`slice-G0-design.md`: `production_vector_kind_surface=[]`). So the ordinary
18645/// "turn the dense arm on over an existing corpus" flow — write rows, then
18646/// declare `searchable→vector` — left every row terminally marked done with no
18647/// vector and no way to get one short of an operator `rebuild`. Both
18648/// `drain`/`wait_for_idle` and `derive_dense_readiness` read that terminal
18649/// through [`connection_has_pending_projection_work`], so the corpus reported
18650/// `ready` while nothing would ever embed it.
18651///
18652/// # Shape (deliberately the `run_rebuild` shape, scoped)
18653///
18654/// `run_rebuild` truncates the readiness terminals and rewinds the projection
18655/// cursor so the scheduler re-walks the corpus. This does the same, but scoped to
18656/// the rows the declaration newly covers, and it does NOT truncate anything else:
18657///
18658/// 1. enrol every vector-eligible node kind present in `canonical_nodes`
18659/// (`row_kind IN ('leaf','coverage')` — the `index_targets_for_row_kind`
18660/// vector-eligibility predicate; `graph` rows are lexically searchable but
18661/// never embedded, so enrolling on them would silently start embedding
18662/// structural rows) **that the vector writer can commit**
18663/// ([`kind_is_vector_committable`], fix-2 / codex §9 [P1]);
18664/// 2. (and 3.) un-strand the rows that enrolment now covers, via
18665/// [`reenqueue_stranded_vector_rows`] — shared verbatim with the write path's
18666/// late enrolment.
18667///
18668/// # Why it is IDEMPOTENT (R-20-PR: "re-registration is a no-op")
18669///
18670/// Every step keys off *state*, not off "was this declaration new": step 1 is
18671/// `INSERT OR IGNORE`; steps 2-3 act only on rows that are stranded RIGHT NOW.
18672/// Once the backfill has been drained those rows carry vectors, so a re-apply
18673/// finds an empty stranded set, returns `false`, and touches neither the
18674/// terminals nor the cursor. No rewind, no re-embed, no spurious `embedding`
18675/// window.
18676///
18677/// # Not a data migration
18678///
18679/// This re-enqueues embed work inside ONE live database at the caller's request.
18680/// It converts no rows across a version step and introduces no migration (HITL
18681/// 2026-07-21; cf. TC-46's in-place vec0 reshape).
18682/// 0.8.20 Slice 22 (R-20-VC / **TC-67**) — the ONE scan of "which node kinds in
18683/// this corpus are candidates for the dense arm?".
18684///
18685/// `row_kind IN ('leaf', 'coverage')` is the `index_targets_for_row_kind` vector
18686/// -eligibility predicate: `graph` rows are lexically searchable but NEVER
18687/// embedded, so they are excluded here on a ROW-KIND axis that has nothing to do
18688/// with the `kind` vocabulary — including them would make TC-67 report structural
18689/// rows as "unsupported kinds", which is a different (and false) statement.
18690///
18691/// Extracted so [`enqueue_declared_vector_backfill`] (which enrols the
18692/// commit-able half) and [`unsupported_vector_kinds`] (which reports the other
18693/// half) partition ONE list rather than running two hand-copied queries that
18694/// could drift — the same TC-56 anti-drift discipline that made
18695/// [`kind_is_vector_committable`] delegate to [`resolve_source_type`].
18696///
18697/// `SELECT DISTINCT … ORDER BY kind` gives the caller a sorted, de-duplicated
18698/// list for free, which is the reported ordering.
18699fn vector_eligible_node_kinds(tx: &Connection) -> rusqlite::Result<Vec<String>> {
18700 let mut stmt = tx.prepare(
18701 "SELECT DISTINCT kind FROM canonical_nodes
18702 WHERE row_kind IN ('leaf', 'coverage')
18703 ORDER BY kind",
18704 )?;
18705 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
18706 rows.collect::<rusqlite::Result<Vec<String>>>()
18707}
18708
18709/// 0.8.20 Slice 22 (R-20-VC / **TC-67**) — **the report that replaces the
18710/// silence.** The vector-eligible node kinds present in the corpus that
18711/// [`kind_is_vector_committable`] excludes, i.e. the exact complement of the set
18712/// [`enqueue_declared_vector_backfill`] enrols.
18713///
18714/// Populates [`ProjectionDelta::vector_unsupported_kinds`]. Read that field's
18715/// doc-comment for the naming, the state-not-diff semantics and the residual;
18716/// what belongs HERE is the one thing the call SITE decides:
18717///
18718/// **It is deliberately NOT gated on `dense_arm_live`.** The enrolment it mirrors
18719/// is (`apply_projection_config` only calls `enqueue_declared_vector_backfill`
18720/// with a live embedder, the Q6a graceful-absent path), but this answer does not
18721/// depend on the session: [`resolve_source_type`]'s vocabulary is a compile-time
18722/// constant, so "this kind can never be embedded" is equally true with no
18723/// embedder attached. Gating it would hide the permanent fact behind the
18724/// transient one, which is the very conflation TC-67 exists to end — a
18725/// no-embedder caller is exactly the caller who most needs to know that
18726/// attaching an embedder later will still not embed these kinds.
18727fn unsupported_vector_kinds(tx: &Connection) -> rusqlite::Result<Vec<String>> {
18728 Ok(vector_eligible_node_kinds(tx)?
18729 .into_iter()
18730 .filter(|kind| !kind_is_vector_committable(kind))
18731 .collect())
18732}
18733
18734fn enqueue_declared_vector_backfill(tx: &Connection) -> rusqlite::Result<bool> {
18735 if !vector_projection_declared(tx)? {
18736 return Ok(false);
18737 }
18738
18739 // (1) Enrol the vector-eligible kinds the live corpus actually contains —
18740 // RESTRICTED to the ones the vector writer can actually commit
18741 // ([`kind_is_vector_committable`], fix-2 / codex §9 [P1]). Enrolling a kind
18742 // outside `resolve_source_type`'s locked vocabulary wedges the projection
18743 // worker forever and starves every other kind with it.
18744 //
18745 // 0.8.20 Slice 22 (TC-67) — the kinds this filter DROPS are what
18746 // [`unsupported_vector_kinds`] reports; both read the same scan through
18747 // [`vector_eligible_node_kinds`] so the report can never describe a
18748 // different set from the one actually excluded.
18749 let kinds = vector_eligible_node_kinds(tx)?;
18750 for kind in kinds.iter().filter(|kind| kind_is_vector_committable(kind)) {
18751 register_vector_kind(tx, kind)?;
18752 }
18753
18754 // (2)+(3) Un-strand the rows the new enrolment now covers.
18755 reenqueue_stranded_vector_rows(tx)
18756}
18757
18758/// 0.8.20 Slice 20c — steps (2) and (3) of the declared-backfill above, as their
18759/// own function because **both** enrolment doors owe this treatment.
18760///
18761/// fix-2 (codex §9 [P2]): [`enqueue_declared_vector_backfill`] is the DECLARE-time
18762/// door; [`Engine::enrol_batch_vector_kinds`] is the WRITE-time one, and it used to
18763/// enrol a kind while enqueueing only the row in its own batch. A database that
18764/// persisted a `searchable→vector` declaration while opened WITHOUT an embedder
18765/// (Q6a graceful-absent: it defers, enrolling nothing), then reopened WITH one and
18766/// wrote the same kind BEFORE re-applying the projection, therefore drained the new
18767/// row and reported `ready` while every row from the no-embedder session kept its
18768/// permanent `'up_to_date'` terminal and no vector. That is a FALSE READY — the
18769/// exact defect class R-20-DR exists to eliminate — so the two doors share ONE
18770/// implementation rather than one of them carrying a partial copy.
18771///
18772/// Returns `true` iff work was re-enqueued; the caller must then `notify_new_work()`
18773/// (after its commit — the dispatcher opens its own connection).
18774///
18775/// 2. find the STRANDED rows — vector-eligible, now vector-kind-registered,
18776/// carrying an `'up_to_date'` terminal, and carrying NO `_fathomdb_vector_rows`
18777/// row — and delete their terminals so the scheduler's `terminal IS NULL`
18778/// predicate sees them again;
18779/// 3. rewind the readiness watermark to just below the lowest stranded cursor, so
18780/// the scheduler's `write_cursor > cursor` filter reaches them.
18781///
18782/// The `_fathomdb_vector_kinds` join is what scopes this to the dense arm: a kind
18783/// that is not enrolled (including one that is not commit-able, per
18784/// [`kind_is_vector_committable`]) is not stranded — it has no dense arm to be
18785/// behind on.
18786///
18787/// Idempotent by construction: it acts only on rows that are stranded RIGHT NOW, so
18788/// once drained the set is empty, it returns `false`, and neither the terminals nor
18789/// the cursor are touched. A `'failed'` terminal is deliberately NOT re-enqueued
18790/// (the filter is `'up_to_date'`): re-enqueueing it would loop a permanently-failing
18791/// row forever, and the documented failure boundary is that a terminally-failed
18792/// embed stops being outstanding work (see [`derive_dense_readiness`]).
18793fn reenqueue_stranded_vector_rows(tx: &Connection) -> rusqlite::Result<bool> {
18794 // (2) The stranded set: covered by the dense arm, terminally marked done, no
18795 // vector. `MIN` first so a no-op apply costs one indexed probe and stops.
18796 let lowest_stranded: Option<u64> = tx.query_row(
18797 "SELECT MIN(n.write_cursor)
18798 FROM canonical_nodes n
18799 JOIN _fathomdb_vector_kinds k ON k.kind = n.kind
18800 JOIN _fathomdb_projection_terminal t ON t.write_cursor = n.write_cursor
18801 LEFT JOIN _fathomdb_vector_rows v ON v.write_cursor = n.write_cursor
18802 WHERE n.row_kind IN ('leaf', 'coverage')
18803 AND t.state = 'up_to_date'
18804 AND v.write_cursor IS NULL",
18805 [],
18806 |row| row.get::<_, Option<u64>>(0),
18807 )?;
18808 let Some(lowest_stranded) = lowest_stranded else {
18809 return Ok(false);
18810 };
18811
18812 tx.execute(
18813 "DELETE FROM _fathomdb_projection_terminal
18814 WHERE write_cursor IN (
18815 SELECT n.write_cursor
18816 FROM canonical_nodes n
18817 JOIN _fathomdb_vector_kinds k ON k.kind = n.kind
18818 JOIN _fathomdb_projection_terminal t ON t.write_cursor = n.write_cursor
18819 LEFT JOIN _fathomdb_vector_rows v ON v.write_cursor = n.write_cursor
18820 WHERE n.row_kind IN ('leaf', 'coverage')
18821 AND t.state = 'up_to_date'
18822 AND v.write_cursor IS NULL
18823 )",
18824 [],
18825 )?;
18826
18827 // (3) Rewind the readiness watermark just below the lowest stranded row so the
18828 // scheduler's `write_cursor > cursor` filter reaches it. Never move it
18829 // FORWARD: rows above the watermark that still hold their terminals are
18830 // skipped by the scheduler's `terminal IS NULL` predicate, and
18831 // `advance_projection_cursor` walks the watermark back up over them.
18832 let rewind_to = lowest_stranded.saturating_sub(1);
18833 if load_projection_cursor(tx)? > rewind_to {
18834 store_projection_cursor(tx, rewind_to)?;
18835 }
18836 Ok(true)
18837}
18838
18839/// 0.8.20 Slice 15d (R-20-PR, Q5) — BOOT re-derive: the engine `ProjectionSpec`
18840/// is a DERIVED cache, re-driven idempotently on boot. For every persisted
18841/// registry declaration, clear + backfill its EAV / property-FTS rows from the
18842/// canonical nodes — so a DB whose registry row survives but whose projection
18843/// rows are missing/partial (a crash window, a restored registry) CONVERGES on
18844/// the next open. A no-op (single empty-table read) when no projections are
18845/// declared — which is every pre-`configure_projections` DB. Runs on the writer
18846/// connection, single-threaded, before readers spawn.
18847fn rederive_projections_on_boot(conn: &Connection) -> rusqlite::Result<()> {
18848 let registry = load_projection_registry(conn)?;
18849 if registry.is_empty() {
18850 return Ok(());
18851 }
18852 conn.execute_batch("BEGIN IMMEDIATE")?;
18853 let result = (|| {
18854 for (name, stored) in ®istry {
18855 clear_attribute_projection(conn, name)?;
18856 backfill_attribute(conn, name, stored)?;
18857 }
18858 Ok(())
18859 })();
18860 match result {
18861 Ok(()) => conn.execute_batch("COMMIT"),
18862 Err(err) => {
18863 let _ = conn.execute_batch("ROLLBACK");
18864 Err(err)
18865 }
18866 }
18867}
18868
18869/// EXP-S (0.8.14 Slice 5) — the `row_kind -> index-target set` dispatch
18870/// (ADR-0.8.14 §D2), and the OPP-12 forward-compat seam (ADR-0.8.14 §D5(a) /
18871/// ledger `TC-1`).
18872///
18873/// This is deliberately a per-kind LOOKUP rather than branching inlined at each
18874/// write call-site: it is the single seam a later declarative OPP-12 projection
18875/// registry (`dev/design/projection-registry-and-async-embed.md`) would wrap to
18876/// populate `row_kind -> {filterable, searchable->FTS (same-txn), searchable->
18877/// vector (async)}` without reshaping the substrate. Per D5, EXP-S implements
18878/// NO OPP-12 surface here (OPP-12 lands >=0.9.x; re-check at its scheduling) —
18879/// this function only records the index-target intent so the async-vs-sync split
18880/// (D5(b)) and the per-kind-extensible terminal-cursor readiness (D5(c)) stay
18881/// wrappable.
18882///
18883/// `Leaf` MUST preserve today's behavior exactly: FTS (sync) + vector (async,
18884/// gated by `kind_is_vector_indexed`).
18885fn index_targets_for_row_kind(row_kind: RowKind) -> IndexTargetSet {
18886 match row_kind {
18887 // Normal record — identical to pre-EXP-S behavior.
18888 RowKind::Leaf => IndexTargetSet { fts: true, vector: true },
18889 // Coverage/summary rows — searchable and embeddable.
18890 RowKind::Coverage => IndexTargetSet { fts: true, vector: true },
18891 // Graph structural rows — lexically searchable, not embedded.
18892 RowKind::Graph => IndexTargetSet { fts: true, vector: false },
18893 }
18894}
18895
18896/// EXP-S (0.8.14 Slice 5, D2/D5) — apply the per-`row_kind` index-target
18897/// dispatch for one just-inserted canonical node row (write_cursor `cursor`).
18898///
18899/// Preserves the OPP-12-shaped split (D5(b)): FTS is written in THIS
18900/// transaction (same-txn `searchable->FTS`); vector work is only *enqueued*
18901/// here into `_fathomdb_projection_state` and embedded later, asynchronously,
18902/// by the projection worker pool (`searchable->vector`). When the row projects
18903/// into no async vector index, its readiness is terminated up-front (D5(c),
18904/// per-kind-extensible) so `advance_projection_cursor` can walk past it.
18905///
18906/// Returns `true` iff async vector work was enqueued (the caller must then
18907/// `notify_new_work`). For `RowKind::Leaf` this is behavior-identical to the
18908/// pre-EXP-S inline node path.
18909fn project_canonical_node_row(
18910 tx: &Connection,
18911 cursor: u64,
18912 kind: &str,
18913 body: &str,
18914 row_kind: RowKind,
18915 pass: ProjectionPass,
18916 node_active: bool,
18917) -> rusqlite::Result<bool> {
18918 let targets = index_targets_for_row_kind(row_kind);
18919 if targets.fts && pass.writes_fts() {
18920 tx.execute(
18921 "INSERT INTO search_index(body, kind, write_cursor) VALUES(?1, ?2, ?3)",
18922 params![body, kind, cursor],
18923 )?;
18924 // F5 (0.8.14 Slice 10) — same coexisting `searchable->FTS` target also
18925 // populates the multi-column `search_index_v2` (kind/body/status) so a
18926 // BM25F query can field-weight the lexical arm. Written SYNCHRONOUSLY in
18927 // THIS transaction, exactly like `search_index` (rowid==write_cursor
18928 // identity preserved). The `status` field mirrors the migration-17
18929 // O(N) re-index: `$.status` from a JSON body, guarded by `json_valid` so
18930 // non-JSON bodies index an empty status. NOTE (codex fix-1 finding 2):
18931 // this is F5's OWN `$.status`-derived field for the BM25F `status`
18932 // column — it is NOT (yet) the value the shipped G10 SearchFilter reads.
18933 // G10 filtering reads the vec0 `status` column, which is still hardwired
18934 // to the empty-string sentinel; wiring G10 onto this field is out of
18935 // scope for F5. Determinism (R-SUB-2) is preserved: the derivation is
18936 // a pure function of `body`, evaluated in-SQL identically on every run.
18937 tx.execute(
18938 "INSERT INTO search_index_v2(kind, body, status, write_cursor)
18939 VALUES(
18940 ?1,
18941 ?2,
18942 CASE WHEN json_valid(?2)
18943 THEN COALESCE(json_extract(?2, '$.status'), '')
18944 ELSE '' END,
18945 ?3
18946 )",
18947 params![kind, body, cursor],
18948 )?;
18949 }
18950 // 0.8.20 Slice 15d (R-20-EAV) — same-transaction attribute projection. Only
18951 // the full `Write` pass re-derives attributes (see `writes_attributes`): the
18952 // FtsOnly tokenizer reproject predates step 24 and must not touch the
18953 // registry/attribute tables; VectorOnly rebuilds only vector shadows. A full
18954 // operator FTS rebuild uses `Write`, so it re-derives attributes after the
18955 // truncate.
18956 //
18957 // fix-2 [P2]: gated on `node_active`. The at-rest attribute projection tracks
18958 // EXACTLY the backfill's row set — `state = 'active' AND superseded_at IS NULL`
18959 // (see `backfill_attribute`). Unlike node-FTS / vector shadows (whose stale
18960 // versions are excluded by the canonical read path's `superseded_at IS NULL`
18961 // / `state = 'active'` join), the property tables carry NO read-side lifecycle
18962 // filter (`property_search_index` is an FTS5 table that cannot), so a pending
18963 // or superseded node's attribute values would otherwise LEAK into a
18964 // same-session property filter / property-FTS. The write path passes
18965 // `state == Active`; a projector-replay rebuild passes `active ∧ non-superseded`
18966 // per row. Lifecycle transitions maintain the store directly (see
18967 // `Engine::transition`). Passes where `writes_attributes()` is false ignore the
18968 // flag entirely.
18969 if pass.writes_attributes() && node_active {
18970 project_node_attributes(tx, cursor as i64, body)?;
18971 }
18972 // 0.8.20 Slice 20c (R-20-DR remainder) — UNCHANGED, deliberately. Late
18973 // enrolment of a kind first written AFTER a `searchable→vector` declaration
18974 // happens in [`Engine::enrol_vector_kind_if_declared`], upstream of this
18975 // transaction, NOT here: the decision needs the engine's LIVE embedder, which
18976 // a free function holding only a `Connection` cannot see. Enrolling without
18977 // one would queue embeds that can only retry-then-fail.
18978 let enqueue_vector = targets.vector && kind_is_vector_indexed(tx, kind).unwrap_or(false);
18979 if pass.writes_vector_state() {
18980 if enqueue_vector {
18981 tx.execute(
18982 "INSERT INTO _fathomdb_projection_state(kind, last_enqueued_cursor, updated_at)
18983 VALUES(?1, ?2, 0)
18984 ON CONFLICT(kind) DO UPDATE SET last_enqueued_cursor = excluded.last_enqueued_cursor",
18985 params![kind, cursor],
18986 )?;
18987 } else {
18988 // Never-vector-projected rows terminate the cursor up-front so
18989 // `advance_projection_cursor` can advance the readiness watermark.
18990 record_projection_terminal(tx, cursor, "up_to_date")?;
18991 }
18992 }
18993 Ok(enqueue_vector)
18994}
18995
18996/// 0.8.20 Slice 5a (R-20-E1, work item 1) — the EDGE half of the total
18997/// projector, extracted verbatim from the inlined `commit_batch` edge arm.
18998///
18999/// Before this extraction there was NO edge projector function: `commit_batch`
19000/// inlined the edge FTS insert + the edge vector enqueue, and
19001/// `rebuild_shadow_state` re-implemented a SUBSET of it (edge FTS only, and only
19002/// for body-carrying edges), so a projector-replay rebuild silently dropped the
19003/// rest — notably the `up_to_date` readiness terminal that the write path
19004/// records for a body-less structural edge. With both sites now calling this one
19005/// function, the write path and the rebuild path produce identical edge
19006/// projections by construction.
19007///
19008/// Mirrors [`project_canonical_node_row`]'s split (ADR-0.8.14 §D5(b)): FTS in
19009/// THIS transaction; vector work only ENQUEUED, embedded later by the worker
19010/// pool. Edge bodies enqueue under the fixed kind `"edge_fact"` so
19011/// `resolve_source_type` maps them to `source_type = "edge_fact"` in
19012/// `vector_default` (partition correctness); that kind is auto-registered in
19013/// `_fathomdb_vector_kinds` (idempotent).
19014///
19015/// Returns `true` iff async vector work was enqueued.
19016fn project_canonical_edge_row(
19017 tx: &Connection,
19018 cursor: u64,
19019 kind: &str,
19020 body: Option<&str>,
19021 pass: ProjectionPass,
19022) -> rusqlite::Result<bool> {
19023 // G11 — edge FTS projection into `search_index_edges` (separate table from
19024 // node-body `search_index` — Option B partition). Body-less structural
19025 // edges carry no lexical content and project no FTS row.
19026 if pass.writes_fts() {
19027 if let Some(edge_body) = body {
19028 tx.execute(
19029 "INSERT INTO search_index_edges(body, kind, write_cursor)
19030 VALUES(?1, ?2, ?3)",
19031 params![edge_body, kind, cursor],
19032 )?;
19033 }
19034 }
19035 let enqueue_vector = body.is_some();
19036 if pass.writes_vector_state() {
19037 if enqueue_vector {
19038 let now_unix =
19039 SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
19040 tx.execute(
19041 "INSERT OR IGNORE INTO _fathomdb_vector_kinds(kind, profile, created_at)
19042 VALUES('edge_fact', 'default', ?1)",
19043 params![now_unix],
19044 )?;
19045 tx.execute(
19046 "INSERT INTO _fathomdb_projection_state(
19047 kind, last_enqueued_cursor, updated_at
19048 ) VALUES('edge_fact', ?1, 0)
19049 ON CONFLICT(kind) DO UPDATE
19050 SET last_enqueued_cursor = excluded.last_enqueued_cursor",
19051 params![cursor],
19052 )?;
19053 // Do NOT call record_projection_terminal — let the scheduler embed
19054 // the body and mark it terminal after projection.
19055 } else {
19056 record_projection_terminal(tx, cursor, "up_to_date")?;
19057 }
19058 }
19059 Ok(enqueue_vector)
19060}
19061
19062/// F5 (0.8.14 Slice 10, fix-1) — tokenizer for the in-engine BM25F scorer.
19063///
19064/// Tokenizes `text` through the SAME FTS5 tokenizer that `search_index_v2` uses
19065/// for candidate recall (`porter unicode61 remove_diacritics 2`), so the scorer
19066/// measures term-frequency, document-frequency, field length, and average field
19067/// length under the index's own tokenization — porter stemming + unicode61
19068/// case-fold + diacritic folding. The previous implementation hand-rolled a
19069/// second lowercase-alnum splitter; a stemmed/diacritic variant recalled by
19070/// `MATCH` (e.g. query `run` vs indexed `running`, or `cafe` vs `café`) was then
19071/// scored as if the term were absent, so ranking was wrong for exactly those
19072/// variants (codex §9 fix-1 finding 1). Reusing FTS5 itself makes scoring
19073/// tokenization-faithful without re-implementing porter/unicode61 in Rust.
19074///
19075/// Mechanism: round-trip `text` through a temp single-column FTS5 table with the
19076/// identical tokenizer, then read the emitted token instances back via the
19077/// `fts5vocab(..., 'instance')` companion. The token multiset is returned in
19078/// index order (duplicates kept) so callers count tf and field length directly.
19079/// Query terms and every candidate field go through this one path, so all four
19080/// statistics are consistent with each other and with the FTS5 index the scorer
19081/// ranks.
19082fn fts5_tokenize(connection: &Connection, text: &str) -> rusqlite::Result<Vec<String>> {
19083 connection.execute_batch(
19084 "CREATE VIRTUAL TABLE IF NOT EXISTS temp.bm25f_tok
19085 USING fts5(t, tokenize = 'porter unicode61 remove_diacritics 2');
19086 CREATE VIRTUAL TABLE IF NOT EXISTS temp.bm25f_tok_vocab
19087 USING fts5vocab('bm25f_tok', 'instance');
19088 DELETE FROM temp.bm25f_tok;",
19089 )?;
19090 connection.execute("INSERT INTO temp.bm25f_tok(t) VALUES(?1)", params![text])?;
19091 let mut stmt =
19092 connection.prepare("SELECT term FROM temp.bm25f_tok_vocab ORDER BY \"offset\"")?;
19093 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
19094 rows.collect()
19095}
19096
19097/// F5 (0.8.14 Slice 10) — build the FTS5 `MATCH` expression for candidate
19098/// recall from the query's tokens: each token is a double-quoted FTS5 string
19099/// (tokens are FTS5-emitted stems — unicode61 alnum, no embedded quotes),
19100/// OR-joined.
19101fn bm25f_match_expression(terms: &[String]) -> String {
19102 terms.iter().map(|t| format!("\"{t}\"")).collect::<Vec<_>>().join(" OR ")
19103}
19104
19105/// F5 (0.8.14 Slice 10) — the BM25F score for one candidate document.
19106///
19107/// Standard BM25F: per query term, accumulate a length-normalized,
19108/// field-weighted pseudo term-frequency across the fields, then apply the BM25
19109/// saturation once. `norm_f = 1 - b + b*(len_f/avglen_f)` is the per-field
19110/// length normalization (this is where tunable `b` bites); `weight_f` is the
19111/// field boost (this is where the R-F5-1 field weighting bites).
19112fn bm25f_score_doc(
19113 plan: &Bm25fQueryPlan,
19114 query_terms: &[String],
19115 // (weight, doc field length, corpus avg field length, per-term tf in field)
19116 fields: &[(f64, f64, f64, &HashMap<String, u32>)],
19117 doc_count: usize,
19118 df: &HashMap<String, usize>,
19119) -> f64 {
19120 let mut score = 0.0_f64;
19121 for term in query_terms {
19122 let mut weighted_tf = 0.0_f64;
19123 for (weight, len_f, avglen_f, tf_map) in fields {
19124 if *weight == 0.0 || *avglen_f <= 0.0 {
19125 continue;
19126 }
19127 let tf = *tf_map.get(term).unwrap_or(&0) as f64;
19128 if tf == 0.0 {
19129 continue;
19130 }
19131 let norm = 1.0 - plan.b + plan.b * (len_f / avglen_f);
19132 if norm <= 0.0 {
19133 continue;
19134 }
19135 weighted_tf += weight * tf / norm;
19136 }
19137 if weighted_tf <= 0.0 {
19138 continue;
19139 }
19140 let dfq = *df.get(term).unwrap_or(&0);
19141 if dfq == 0 {
19142 continue;
19143 }
19144 let n = doc_count as f64;
19145 let idf = ((n - dfq as f64 + 0.5) / (dfq as f64 + 0.5) + 1.0).ln();
19146 score += idf * (weighted_tf * (plan.k1 + 1.0)) / (plan.k1 + weighted_tf);
19147 }
19148 score
19149}
19150
19151/// F5 (0.8.14 Slice 10) — connection-level implementation of the BM25F lexical
19152/// arm. See [`Engine::bm25f_search`].
19153fn bm25f_search_inner(
19154 connection: &Connection,
19155 query: &str,
19156 plan: &Bm25fQueryPlan,
19157) -> rusqlite::Result<Vec<(u64, f64)>> {
19158 let query_terms: Vec<String> = {
19159 let mut seen = BTreeSet::new();
19160 fts5_tokenize(connection, query)?.into_iter().filter(|t| seen.insert(t.clone())).collect()
19161 };
19162 if query_terms.is_empty() {
19163 return Ok(Vec::new());
19164 }
19165
19166 // Corpus pass over ACTIVE rows (superseded versions excluded): accumulate
19167 // N, total field length per field (for avg field length), and per-term
19168 // document frequency — all under the SAME FTS5 tokenization the index uses.
19169 let mut doc_count: usize = 0;
19170 let mut total_len = [0.0_f64; 3]; // kind, body, status
19171 let mut df: HashMap<String, usize> = HashMap::new();
19172 {
19173 let mut stmt = connection.prepare(
19174 "SELECT v.kind, v.body, v.status
19175 FROM search_index_v2 v
19176 JOIN canonical_nodes cn ON cn.write_cursor = v.write_cursor
19177 WHERE cn.superseded_at IS NULL AND cn.state = 'active'",
19178 )?;
19179 let mut rows = stmt.query([])?;
19180 while let Some(row) = rows.next()? {
19181 let fields =
19182 [row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?];
19183 doc_count += 1;
19184 let mut present: BTreeSet<String> = BTreeSet::new();
19185 for (i, field) in fields.iter().enumerate() {
19186 let toks = fts5_tokenize(connection, field)?;
19187 total_len[i] += toks.len() as f64;
19188 for tok in toks {
19189 if query_terms.contains(&tok) {
19190 present.insert(tok);
19191 }
19192 }
19193 }
19194 for term in present {
19195 *df.entry(term).or_insert(0) += 1;
19196 }
19197 }
19198 }
19199 if doc_count == 0 {
19200 return Ok(Vec::new());
19201 }
19202 let avglen = [
19203 total_len[0] / doc_count as f64,
19204 total_len[1] / doc_count as f64,
19205 total_len[2] / doc_count as f64,
19206 ];
19207
19208 // Active write_cursor set, to filter FTS5 MATCH candidates (search_index_v2
19209 // retains superseded versions, exactly like search_index).
19210 let active: BTreeSet<i64> = {
19211 let mut stmt = connection
19212 .prepare("SELECT write_cursor FROM canonical_nodes WHERE superseded_at IS NULL AND state = 'active'")?;
19213 let rows = stmt.query_map([], |r| r.get::<_, i64>(0))?;
19214 rows.collect::<rusqlite::Result<BTreeSet<i64>>>()?
19215 };
19216
19217 // Candidate recall through the FTS5 index (this is what makes the v2 index
19218 // load-bearing), then score each candidate with the in-engine BM25F.
19219 let match_expr = bm25f_match_expression(&query_terms);
19220 let mut scored: Vec<(u64, f64)> = Vec::new();
19221 {
19222 let mut stmt = connection.prepare(
19223 "SELECT write_cursor, kind, body, status
19224 FROM search_index_v2
19225 WHERE search_index_v2 MATCH ?1",
19226 )?;
19227 let mut rows = stmt.query([match_expr.as_str()])?;
19228 while let Some(row) = rows.next()? {
19229 let wc = row.get::<_, i64>(0)?;
19230 if !active.contains(&wc) {
19231 continue;
19232 }
19233 let kind = row.get::<_, String>(1)?;
19234 let body = row.get::<_, String>(2)?;
19235 let status = row.get::<_, String>(3)?;
19236
19237 let mut tf_kind: HashMap<String, u32> = HashMap::new();
19238 let mut len_kind = 0.0_f64;
19239 for tok in fts5_tokenize(connection, &kind)? {
19240 len_kind += 1.0;
19241 *tf_kind.entry(tok).or_insert(0) += 1;
19242 }
19243 let mut tf_body: HashMap<String, u32> = HashMap::new();
19244 let mut len_body = 0.0_f64;
19245 for tok in fts5_tokenize(connection, &body)? {
19246 len_body += 1.0;
19247 *tf_body.entry(tok).or_insert(0) += 1;
19248 }
19249 let mut tf_status: HashMap<String, u32> = HashMap::new();
19250 let mut len_status = 0.0_f64;
19251 for tok in fts5_tokenize(connection, &status)? {
19252 len_status += 1.0;
19253 *tf_status.entry(tok).or_insert(0) += 1;
19254 }
19255
19256 let fields = [
19257 (plan.weights.kind, len_kind, avglen[0], &tf_kind),
19258 (plan.weights.body, len_body, avglen[1], &tf_body),
19259 (plan.weights.status, len_status, avglen[2], &tf_status),
19260 ];
19261 let score = bm25f_score_doc(plan, &query_terms, &fields, doc_count, &df);
19262 scored.push((wc as u64, score));
19263 }
19264 }
19265
19266 // Descending score; write_cursor ascending as the deterministic tiebreak.
19267 scored.sort_by(|a, b| {
19268 b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal).then(a.0.cmp(&b.0))
19269 });
19270 Ok(scored)
19271}
19272
19273fn commit_batch(
19274 connection: &mut Connection,
19275 batch: &[PreparedWrite],
19276 plans: &[WritePlan],
19277 base_cursor: u64,
19278 provenance_row_cap: u64,
19279) -> rusqlite::Result<u64> {
19280 // 0.8.20 Slice 21a-2 (TC-57) — `BEGIN IMMEDIATE`, not rusqlite's `BEGIN
19281 // DEFERRED` default. Take the WAL write lock AT `BEGIN`, before the
19282 // supersession SELECT below, so this transaction never has to PROMOTE a read
19283 // lock to a write lock.
19284 //
19285 // The defect this closes (characterized in
19286 // `dev/design/0.8.20-tc57-write-race-characterization.md`, repro 10/10 at
19287 // baseline `41a81c17`): for a GOVERNED write (`logical_id: Some`) the first
19288 // statement in this transaction is a read —
19289 // `prior_node_cursors_by_logical_id` — and the second is the supersession
19290 // UPDATE. When the async projection worker holds the write lock on its own
19291 // connection at that instant, SQLite refuses the promotion with plain
19292 // `SQLITE_BUSY` (5) and SKIPS the busy handler entirely, for deadlock
19293 // avoidance (`sqlite3_busy_handler`: "if SQLite determines that invoking the
19294 // busy handler could result in a deadlock, it will go ahead and return
19295 // SQLITE_BUSY"). MEASURED: handler invoked ZERO times, error returned in 0 ms
19296 // against rusqlite's 5 000 ms default timeout. So NO `busy_timeout` value
19297 // could ever have absorbed it, and the caller saw an opaque, un-retryable
19298 // `EngineError::Storage` mid-ingest. The same shape also has a second,
19299 // narrower exit — `SQLITE_BUSY_SNAPSHOT` (517) when the WAL advances past the
19300 // read snapshot — which this closes too, by construction.
19301 //
19302 // UNCONDITIONAL rather than gated on `logical_id`, deliberately: an anonymous
19303 // batch's first statement is already the INSERT below, so it takes the write
19304 // lock essentially immediately anyway and the delta is microseconds, whereas a
19305 // content-dependent transaction behaviour would be a NEW correctness surface
19306 // (mixed batches, edge arms, future write kinds) with a place to be wrong in
19307 // each. MEASURED cost on the anonymous arm: none detectable
19308 // (`tc57_worker_commit_pressure.rs`).
19309 //
19310 // `BEGIN IMMEDIATE` can itself return `SQLITE_BUSY` — but WITH the busy
19311 // handler consulted, i.e. absorbed by the existing 5 s default instead of
19312 // surfaced (pinned by `tc57_mechanism_control_write_first_is_retryable`).
19313 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
19314
19315 for (i, (write, plan)) in batch.iter().zip(plans).enumerate() {
19316 // Per-row cursor: row i gets `base_cursor + i + 1`. See the
19317 // comment in `Engine::write_inner`.
19318 let cursor = base_cursor.saturating_add((i as u64).saturating_add(1));
19319 match (write, plan) {
19320 (
19321 PreparedWrite::Node {
19322 kind,
19323 body,
19324 source_id,
19325 logical_id,
19326 state,
19327 reason,
19328 valid_from,
19329 valid_until,
19330 },
19331 WritePlan::Node,
19332 ) => {
19333 // G0 — supersession is tombstone-then-insert in this same txn:
19334 // mark the prior active version superseded BEFORE inserting the
19335 // new active row, so the partial-unique-active index never sees
19336 // two active rows for one logical_id. Scoped to logical_id ALONE
19337 // (Decision 5, HITL-SIGNED 2026-06-05): a kind-change re-ingest of
19338 // the same logical_id SUPERSEDES, never forks. No-op when logical_id
19339 // is None (legacy/own-identity insert, behavior-identical to 0.7.x).
19340 if let Some(logical_id) = logical_id {
19341 // fix-1 finding 2 [P2]: collect the prior active cursor(s)
19342 // BEFORE tombstoning so we can purge the superseded row's
19343 // row-owned attribute projections and keep the at-rest EAV /
19344 // property-FTS store ACTIVE-ONLY. Without this, a same-session
19345 // property filter / property-FTS saw BOTH the stale and the
19346 // current value until a boot re-derive/reconfigure cleared the
19347 // table — a stale read that violates the active-only invariant.
19348 let prior_g0 = prior_node_cursors_by_logical_id(&tx, logical_id)?;
19349 tx.execute(
19350 "UPDATE canonical_nodes SET superseded_at = ?1
19351 WHERE logical_id = ?2 AND superseded_at IS NULL",
19352 params![cursor, logical_id],
19353 )?;
19354 // Purge only the Attribute + PropertyFts classes: those tables
19355 // have NO `superseded_at IS NULL` read-side filter (the FTS5
19356 // `property_search_index` cannot carry one), so their stale rows
19357 // MUST be deleted at rest. The NodeFts (`search_index` /
19358 // `search_index_v2`) + Vector shadows are left intact — the node
19359 // read path already excludes their superseded rows via the
19360 // `canonical_nodes WHERE superseded_at IS NULL` join, so purging
19361 // them here would be a behaviour change outside this fix's scope.
19362 for sc in &prior_g0 {
19363 purge_row_projections_for_cursor_in(
19364 &tx,
19365 *sc,
19366 &[ProjectionClass::Attribute, ProjectionClass::PropertyFts],
19367 )?;
19368 }
19369 }
19370 // EXP-S (0.8.14 Slice 5, D1) — a `PreparedWrite::Node` is the
19371 // `leaf` structural row_kind (a normal record). coverage/graph
19372 // rows are written via internal paths (row_kind is a SEPARATE
19373 // axis from the doc-type `kind`, and there is no public SDK
19374 // surface for it this release). Writing `leaf` explicitly is
19375 // value-identical to the column DEFAULT.
19376 // OPP-12 Phase-1 (0.8.19 Slice 5) — persist the create-time
19377 // existence state + advisory reason. `InitialState::Active`
19378 // (the default) writes `state = 'active'`, value-identical to the
19379 // migration step-20 column DEFAULT; `Pending` quarantines the node
19380 // out of default retrieval (the `state = 'active'` read exclusion).
19381 // 0.8.20 Slice 15b (TC-34) — persist the world-time validity
19382 // window. A `None` binds SQL NULL, which is what the migration
19383 // step-22 columns already hold for every pre-existing row and what
19384 // `ReadView::validity_sql` reads as UNBOUNDED on that side. So a
19385 // write that omits the window is byte-identical on disk to a
19386 // pre-slice write, and default-view visibility cannot drift.
19387 tx.execute(
19388 "INSERT INTO canonical_nodes(write_cursor, kind, body, source_id, logical_id, row_kind, state, reason, valid_from, valid_until)
19389 VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
19390 params![cursor, kind, body, source_id.as_str(), logical_id, RowKind::Leaf.as_str(), state.as_str(), reason, valid_from, valid_until],
19391 )?;
19392 // EXP-S (D2/D5) — per-row_kind index-target dispatch. For `leaf`
19393 // this is behavior-identical to the pre-EXP-S inline path: FTS
19394 // (sync, in-tx) + vector (async, gated by kind_is_vector_indexed);
19395 // else the cursor is terminated up-front.
19396 // fix-2 [P2]: gate the attribute projection on the create-time
19397 // state. A fresh insert is always non-superseded, so the backfill
19398 // predicate (`state = 'active' AND superseded_at IS NULL`) reduces
19399 // to `state == Active` here. A `Pending` node is quarantined out of
19400 // the canonical read model — its declared attributes must NOT reach
19401 // the property store until a `transition(pending → active)` promotes
19402 // it (which projects them then). Node-FTS / vector shadows are left
19403 // to their read-side lifecycle filter, exactly as for supersession.
19404 project_canonical_node_row(
19405 &tx,
19406 cursor,
19407 kind,
19408 body,
19409 RowKind::Leaf,
19410 ProjectionPass::Write,
19411 matches!(state, InitialState::Active),
19412 )?;
19413 }
19414 (
19415 PreparedWrite::Edge {
19416 kind,
19417 from,
19418 to,
19419 source_id,
19420 logical_id,
19421 body,
19422 t_valid,
19423 t_invalid,
19424 confidence,
19425 extractor_model_id,
19426 temporal_fallback,
19427 },
19428 WritePlan::Edge,
19429 ) => {
19430 // G0 — identical tombstone-then-insert supersession on edges,
19431 // keyed by logical_id ALONE (Decision 5, HITL-SIGNED 2026-06-05;
19432 // edge `kind` is relationship-type, not identity — a kind-change
19433 // re-ingest of the same edge logical_id SUPERSEDES, never forks).
19434 // No-op when logical_id is None.
19435 if let Some(logical_id) = logical_id {
19436 // fix-30 [P2]: collect prior active cursors BEFORE tombstoning
19437 // so stale vector_default rows can be pruned.
19438 let prior_g0 = prior_edge_cursors_by_logical_id(&tx, logical_id)?;
19439 tx.execute(
19440 "UPDATE canonical_edges SET superseded_at = ?1
19441 WHERE logical_id = ?2 AND superseded_at IS NULL",
19442 params![cursor, logical_id],
19443 )?;
19444 for sc in &prior_g0 {
19445 delete_vector_partition_row(&tx, *sc)?;
19446 tx.execute(
19447 "DELETE FROM _fathomdb_vector_rows WHERE write_cursor = ?1",
19448 [sc],
19449 )?;
19450 // fix-32 [P2]: record terminal so advance_projection_cursor
19451 // can walk past this now-superseded cursor.
19452 // TC-45: the token MUST be 'up_to_date', NOT 'superseded'.
19453 // The terminal table (schema step 7) carries
19454 // CHECK(state IN ('failed','up_to_date')) and the writer is
19455 // INSERT OR IGNORE, which SILENTLY SKIPS a CHECK-violating
19456 // row — so 'superseded' was dropped without error and this
19457 // cursor stalled forever (nothing backfills it: the job
19458 // query and the pending-work probe both exclude superseded
19459 // edges). 'up_to_date' is the CHECK-valid, non-'failed'
19460 // terminal and is semantically exact here: the row is
19461 // tombstoned and its vector shadow just deleted, so there is
19462 // no further projection work for this cursor. Same reasoning
19463 // and same token as the step-23 backfill (fix-4, TC-33).
19464 record_projection_terminal(&tx, *sc as u64, "up_to_date")?;
19465 }
19466 }
19467 // G11 — invalidate-not-accumulate: for fact-edges (body IS NOT NULL),
19468 // tombstone any prior active edge on the same (from_id, to_id, kind)
19469 // BEFORE inserting the new row. This is DIFFERENT from the G0
19470 // logical_id tombstone: it is keyed on the triple, not the identity.
19471 // Regular edges (body=None) skip this path — they retain G0 semantics.
19472 if body.is_some() {
19473 // fix-30 [P2]: collect and prune vector shadow for the superseded edge.
19474 let prior_g11 = prior_edge_cursors_by_triple(&tx, from, to, kind)?;
19475 tx.execute(
19476 "UPDATE canonical_edges SET superseded_at = ?1
19477 WHERE from_id = ?2 AND to_id = ?3 AND kind = ?4 AND superseded_at IS NULL",
19478 params![cursor, from, to, kind],
19479 )?;
19480 for sc in &prior_g11 {
19481 delete_vector_partition_row(&tx, *sc)?;
19482 tx.execute(
19483 "DELETE FROM _fathomdb_vector_rows WHERE write_cursor = ?1",
19484 [sc],
19485 )?;
19486 // fix-32 [P2]: mark terminal so projection cursor can advance.
19487 // TC-45: 'up_to_date', NOT 'superseded' — see the identical
19488 // note on the G0 prune loop above. The step-7 CHECK admits
19489 // only ('failed','up_to_date') and INSERT OR IGNORE swallows
19490 // a violating row, so 'superseded' never landed and wedged
19491 // the shared readiness watermark.
19492 record_projection_terminal(&tx, *sc as u64, "up_to_date")?;
19493 }
19494 }
19495 let temporal_fallback_i: Option<i64> =
19496 temporal_fallback.and_then(|f| if f { Some(1) } else { None });
19497 tx.execute(
19498 "INSERT INTO canonical_edges(
19499 write_cursor, kind, from_id, to_id, source_id, logical_id,
19500 body, t_valid, t_invalid, confidence, extractor_model_id,
19501 temporal_fallback
19502 ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
19503 params![
19504 cursor,
19505 kind,
19506 from,
19507 to,
19508 source_id.as_str(),
19509 logical_id,
19510 body,
19511 t_valid,
19512 t_invalid,
19513 confidence,
19514 extractor_model_id,
19515 temporal_fallback_i
19516 ],
19517 )?;
19518 // 0.8.20 Slice 5a (R-20-E1, work item 1) — edge projection is no
19519 // longer inlined here: the write path and the rebuild replay
19520 // share ONE projector, so they cannot drift.
19521 project_canonical_edge_row(
19522 &tx,
19523 cursor,
19524 kind,
19525 body.as_deref(),
19526 ProjectionPass::Write,
19527 )?;
19528 }
19529 (
19530 PreparedWrite::AdminSchema { name, kind, schema_json, retention_json },
19531 WritePlan::AdminSchema,
19532 ) => {
19533 tx.execute(
19534 "INSERT INTO operational_collections(
19535 name, kind, schema_json, retention_json, format_version, created_at
19536 ) VALUES(?1, ?2, ?3, ?4, 1, 0)
19537 ON CONFLICT(name) DO UPDATE SET
19538 schema_json = excluded.schema_json,
19539 retention_json = excluded.retention_json",
19540 params![name, kind, schema_json, retention_json],
19541 )?;
19542 record_projection_terminal(&tx, cursor, "up_to_date")?;
19543 }
19544 (
19545 PreparedWrite::OpStore { collection, record_key, schema_id, body },
19546 WritePlan::AppendOnlyLog,
19547 ) => {
19548 tx.execute(
19549 "INSERT INTO operational_mutations(
19550 collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
19551 ) VALUES(?1, ?2, 'append', ?3, ?4, ?5)",
19552 params![collection, record_key, body, schema_id, cursor],
19553 )?;
19554 record_projection_terminal(&tx, cursor, "up_to_date")?;
19555 }
19556 (
19557 PreparedWrite::OpStore { collection, record_key, schema_id, body },
19558 WritePlan::LatestState,
19559 ) => {
19560 tx.execute(
19561 "INSERT INTO operational_state(
19562 collection_name, record_key, payload_json, schema_id, write_cursor
19563 ) VALUES(?1, ?2, ?3, ?4, ?5)
19564 ON CONFLICT(collection_name, record_key) DO UPDATE SET
19565 payload_json = excluded.payload_json,
19566 schema_id = excluded.schema_id,
19567 write_cursor = excluded.write_cursor",
19568 params![collection, record_key, body, schema_id, cursor],
19569 )?;
19570 record_projection_terminal(&tx, cursor, "up_to_date")?;
19571 }
19572 _ => return Err(rusqlite::Error::InvalidQuery),
19573 }
19574 }
19575
19576 // G8 (Slice 20 / F10) — cross-row dangling-edge flag-and-count. This runs
19577 // AFTER the batch loop (so every same-batch node is already on disk in `tx`
19578 // and a same-batch later-inserted endpoint is visible) and BEFORE retention /
19579 // projection-cursor / commit. It is the cross-row reason this lives here and
19580 // not in single-row pre-insert `validate_write`. Default is FLAG-AND-COUNT:
19581 // we only COUNT, never roll back (strict-mode rollback is deferred to
19582 // reserved-gap band 22 — adding a write-options surface is out of scope).
19583 //
19584 // Probe is `logical_id`-alone against the step-12 partial index
19585 // `canonical_nodes_logical_active_idx ON canonical_nodes(logical_id)
19586 // WHERE superseded_at IS NULL` (its leading column + partial predicate), so
19587 // it SEARCHes the index with no SCAN (see `tests/pr_g8_dangling_edges.rs`
19588 // case (f)). There is no node-kind to match: `canonical_edges` stores only
19589 // the edge's own kind, not the endpoint node's kind.
19590 let dangling_edge_endpoints = {
19591 // O(N) pre-pass: record, per `logical_id`, the LAST (highest) index at
19592 // which an `Edge { logical_id: Some(_), .. }` with that id appears. Keyed
19593 // by `logical_id` ALONE (Decision 5, HITL-SIGNED 2026-06-05) to match the
19594 // supersession UPDATE, which keys by logical_id alone: a kind-change
19595 // re-ingest of the same edge logical_id SUPERSEDES the earlier one.
19596 // Iterating front-to-back and overwriting means the stored value ends up
19597 // as the final index for each id. An edge at index `i` with that id is
19598 // then in-batch-superseded iff `last_index[lid] > i`. This is
19599 // behavior-identical to the prior per-edge `batch[i+1..]` `.any(..)` scan
19600 // (which was O(N²) under the single-writer txn) — same skip-set, same count.
19601 let mut last_index: HashMap<&str, usize> = HashMap::new();
19602 for (i, write) in batch.iter().enumerate() {
19603 if let PreparedWrite::Edge { logical_id: Some(lid), .. } = write {
19604 last_index.insert(lid.as_str(), i);
19605 }
19606 }
19607
19608 let mut probe = tx.prepare(
19609 "SELECT 1 FROM canonical_nodes WHERE logical_id = ?1 AND superseded_at IS NULL LIMIT 1",
19610 )?;
19611 let mut count: u64 = 0;
19612 for (i, write) in batch.iter().enumerate() {
19613 if let PreparedWrite::Edge { from, to, logical_id, .. } = write {
19614 // Honor `edge.superseded_at IS NULL`: an edge inserted in this
19615 // batch is active unless a LATER same-batch edge with the same
19616 // `Some(logical_id)` tombstoned it (the loop's supersession
19617 // UPDATE). Skip such an in-batch-superseded edge. Edges with
19618 // `logical_id: None` are never superseded-in-batch.
19619 if let Some(lid) = logical_id {
19620 let superseded_in_batch =
19621 last_index.get(lid.as_str()).is_some_and(|&last| last > i);
19622 if superseded_in_batch {
19623 continue;
19624 }
19625 }
19626 // Probe `from_id` and `to_id` independently (0, 1, or 2 per edge).
19627 for endpoint in [from, to] {
19628 if !probe.exists(params![endpoint])? {
19629 count = count.saturating_add(1);
19630 }
19631 }
19632 }
19633 }
19634 count
19635 };
19636
19637 enforce_provenance_retention(&tx, provenance_row_cap)?;
19638 advance_projection_cursor(&tx)?;
19639
19640 tx.commit()?;
19641 Ok(dangling_edge_endpoints)
19642}
19643
19644fn load_next_cursor(connection: &Connection) -> u64 {
19645 let nodes = max_cursor(connection, "canonical_nodes").unwrap_or(0);
19646 let edges = max_cursor(connection, "canonical_edges").unwrap_or(0);
19647 let mutations = max_cursor(connection, "operational_mutations").unwrap_or(0);
19648 let state = max_cursor(connection, "operational_state").unwrap_or(0);
19649 // TC-33: schema step 23 RECREATES `canonical_edges` (no data migration), so
19650 // the edge rows that used to hold the high-water mark are gone. Without this
19651 // term the allocator can hand out a cursor a PREVIOUS edge already used —
19652 // and stale `_fathomdb_projection_terminal` / `_fathomdb_vector_rows` / vec0
19653 // rows still key on it, so a brand-new row would be treated as
19654 // already-projected and never get indexed. Step 23 stashes the pre-drop
19655 // maximum here; folding it in keeps cursors monotonic across the migration.
19656 let reserved = reserved_write_cursor(connection);
19657 nodes.max(edges).max(mutations).max(state).max(reserved)
19658}
19659
19660/// The write-cursor high-water mark reserved by schema step 23, or 0 when the
19661/// key is absent (fresh DB, or a DB that never had edges). Never fails the
19662/// caller: a missing/unparseable value degrades to 0, which is the pre-TC-33
19663/// behaviour.
19664fn reserved_write_cursor(connection: &Connection) -> u64 {
19665 connection
19666 .query_row(
19667 "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
19668 params![fathomdb_schema::RESERVED_WRITE_CURSOR_KEY],
19669 |row| row.get::<_, String>(0),
19670 )
19671 .ok()
19672 .and_then(|raw| raw.parse::<u64>().ok())
19673 .unwrap_or(0)
19674}
19675
19676fn max_cursor(connection: &Connection, table: &str) -> rusqlite::Result<u64> {
19677 let sql = format!("SELECT COALESCE(MAX(write_cursor), 0) FROM {table}");
19678 connection.query_row(&sql, [], |row| row.get::<_, u64>(0))
19679}
19680
19681/// Map a rusqlite error to its stable SQLite extended-code name.
19682///
19683/// Returns `None` for non-`SqliteFailure` variants (e.g. JSON conversion
19684/// failures, type mismatches at the rusqlite layer) — those are not
19685/// SQLite-internal events and should not be surfaced under
19686/// `EventSource::SqliteInternal`. The names returned here are the
19687/// canonical `SQLITE_*` symbol names from `sqlite3.h` and are stable
19688/// dispatch keys for AC-021 / AC-006 binding adapters.
19689///
19690/// Only the subset of codes the engine can reach in 0.6.0 is enumerated
19691/// — bare-extended-code matching covers the rest with a stable
19692/// `"SQLITE_UNKNOWN"` fallback so subscribers always see a typed code.
19693///
19694/// Diagnostic completeness for unmapped codes — **corrected 0.8.20 Slice 21a-2
19695/// (TC-57)**. This comment used to claim that when the helper returns
19696/// `"SQLITE_UNKNOWN"` the numeric extended code "is not lost — it remains on the
19697/// underlying `rusqlite::Error::SqliteFailure` carried in the engine error chain
19698/// that subscribers can inspect via `EngineError`'s `source()`". **That is
19699/// false.** There is no such chain: `EngineError::Storage` is a UNIT variant with
19700/// no payload and no `source()`, and `write_inner` drops the `rusqlite::Error`
19701/// immediately after emitting the lifecycle event. So for an unmapped code the
19702/// numeric value IS lost, and the only signal a host receives is the string
19703/// `"SQLITE_UNKNOWN"`.
19704///
19705/// Concretely: `SQLITE_BUSY_SNAPSHOT` (517) matches none of the PRIMARY constants
19706/// below — the match is on the EXTENDED value — so it reaches subscribers as
19707/// `"SQLITE_UNKNOWN"` and is unrecoverable from the public API. Restructuring the
19708/// error path so busy codes are distinguishable (and surfacing the numeric code as
19709/// a typed payload field) is candidate R2 of
19710/// `dev/design/0.8.20-tc57-write-race-characterization.md` §7, explicitly OUT of
19711/// scope for the 21a-2 fix and recorded here rather than silently carried.
19712fn sqlite_extended_code_name(err: &rusqlite::Error) -> Option<&'static str> {
19713 let sqlite_error = err.sqlite_error()?;
19714 let extended = sqlite_error.extended_code;
19715 Some(match extended {
19716 rusqlite::ffi::SQLITE_SCHEMA => "SQLITE_SCHEMA",
19717 rusqlite::ffi::SQLITE_BUSY => "SQLITE_BUSY",
19718 rusqlite::ffi::SQLITE_LOCKED => "SQLITE_LOCKED",
19719 rusqlite::ffi::SQLITE_CORRUPT => "SQLITE_CORRUPT",
19720 rusqlite::ffi::SQLITE_NOTADB => "SQLITE_NOTADB",
19721 rusqlite::ffi::SQLITE_IOERR => "SQLITE_IOERR",
19722 rusqlite::ffi::SQLITE_FULL => "SQLITE_FULL",
19723 rusqlite::ffi::SQLITE_READONLY => "SQLITE_READONLY",
19724 rusqlite::ffi::SQLITE_CONSTRAINT => "SQLITE_CONSTRAINT",
19725 rusqlite::ffi::SQLITE_MISUSE => "SQLITE_MISUSE",
19726 rusqlite::ffi::SQLITE_INTERRUPT => "SQLITE_INTERRUPT",
19727 rusqlite::ffi::SQLITE_NOMEM => "SQLITE_NOMEM",
19728 rusqlite::ffi::SQLITE_PERM => "SQLITE_PERM",
19729 rusqlite::ffi::SQLITE_ABORT => "SQLITE_ABORT",
19730 rusqlite::ffi::SQLITE_PROTOCOL => "SQLITE_PROTOCOL",
19731 rusqlite::ffi::SQLITE_RANGE => "SQLITE_RANGE",
19732 rusqlite::ffi::SQLITE_TOOBIG => "SQLITE_TOOBIG",
19733 rusqlite::ffi::SQLITE_MISMATCH => "SQLITE_MISMATCH",
19734 rusqlite::ffi::SQLITE_AUTH => "SQLITE_AUTH",
19735 rusqlite::ffi::SQLITE_NOTFOUND => "SQLITE_NOTFOUND",
19736 rusqlite::ffi::SQLITE_CANTOPEN => "SQLITE_CANTOPEN",
19737 _ => "SQLITE_UNKNOWN",
19738 })
19739}
19740
19741fn sqlite_extended_code_name_from_int(extended: i32) -> &'static str {
19742 match extended {
19743 rusqlite::ffi::SQLITE_SCHEMA => "SQLITE_SCHEMA",
19744 rusqlite::ffi::SQLITE_BUSY => "SQLITE_BUSY",
19745 rusqlite::ffi::SQLITE_LOCKED => "SQLITE_LOCKED",
19746 rusqlite::ffi::SQLITE_CORRUPT => "SQLITE_CORRUPT",
19747 rusqlite::ffi::SQLITE_NOTADB => "SQLITE_NOTADB",
19748 rusqlite::ffi::SQLITE_IOERR => "SQLITE_IOERR",
19749 rusqlite::ffi::SQLITE_FULL => "SQLITE_FULL",
19750 rusqlite::ffi::SQLITE_READONLY => "SQLITE_READONLY",
19751 rusqlite::ffi::SQLITE_CONSTRAINT => "SQLITE_CONSTRAINT",
19752 rusqlite::ffi::SQLITE_MISUSE => "SQLITE_MISUSE",
19753 rusqlite::ffi::SQLITE_INTERRUPT => "SQLITE_INTERRUPT",
19754 rusqlite::ffi::SQLITE_NOMEM => "SQLITE_NOMEM",
19755 rusqlite::ffi::SQLITE_PERM => "SQLITE_PERM",
19756 rusqlite::ffi::SQLITE_ABORT => "SQLITE_ABORT",
19757 rusqlite::ffi::SQLITE_PROTOCOL => "SQLITE_PROTOCOL",
19758 rusqlite::ffi::SQLITE_RANGE => "SQLITE_RANGE",
19759 rusqlite::ffi::SQLITE_TOOBIG => "SQLITE_TOOBIG",
19760 rusqlite::ffi::SQLITE_MISMATCH => "SQLITE_MISMATCH",
19761 rusqlite::ffi::SQLITE_AUTH => "SQLITE_AUTH",
19762 rusqlite::ffi::SQLITE_NOTFOUND => "SQLITE_NOTFOUND",
19763 rusqlite::ffi::SQLITE_CANTOPEN => "SQLITE_CANTOPEN",
19764 _ => "SQLITE_UNKNOWN",
19765 }
19766}
19767
19768fn map_open_sqlite_error(err: rusqlite::Error, stage: OpenStage) -> EngineOpenError {
19769 let Some(sqlite_error) = err.sqlite_error() else {
19770 return EngineOpenError::Io { message: "could not open database".to_string() };
19771 };
19772 match sqlite_error.extended_code {
19773 rusqlite::ffi::SQLITE_CORRUPT | rusqlite::ffi::SQLITE_NOTADB => {
19774 EngineOpenError::Corruption(CorruptionDetail {
19775 kind: match stage {
19776 OpenStage::WalReplay => CorruptionKind::WalReplayFailure,
19777 OpenStage::HeaderProbe => CorruptionKind::HeaderMalformed,
19778 OpenStage::SchemaProbe => CorruptionKind::SchemaInconsistent,
19779 OpenStage::EmbedderIdentity => CorruptionKind::EmbedderIdentityDrift,
19780 },
19781 stage,
19782 locator: CorruptionLocator::OpaqueSqliteError {
19783 sqlite_extended_code: sqlite_error.extended_code,
19784 },
19785 recovery_hint: RecoveryHint {
19786 code: match stage {
19787 OpenStage::WalReplay => "E_CORRUPT_WAL_REPLAY",
19788 OpenStage::HeaderProbe => "E_CORRUPT_HEADER",
19789 OpenStage::SchemaProbe => "E_CORRUPT_SCHEMA",
19790 OpenStage::EmbedderIdentity => "E_CORRUPT_EMBEDDER_IDENTITY",
19791 },
19792 doc_anchor: match stage {
19793 OpenStage::WalReplay => "design/recovery.md#wal-replay-failures",
19794 OpenStage::HeaderProbe => "design/recovery.md#header-malformed",
19795 OpenStage::SchemaProbe => "design/recovery.md#schema-inconsistent",
19796 OpenStage::EmbedderIdentity => "design/recovery.md#embedder-identity-drift",
19797 },
19798 },
19799 })
19800 }
19801 _ => EngineOpenError::Io { message: "could not open database".to_string() },
19802 }
19803}
19804
19805fn emit_open_error_event(subscriber: &Arc<dyn lifecycle::Subscriber>, err: &EngineOpenError) {
19806 if let EngineOpenError::Corruption(detail) = err {
19807 let code = match detail.locator {
19808 CorruptionLocator::OpaqueSqliteError { sqlite_extended_code } => {
19809 Some(sqlite_extended_code_name_from_int(sqlite_extended_code))
19810 }
19811 _ => None,
19812 };
19813 let event = lifecycle::Event {
19814 phase: lifecycle::Phase::Failed,
19815 source: lifecycle::EventSource::SqliteInternal,
19816 category: lifecycle::EventCategory::Corruption,
19817 code,
19818 };
19819 subscriber.on_event(&event);
19820 }
19821}
19822
19823/// Install a `sqlite3_profile` callback on `connection` that dispatches
19824/// per-statement profile records and slow-statement signals to the
19825/// engine's subscriber registry.
19826///
19827/// Why FFI rather than `rusqlite::Connection::profile`: the safe API
19828/// (rusqlite 0.31) accepts only a `fn(&str, Duration)` with no
19829/// environment, so it cannot carry a per-engine subscriber-registry
19830/// pointer. We use `sqlite3_profile` directly with a leaked-into-`Box`
19831/// context whose pointer is tied to the engine's lifetime via
19832/// `Engine::profile_contexts`.
19833///
19834/// `sqlite3_profile` is documented as deprecated in favor of
19835/// `sqlite3_trace_v2`, but it remains supported and is sufficient for
19836/// the wall-clock + SQL-text payload required by AC-005a/b.
19837#[allow(clippy::vec_box)]
19838fn install_profile_callback(
19839 connection: &Connection,
19840 subscribers: &Arc<lifecycle::SubscriberRegistry>,
19841 profiling_enabled: &Arc<AtomicBool>,
19842 slow_threshold_ms: &Arc<AtomicU64>,
19843 contexts: &mut Vec<Box<ProfileContext>>,
19844) {
19845 let mut ctx = Box::new(ProfileContext {
19846 subscribers: Arc::clone(subscribers),
19847 profiling_enabled: Arc::clone(profiling_enabled),
19848 slow_threshold_ms: Arc::clone(slow_threshold_ms),
19849 });
19850 let ctx_ptr: *mut ProfileContext = &mut *ctx;
19851
19852 // SAFETY: the Box outlives the connection. Rust drops struct fields
19853 // in declaration order. `connection` and `reader_pool` are declared
19854 // before `profile_contexts`. `ReaderWorkerPool::Drop` joins every
19855 // reader worker, and each worker uninstalls and drops its owned
19856 // connection inside `reader_worker_loop` before the worker thread
19857 // returns. Therefore all connections — and SQLite's internal
19858 // profile-callback state with them — are torn down before the
19859 // `Box<ProfileContext>` allocations are freed. `Engine::close`
19860 // additionally clears the callback via
19861 // `sqlite3_profile(handle, None, NULL)` before connection close to
19862 // drain any in-flight callback dispatch.
19863 unsafe {
19864 rusqlite::ffi::sqlite3_profile(
19865 connection.handle(),
19866 Some(profile_callback_trampoline),
19867 ctx_ptr.cast::<std::ffi::c_void>(),
19868 );
19869 }
19870 contexts.push(ctx);
19871}
19872
19873/// Uninstall the profile callback so SQLite stops calling into our
19874/// freed `Box<ProfileContext>` pointer once a connection is being torn
19875/// down. Call before dropping `profile_contexts`.
19876fn uninstall_profile_callback(connection: &Connection) {
19877 // SAFETY: passing `None` as the callback unregisters the previous
19878 // callback; SQLite documents this as legal and idempotent.
19879 unsafe {
19880 rusqlite::ffi::sqlite3_profile(connection.handle(), None, std::ptr::null_mut());
19881 }
19882}
19883
19884/// Pack 6.G G.1 — configure SQLite per-connection lookaside on a reader
19885/// worker connection. Must be called BEFORE any statement is prepared
19886/// or any PRAGMA is run on `connection`; per the SQLite docs
19887/// (https://www.sqlite.org/malloc.html §3) lookaside is silently
19888/// ignored if reconfigured after the first allocation on the
19889/// connection. Passing `NULL` for the buffer pointer lets SQLite
19890/// allocate the lookaside backing memory itself.
19891///
19892/// rusqlite 0.31's `set_db_config` only handles the boolean
19893/// `DbConfig::*` variants; `SQLITE_DBCONFIG_LOOKASIDE` is not surfaced
19894/// (it is commented out in `rusqlite/src/config.rs`), so we call the
19895/// raw FFI directly.
19896///
19897/// Returns the rc of `sqlite3_db_config` so callers can debug-assert
19898/// `SQLITE_OK` and surface configuration failure under
19899/// `debug_assertions` test builds without expanding the public surface.
19900/// 0.7.0 perf-experiments hook: apply caller-supplied reader PRAGMAs
19901/// from the `FATHOMDB_PERF_READER_PRAGMAS` env var. Format:
19902/// comma-separated `name=value` pairs (e.g.
19903/// `cache_size=-262144,mmap_size=268435456,temp_store=MEMORY`).
19904///
19905/// **Gated on `FATHOMDB_PERF_EXPERIMENTS=1`.** No-op if the gate env
19906/// var is unset, so production paths are never affected. Failures to
19907/// apply individual PRAGMAs are logged to stderr (via `eprintln!`) but
19908/// do not error the connection open — experiments are best-effort,
19909/// not contract.
19910///
19911/// Scope: 0.7.0 perf-experiment campaign per
19912/// `dev/plans/0.7.0-perf-experiments.md`. Once Wave 5 picks the
19913/// landing combination, the chosen PRAGMAs are hardcoded as the new
19914/// reader-open default and this hook is removed.
19915/// 0.7.0 perf-experiments hook: apply writer-side PRAGMAs from
19916/// `FATHOMDB_PERF_WRITER_PRAGMAS` (same format as reader hook).
19917/// **Runs BEFORE migrations** so PRAGMAs like `page_size` that must
19918/// precede any table creation take effect on a fresh DB.
19919///
19920/// Gated on `FATHOMDB_PERF_EXPERIMENTS=1`. No-op otherwise.
19921fn apply_perf_experiment_writer_pragmas(connection: &Connection) {
19922 if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
19923 return;
19924 }
19925 let raw = match std::env::var("FATHOMDB_PERF_WRITER_PRAGMAS") {
19926 Ok(s) if !s.is_empty() => s,
19927 _ => return,
19928 };
19929 for entry in raw.split(',') {
19930 let entry = entry.trim();
19931 if entry.is_empty() {
19932 continue;
19933 }
19934 let (name, value) = match entry.split_once('=') {
19935 Some((n, v)) => (n.trim(), v.trim()),
19936 None => {
19937 eprintln!("perf-experiment: bad writer pragma entry (expect name=value): {entry}");
19938 continue;
19939 }
19940 };
19941 if name.is_empty() {
19942 eprintln!("perf-experiment: empty pragma name in writer entry: {entry}");
19943 continue;
19944 }
19945 match connection.pragma_update(None, name, value) {
19946 Ok(()) => {
19947 eprintln!(
19948 "perf-experiment: applied PRAGMA {name}={value} on writer (pre-migration)"
19949 );
19950 }
19951 Err(err) => {
19952 eprintln!("perf-experiment: writer PRAGMA {name}={value} failed: {err}");
19953 }
19954 }
19955 }
19956}
19957
19958fn apply_perf_experiment_reader_pragmas(connection: &Connection) {
19959 if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
19960 return;
19961 }
19962 let raw = match std::env::var("FATHOMDB_PERF_READER_PRAGMAS") {
19963 Ok(s) if !s.is_empty() => s,
19964 _ => return,
19965 };
19966 for entry in raw.split(',') {
19967 let entry = entry.trim();
19968 if entry.is_empty() {
19969 continue;
19970 }
19971 let (name, value) = match entry.split_once('=') {
19972 Some((n, v)) => (n.trim(), v.trim()),
19973 None => {
19974 eprintln!("perf-experiment: bad pragma entry (expect name=value): {entry}");
19975 continue;
19976 }
19977 };
19978 if name.is_empty() {
19979 eprintln!("perf-experiment: empty pragma name in entry: {entry}");
19980 continue;
19981 }
19982 match connection.pragma_update(None, name, value) {
19983 Ok(()) => {
19984 eprintln!("perf-experiment: applied PRAGMA {name}={value} on reader");
19985 }
19986 Err(err) => {
19987 eprintln!("perf-experiment: PRAGMA {name}={value} failed: {err}");
19988 }
19989 }
19990 }
19991}
19992
19993fn configure_reader_lookaside(connection: &Connection) -> std::os::raw::c_int {
19994 // SAFETY: `connection.handle()` returns a valid `*mut sqlite3` for
19995 // the lifetime of `connection`. The variadic
19996 // `sqlite3_db_config(LOOKASIDE)` call expects three trailing
19997 // arguments of types `void*`, `int`, `int` — the prototype shape
19998 // documented in `sqlite3.h`. We pass a null buffer so SQLite owns
19999 // the lookaside backing allocation, and the slot size / count from
20000 // the G.1 constants. No allocations happen on the connection
20001 // before this call (reader open path is `Connection::open` ->
20002 // `configure_reader_lookaside` -> first PRAGMA).
20003 unsafe {
20004 rusqlite::ffi::sqlite3_db_config(
20005 connection.handle(),
20006 rusqlite::ffi::SQLITE_DBCONFIG_LOOKASIDE,
20007 std::ptr::null_mut::<std::ffi::c_void>(),
20008 READER_LOOKASIDE_SLOT_SIZE,
20009 READER_LOOKASIDE_SLOT_COUNT,
20010 )
20011 }
20012}
20013
20014/// Read the high-water-mark for `SQLITE_DBSTATUS_LOOKASIDE_USED` on
20015/// `connection`. The `current` out-param is the live checked-out slot
20016/// count and decays as transactions finalize, so it is unreliable as
20017/// post-warmup evidence. The `hiwtr` out-param latches the largest
20018/// observed `current` value since the last reset and is the right
20019/// signal that lookaside was honored at any point on this connection.
20020/// Reset flag is `0` so reading does not clear the high-water mark.
20021#[cfg(debug_assertions)]
20022fn read_lookaside_used_hiwtr(connection: &Connection) -> std::os::raw::c_int {
20023 let mut current: std::os::raw::c_int = 0;
20024 let mut hiwtr: std::os::raw::c_int = 0;
20025 // SAFETY: handle is valid; both out pointers are to local stack
20026 // ints; reset flag 0 is documented as legal.
20027 unsafe {
20028 rusqlite::ffi::sqlite3_db_status(
20029 connection.handle(),
20030 rusqlite::ffi::SQLITE_DBSTATUS_LOOKASIDE_USED,
20031 &mut current,
20032 &mut hiwtr,
20033 0,
20034 );
20035 }
20036 hiwtr
20037}
20038
20039/// Pack 6.G G.3.5 — read the three page-cache pressure counters on
20040/// `connection`: `SQLITE_DBSTATUS_CACHE_HIT`, `_CACHE_MISS`, and
20041/// `_CACHE_USED`. Returns `(hit, miss, used_bytes)`. Hit/miss are
20042/// monotonic counters (reset flag = 0 here); used_bytes is the live
20043/// page-cache memory footprint at call time. The caller is expected to
20044/// take pre/post snapshots and do delta arithmetic explicitly.
20045#[cfg(debug_assertions)]
20046fn read_cache_status(
20047 connection: &Connection,
20048) -> (std::os::raw::c_int, std::os::raw::c_int, std::os::raw::c_int) {
20049 let mut hit_current: std::os::raw::c_int = 0;
20050 let mut hit_hiwtr: std::os::raw::c_int = 0;
20051 let mut miss_current: std::os::raw::c_int = 0;
20052 let mut miss_hiwtr: std::os::raw::c_int = 0;
20053 let mut used_current: std::os::raw::c_int = 0;
20054 let mut used_hiwtr: std::os::raw::c_int = 0;
20055 // SAFETY: `connection.handle()` returns a valid `*mut sqlite3` for
20056 // the lifetime of `connection`. All out-pointers are to local stack
20057 // ints. Reset flag 0 is documented as legal (no counter is reset).
20058 unsafe {
20059 rusqlite::ffi::sqlite3_db_status(
20060 connection.handle(),
20061 rusqlite::ffi::SQLITE_DBSTATUS_CACHE_HIT,
20062 &mut hit_current,
20063 &mut hit_hiwtr,
20064 0,
20065 );
20066 rusqlite::ffi::sqlite3_db_status(
20067 connection.handle(),
20068 rusqlite::ffi::SQLITE_DBSTATUS_CACHE_MISS,
20069 &mut miss_current,
20070 &mut miss_hiwtr,
20071 0,
20072 );
20073 rusqlite::ffi::sqlite3_db_status(
20074 connection.handle(),
20075 rusqlite::ffi::SQLITE_DBSTATUS_CACHE_USED,
20076 &mut used_current,
20077 &mut used_hiwtr,
20078 0,
20079 );
20080 }
20081 // CACHE_HIT / CACHE_MISS are monotonic counters reported in the
20082 // `current` out-param; CACHE_USED is the live byte count, also in
20083 // `current`. The hiwtr values are unused for this telemetry.
20084 (hit_current, miss_current, used_current)
20085}
20086
20087/// FFI trampoline for `sqlite3_profile`.
20088///
20089/// Invoked by SQLite at statement-finish with the SQL text and the
20090/// statement's wall-clock cost in nanoseconds. We dispatch a
20091/// `ProfileRecord` (when profiling is enabled) and a `SlowStatement`
20092/// signal (when `wall_clock_ms` exceeds the configured slow threshold).
20093///
20094/// Per `dev/design/lifecycle.md` § Public record shape, the public
20095/// payload exposes `wall_clock_ms`, `step_count`, and `cache_delta`.
20096/// `sqlite3_profile` does not surface per-statement step counts or
20097/// cache-hit deltas in its callback; we emit `0` for those fields and
20098/// document the hazard. AC-005b requires the fields be typed numeric,
20099/// not that they carry non-zero values for every backend.
20100unsafe extern "C" fn profile_callback_trampoline(
20101 user_data: *mut std::ffi::c_void,
20102 sql: *const std::os::raw::c_char,
20103 nanoseconds: u64,
20104) {
20105 if user_data.is_null() || sql.is_null() {
20106 return;
20107 }
20108 let ctx = unsafe { &*(user_data.cast::<ProfileContext>()) };
20109 let sql_text = match unsafe { std::ffi::CStr::from_ptr(sql) }.to_str() {
20110 Ok(s) => s,
20111 Err(_) => return,
20112 };
20113
20114 let wall_clock_ms = nanoseconds / 1_000_000;
20115
20116 if ctx.profiling_enabled.load(Ordering::Relaxed) {
20117 let record = lifecycle::ProfileRecord {
20118 wall_clock_ms,
20119 // step_count / cache_delta are not surfaced by
20120 // sqlite3_profile; placeholder 0 satisfies AC-005b's
20121 // "typed numeric" contract. A future profiling refactor
20122 // around sqlite3_stmt_status + sqlite3_db_status would
20123 // populate them with non-zero deltas.
20124 step_count: 0,
20125 cache_delta: 0,
20126 };
20127 ctx.subscribers.dispatch_profile(&record);
20128 }
20129
20130 let threshold = ctx.slow_threshold_ms.load(Ordering::Relaxed);
20131 if wall_clock_ms > threshold {
20132 let signal = lifecycle::SlowStatement { statement: sql_text.to_string(), wall_clock_ms };
20133 ctx.subscribers.dispatch_slow_statement(&signal);
20134 }
20135}
20136
20137#[cfg(test)]
20138mod tests {
20139 use super::{
20140 derive_stable_id, resolve_source_type, Engine, IdSpace, IdSpaceKind, PreparedWrite,
20141 KIND_TO_SOURCE_TYPE_CASE_SQL, ROW_OWNED_PROJECTIONS,
20142 };
20143 use rusqlite::Connection;
20144 use tempfile::TempDir;
20145
20146 /// 0.8.20 Slice 5a (R-20-E1, work item 2) — the registry GUARD.
20147 ///
20148 /// Introspects `sqlite_master` on a freshly migrated database and asserts
20149 /// that EVERY `write_cursor`-keyed table is accounted for: either it is a
20150 /// registered row-owned projection, or it is one of the explicitly named
20151 /// canonical / operational tables that are sources of truth, not shadows.
20152 /// A future projection table therefore cannot be added without either
20153 /// registering it in [`ROW_OWNED_PROJECTIONS`] (making it erasable at every
20154 /// maintenance site at once) or consciously failing this test.
20155 ///
20156 /// **`_fathomdb_projection_state` is allowlisted as KIND-owned** (design v5
20157 /// §1.1): it is keyed by `kind`, not by `write_cursor`, and holds a per-kind
20158 /// enqueue watermark. Erasing one row must not rewind a whole kind's
20159 /// watermark, so it must NEVER be deleted per-cursor. The test asserts both
20160 /// halves of that claim — that it carries no `write_cursor` column, and that
20161 /// it is absent from the row-owned registry.
20162 #[test]
20163 fn guard_row_owned_registry() {
20164 /// Canonical + operational tables: `write_cursor`-carrying SOURCES OF
20165 /// TRUTH, never row-owned projections of another row.
20166 const NON_PROJECTION_CURSOR_TABLES: &[&str] =
20167 &["canonical_nodes", "canonical_edges", "operational_mutations", "operational_state"];
20168
20169 let dir = TempDir::new().unwrap();
20170 let path = dir.path().join("registry_guard.fathomdb");
20171 Engine::open(&path).expect("open").engine.close().expect("close");
20172 let conn = Connection::open(&path).expect("open sqlite");
20173
20174 let table_names: Vec<String> = conn
20175 .prepare(
20176 "SELECT name FROM sqlite_master
20177 WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
20178 )
20179 .expect("prepare")
20180 .query_map([], |row| row.get::<_, String>(0))
20181 .expect("query")
20182 .collect::<rusqlite::Result<Vec<_>>>()
20183 .expect("collect");
20184 assert!(table_names.len() > 5, "sqlite_master introspection returned nothing useful");
20185
20186 let has_write_cursor = |table: &str| -> bool {
20187 conn.prepare(&format!("PRAGMA table_info({table})"))
20188 .and_then(|mut stmt| {
20189 let names = stmt
20190 .query_map([], |row| row.get::<_, String>(1))?
20191 .collect::<rusqlite::Result<Vec<_>>>()?;
20192 Ok(names.iter().any(|n| n == "write_cursor"))
20193 })
20194 .unwrap_or(false)
20195 };
20196
20197 let registered: Vec<&str> = ROW_OWNED_PROJECTIONS.iter().map(|p| p.table).collect();
20198
20199 // (1) Every write_cursor-keyed table is registered or explicitly excused.
20200 for table in &table_names {
20201 if !has_write_cursor(table) {
20202 continue;
20203 }
20204 assert!(
20205 registered.contains(&table.as_str())
20206 || NON_PROJECTION_CURSOR_TABLES.contains(&table.as_str()),
20207 "table `{table}` is keyed by write_cursor but is neither registered in \
20208 ROW_OWNED_PROJECTIONS nor listed as a non-projection source of truth. \
20209 If it is a projection, register it — otherwise erasure will leave its \
20210 rows on disk (the `search_index_v2` defect)."
20211 );
20212 }
20213
20214 // (2) Every registered projection actually exists and is erasable by its
20215 // declared cursor column (vec0's `rowid` included).
20216 for projection in ROW_OWNED_PROJECTIONS {
20217 assert!(
20218 table_names.iter().any(|t| t == projection.table),
20219 "registered projection `{}` does not exist in the schema",
20220 projection.table
20221 );
20222 conn.query_row(
20223 &format!(
20224 "SELECT COUNT(*) FROM {} WHERE {} = 0",
20225 projection.table, projection.cursor_column
20226 ),
20227 [],
20228 |row| row.get::<_, u64>(0),
20229 )
20230 .unwrap_or_else(|err| {
20231 panic!(
20232 "registered projection `{}` is not erasable by `{}`: {err}",
20233 projection.table, projection.cursor_column
20234 )
20235 });
20236 }
20237
20238 // (3) `_fathomdb_projection_state` is KIND-owned, not row-owned.
20239 assert!(
20240 !has_write_cursor("_fathomdb_projection_state"),
20241 "_fathomdb_projection_state gained a write_cursor column — re-decide its ownership \
20242 class before treating it as kind-owned"
20243 );
20244 assert!(
20245 !registered.contains(&"_fathomdb_projection_state"),
20246 "_fathomdb_projection_state is KIND-owned (per-kind enqueue watermark) and must \
20247 never be deleted per-cursor: erasing one row would rewind a whole kind's watermark"
20248 );
20249 }
20250
20251 /// 0.8.20 Slice 15d (R-20-EAV) — PROVE THE GUARD BITES. The two net-new
20252 /// content-storing projection tables (`canonical_attributes`,
20253 /// `property_search_index`) are `write_cursor`-keyed and hold attribute
20254 /// values at rest. This test asserts (1) they ARE registered in
20255 /// `ROW_OWNED_PROJECTIONS` (so `erase_row_projections` reaches them), and (2)
20256 /// the guard's core predicate — "registered OR a named source of truth" —
20257 /// FAILS for either table if it is (hypothetically) removed from the
20258 /// registry. This is what makes forgetting to register a future
20259 /// content-storing projection a red test, not a silent erasure leak.
20260 #[test]
20261 fn slice15d_attribute_projections_registered_and_guard_bites() {
20262 const NON_PROJECTION_CURSOR_TABLES: &[&str] =
20263 &["canonical_nodes", "canonical_edges", "operational_mutations", "operational_state"];
20264
20265 let registered: Vec<&str> = ROW_OWNED_PROJECTIONS.iter().map(|p| p.table).collect();
20266
20267 // (1) Both new content-storing projections are registered as row-owned.
20268 for table in ["canonical_attributes", "property_search_index"] {
20269 assert!(
20270 registered.contains(&table),
20271 "{table} holds attribute values at rest and MUST be in ROW_OWNED_PROJECTIONS \
20272 so purge/excise_source reach it"
20273 );
20274 }
20275
20276 // (2) The guard predicate BITES: pretend one of them was never
20277 // registered — the guard's "registered OR source-of-truth" check must
20278 // reject it (the exact assertion `guard_row_owned_registry` runs).
20279 for hidden in ["canonical_attributes", "property_search_index"] {
20280 let as_if_unregistered: Vec<&str> =
20281 registered.iter().copied().filter(|t| *t != hidden).collect();
20282 let accepted = as_if_unregistered.contains(&hidden)
20283 || NON_PROJECTION_CURSOR_TABLES.contains(&hidden);
20284 assert!(
20285 !accepted,
20286 "if {hidden} were unregistered the guard would still (incorrectly) accept it — \
20287 the guard does not actually bite"
20288 );
20289 }
20290 }
20291
20292 /// Cause-A (0.8.11.2) / C-2 (0.8.19) — `derive_stable_id` id-space contract:
20293 /// a present `logical_id` yields a `Logical` (`"l:"`) [`IdSpace`]; a NULL or
20294 /// empty `logical_id` falls back to a deterministic `Content` (`"h:"`) sha256
20295 /// content-hash of the body. The typed spaces are prefix-distinguishable and
20296 /// the value is behaviour-neutral (never used in ranking). Post-C-2 the helper
20297 /// returns a typed [`IdSpace`] whose `to_prefixed()` reproduces the pre-swap
20298 /// string byte-for-byte (eu7 no-op basis).
20299 #[test]
20300 fn derive_stable_id_id_space_contract() {
20301 // logical_id present → Logical space, body-independent.
20302 assert_eq!(derive_stable_id(Some("alice-1"), "any body"), IdSpace::logical("alice-1"));
20303 assert_eq!(
20304 derive_stable_id(Some("alice-1"), "a different body"),
20305 IdSpace::logical("alice-1")
20306 );
20307 // Byte-identical prefixed form to the pre-C-2 `stable_id` string.
20308 assert_eq!(derive_stable_id(Some("alice-1"), "any body").to_prefixed(), "l:alice-1");
20309
20310 // NULL logical_id → Content space, deterministic on body.
20311 let h1 = derive_stable_id(None, "stable body text");
20312 let h2 = derive_stable_id(None, "stable body text");
20313 assert_eq!(h1, h2, "content-hash is deterministic");
20314 assert_eq!(h1.space, IdSpaceKind::Content);
20315 let h1s = h1.to_prefixed();
20316 assert!(h1s.starts_with("h:"));
20317 assert_eq!(h1s.len(), 2 + 64, "h: + sha256 hex");
20318 assert!(h1s["h:".len()..].chars().all(|c| c.is_ascii_hexdigit()));
20319
20320 // Empty logical_id is treated as absent (falls back to content-hash).
20321 assert_eq!(derive_stable_id(Some(""), "stable body text"), h1);
20322
20323 // Distinct bodies → distinct content-hashes (no collision).
20324 assert_ne!(derive_stable_id(None, "body A"), derive_stable_id(None, "body B"));
20325 }
20326
20327 /// C-2 (0.8.19 / TC-8) — [`IdSpace`] parse/format round-trip is stable across
20328 /// all three spaces, including a value that itself contains `":"`.
20329 #[test]
20330 fn id_space_parse_format_round_trip() {
20331 let cases = [
20332 IdSpace::logical("alice-1"),
20333 IdSpace::content("a".repeat(64)),
20334 IdSpace::passage("7"),
20335 IdSpace::logical("l:weird:value"), // value contains the delimiter
20336 ];
20337 for id in cases {
20338 assert_eq!(IdSpace::parse(&id.to_prefixed()), Some(id.clone()), "round-trip {id:?}");
20339 }
20340 assert_eq!(IdSpace::logical("x").to_prefixed(), "l:x");
20341 assert_eq!(IdSpace::content("y").to_prefixed(), "h:y");
20342 assert_eq!(IdSpace::passage("3").to_prefixed(), "p:3");
20343 assert_eq!(IdSpace::parse("untagged"), None);
20344 }
20345
20346 // Pack 1 drift-detection: the Rust helper used by the two writer
20347 // sites must agree with the CASE WHEN used by the Pack 1 reshape
20348 // migration in `migrate_vector_partition_to_pack1`. The CASE SQL
20349 // is exported as `KIND_TO_SOURCE_TYPE_CASE_SQL`; this test
20350 // exercises it against an in-memory SQLite (no sqlite-vec extension
20351 // required — only the CASE) and asserts byte-equal output with the
20352 // Rust helper for every kind in the locked Pack 1 vocabulary
20353 // (incl. the synthetic `doc` -> `article` coercion). See
20354 // `dev/design/0.7.0-vector-quant-pack1.md` D3 / D4.
20355 #[test]
20356 fn resolve_source_type_drift_check() {
20357 let kinds = ["email", "article", "paper", "meeting", "note", "todo", "doc"];
20358
20359 // 1. Rust helper return values (table is the contract: changes
20360 // here must be reflected in the SQL CASE or this test fails).
20361 let want: &[(&str, &str)] = &[
20362 ("email", "email"),
20363 ("article", "article"),
20364 ("paper", "paper"),
20365 ("meeting", "meeting"),
20366 ("note", "note"),
20367 ("todo", "todo"),
20368 ("doc", "article"),
20369 ];
20370 for (kind, expected) in want {
20371 let got = resolve_source_type(kind).unwrap_or_else(|_| {
20372 panic!("resolve_source_type({kind}) returned Err; want Ok({expected})")
20373 });
20374 assert_eq!(got, *expected, "Rust helper drift for kind={kind}");
20375 }
20376 assert!(
20377 resolve_source_type("banana").is_err(),
20378 "unknown kind must surface as writer error"
20379 );
20380
20381 // 2. SQL CASE evaluated against the same kinds. Build a
20382 // one-row staging row per kind and SELECT through
20383 // KIND_TO_SOURCE_TYPE_CASE_SQL; assert each row equals the
20384 // Rust helper's output. Drift in either direction fails.
20385 let conn = Connection::open_in_memory().expect("in-memory sqlite");
20386 conn.execute_batch("CREATE TABLE s(kind TEXT NOT NULL)").expect("create s");
20387 for kind in &kinds {
20388 conn.execute("INSERT INTO s(kind) VALUES (?1)", [kind]).expect("insert kind");
20389 }
20390 let sql = format!("SELECT s.kind, {KIND_TO_SOURCE_TYPE_CASE_SQL} FROM s");
20391 let mut stmt = conn.prepare(&sql).expect("prepare CASE");
20392 let rows: Vec<(String, String)> = stmt
20393 .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
20394 .expect("query")
20395 .map(|r| r.expect("row"))
20396 .collect();
20397 assert_eq!(rows.len(), kinds.len(), "row count drift");
20398 for (kind, sql_result) in &rows {
20399 let rust_result = resolve_source_type(kind).expect("known kind");
20400 assert_eq!(
20401 sql_result, rust_result,
20402 "SQL CASE vs Rust helper drift for kind={kind}: SQL={sql_result}, Rust={rust_result}"
20403 );
20404 }
20405 }
20406
20407 #[test]
20408 fn write_advances_cursor() {
20409 let dir = TempDir::new().unwrap();
20410 let opened = Engine::open(dir.path().join("rewrite.sqlite")).expect("engine should open");
20411 let receipt = opened
20412 .engine
20413 .write(&[PreparedWrite::Node {
20414 kind: "doc".to_string(),
20415 body: "hello".to_string(),
20416 source_id: crate::SourceId::new("test:fixture").expect("test source id"),
20417 logical_id: None,
20418 state: crate::InitialState::Active,
20419 reason: None,
20420 valid_from: None,
20421 valid_until: None,
20422 }])
20423 .expect("write should succeed");
20424
20425 assert_eq!(receipt.cursor, 1);
20426 }
20427}