Skip to main content

mongreldb_core/
engine.rs

1//! The engine tying the write and read paths together.
2//!
3//! Sub-ms writes: [`Table::put`] appends to the WAL **without fsyncing**, upserts
4//! the skip-list memtable, and updates the in-memory HOT index + secondary
5//! indexes. A batch-driven [`Table::commit`] does the group `fsync` and bumps the
6//! epoch. [`Table::flush`] commits, drains the memtable into an immutable sorted
7//! run, and rotates the WAL. Reads merge versions across the live memtable and
8//! all sorted runs ([`Table::get`], [`Table::visible_rows`]).
9
10use crate::columnar;
11use crate::cursor::NativePageCursor;
12use crate::encryption::Kek;
13use crate::encryption::DEK_LEN;
14use crate::epoch::{Epoch, EpochAuthority, Snapshot};
15use crate::global_idx;
16use crate::index::{
17    AnnIndex, BitmapIndex, ColumnLearnedRange, FmIndex, HotIndex, MinHashIndex, SparseIndex,
18};
19use crate::manifest::{self, Manifest, RunRef, TtlPolicy};
20use crate::memtable::{Memtable, Row, Value};
21use crate::mutable_run::MutableRun;
22use crate::row_id_set::RowIdSet;
23use crate::rowid::{RowId, RowIdAllocator};
24use crate::schema::{AlterColumn, ColumnDef, ColumnFlags, IndexDef, IndexKind, Schema, TypeId};
25use crate::sorted_run::{RunReader, RunWriter};
26use crate::txn::{GroupCommit, OwnedRow};
27use crate::wal::{Op, SharedWal, Wal};
28use crate::{MongrelError, Result};
29use std::collections::{BTreeMap, HashMap, HashSet};
30use std::path::{Path, PathBuf};
31use std::sync::atomic::AtomicBool;
32use std::sync::Arc;
33use zeroize::Zeroizing;
34
35pub const WAL_DIR: &str = "_wal";
36pub const RUNS_DIR: &str = "_runs";
37pub const CACHE_DIR: &str = "_cache";
38pub const META_DIR: &str = "_meta";
39pub const RCACHE_DIR: &str = "_rcache";
40pub const KEYS_FILENAME: &str = "keys";
41pub const SCHEMA_FILENAME: &str = "schema.json";
42
43/// Current UTC time as an ISO-8601 string in bytes (e.g. `b"2024-07-07T14:30:00Z"`).
44/// Used by `DefaultExpr::Now` at stage time.
45fn iso_now_bytes() -> Vec<u8> {
46    let secs = std::time::SystemTime::now()
47        .duration_since(std::time::UNIX_EPOCH)
48        .map(|d| d.as_secs() as i64)
49        .unwrap_or(0);
50    let days = secs.div_euclid(86_400);
51    let rem = secs.rem_euclid(86_400);
52    let (hour, minute, second) = (rem / 3600, (rem % 3600) / 60, rem % 60);
53    let (year, month, day) = civil_from_days(days);
54    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z").into_bytes()
55}
56
57pub(crate) fn unix_nanos_now() -> i64 {
58    std::time::SystemTime::now()
59        .duration_since(std::time::UNIX_EPOCH)
60        .map(|d| d.as_nanos().min(i64::MAX as u128) as i64)
61        .unwrap_or(0)
62}
63
64fn civil_from_days(z: i64) -> (i64, u32, u32) {
65    let z = z + 719_468;
66    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
67    let doe = z - era * 146_097;
68    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
69    let y = yoe + era * 400;
70    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
71    let mp = (5 * doy + 2) / 153;
72    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
73    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
74    (if m <= 2 { y + 1 } else { y }, m, d)
75}
76
77const DEFAULT_SYNC_BYTE_THRESHOLD: u64 = 0; // manual commit only (pure group commit)
78pub(crate) const PAGE_CACHE_CAPACITY: u64 = 64 * 1024 * 1024; // 64 MiB shared page cache
79pub(crate) const DECODED_CACHE_CAPACITY: u64 = 64 * 1024 * 1024; // 64 MiB shared decoded-page cache (Phase 15.4)
80/// Default byte watermark at which the PMA mutable-run tier spills to an
81/// immutable `.sr` sorted run (Phase 11.1). Coalesces many small flushes into
82/// one larger run so the read path merges fewer readers.
83const DEFAULT_MUTABLE_RUN_SPILL_BYTES: u64 = 8 * 1024 * 1024;
84
85/// Engine-managed `AUTO_INCREMENT` counter state for a table (present iff the
86/// schema declares an `AUTO_INCREMENT` primary key).
87///
88/// `next` is the next value to hand out (1-based, monotonic, never reused). It
89/// is `0` while *unseeded* — the counter has never been advanced (fresh table or
90/// a legacy manifest predating `auto_inc_next`). When `seeded` is `false` the
91/// first allocation scans `max(PK)` over all visible rows so the counter never
92/// collides with pre-existing rows; a value of `0` after seeding never happens
93/// (ids are never 0). The manifest persists `next` only when `seeded`, so a
94/// reopen that reads `auto_inc_next > 0` is authoritative.
95///
96/// `seeded == false` but `next > 0` is a transient recovery-only state: WAL
97/// replay may bump `next` past replayed ids without marking it seeded, so the
98/// scan still runs to cover rows that were already flushed to sorted runs.
99#[derive(Clone, Copy, Debug)]
100struct AutoIncState {
101    column_id: u16,
102    next: i64,
103    seeded: bool,
104}
105
106type FilledAutoIncRow = (Vec<(u16, Value)>, Option<i64>);
107
108/// Resolve the auto-increment column (if any) from a schema into initial
109/// counter state. Always called after [`crate::schema::Schema::validate_auto_increment`].
110fn resolve_auto_inc(schema: &Schema) -> Option<AutoIncState> {
111    schema.auto_increment_column().map(|c| AutoIncState {
112        column_id: c.id,
113        next: 0,
114        seeded: false,
115    })
116}
117
118/// When a bulk load (`bulk_load` / `bulk_load_columns` / `bulk_load_fast`)
119/// builds the live in-memory indexes.
120///
121/// The engine is correct under either policy: with [`Self::Deferred`] the
122/// indexes are rebuilt lazily by the first `query`/`flush` (Phase 14.7,
123/// `ensure_indexes_complete`), with [`Self::Eager`] they are built — and
124/// checkpointed to `_idx/global.idx` — inside the bulk load itself. The trade
125/// is *where* the build cost lands: `Deferred` keeps the ingest critical path
126/// minimal (write the run, persist the manifest, return); `Eager` gives
127/// predictable first-query latency at the price of a slower load. Serving
128/// deployments that load then immediately serve point queries (e.g. a warm
129/// daemon) may prefer `Eager`; batch/ETL ingest wants `Deferred`.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
131pub enum IndexBuildPolicy {
132    /// Defer index building to the first query/flush — fastest ingest (default).
133    #[default]
134    Deferred,
135    /// Build and checkpoint indexes inside the bulk load — fastest first query.
136    Eager,
137}
138
139/// An open MongrelDB table.
140pub struct Table {
141    dir: PathBuf,
142    table_id: u64,
143    /// The table's catalog name, set at mount time. Used by the auth
144    /// enforcement layer to check `Select`/`Insert`/`Update`/`Delete`
145    /// permissions against this specific table.
146    name: String,
147    /// Optional auth checker for per-operation enforcement. `None` on
148    /// credentialless databases (the default); `Some` when the database has
149    /// `require_auth = true`. The checker is shared (via `Arc`) so it sees
150    /// live updates to the principal and the `require_auth` flag.
151    auth: Option<Arc<dyn crate::auth_state::TableAuthChecker>>,
152    /// Logical writes are forbidden when this table belongs to a replication
153    /// follower. Replication itself appends through the database WAL API.
154    read_only: bool,
155    wal: WalSink,
156    memtable: Memtable,
157    /// PMA-backed mutable-run LSM tier (Phase 11.1). A flush drains the
158    /// memtable into this in-memory sorted tier instead of immediately writing
159    /// a `.sr` run; once it crosses `mutable_run_spill_bytes` it spills to an
160    /// immutable run. Purely in-memory — rebuilt from WAL replay on reopen.
161    mutable_run: MutableRun,
162    /// Byte watermark controlling when `mutable_run` spills to a sorted run.
163    mutable_run_spill_bytes: u64,
164    /// Zstd compression level for compaction output (Phase 18.1: default 3;
165    /// higher = better ratio but slower compaction).
166    compaction_zstd_level: i32,
167    allocator: RowIdAllocator,
168    epoch: Arc<EpochAuthority>,
169    /// Manifest-endorsed epoch at open; used to seed the (shared) epoch
170    /// authority on a fresh open. Updated whenever the manifest is persisted.
171    persisted_epoch: u64,
172    /// Table-local content generation used by authorization caches. Unlike the
173    /// shared MVCC epoch, unrelated table commits do not change this value.
174    data_generation: u64,
175    schema: Schema,
176    hot: HotIndex,
177    /// Table Key-Encryption Key (Argon2id+HKDF from the passphrase). Each run
178    /// stores a fresh DEK wrapped by this KEK (see §7). `None` when plaintext.
179    kek: Option<Arc<Kek>>,
180    /// Per-column indexable-encryption keys + scheme (Phase 10.2) for every
181    /// ENCRYPTED_INDEXABLE column, derived deterministically from the KEK so
182    /// tokens are identical across runs. Empty when the table is plaintext.
183    column_keys: HashMap<u16, ([u8; 32], u8)>,
184    run_refs: Vec<RunRef>,
185    /// Runs superseded by compaction, kept on disk for snapshot retention until
186    /// `gc()` reaps them (spec §6.4). Persisted in the manifest (`retiring`).
187    retiring: Vec<crate::manifest::RetiredRun>,
188    next_run_id: u64,
189    sync_byte_threshold: u64,
190    /// Next transaction id to assign to a single-table auto-commit txn
191    /// (`put`/`delete` then `commit`). 0 is reserved for [`wal::SYSTEM_TXN_ID`].
192    /// The Database transaction layer (P2.5) assigns these globally; the
193    /// single-table path uses this local counter.
194    current_txn_id: u64,
195    bitmap: HashMap<u16, BitmapIndex>,
196    ann: HashMap<u16, AnnIndex>,
197    fm: HashMap<u16, FmIndex>,
198    sparse: HashMap<u16, SparseIndex>,
199    minhash: HashMap<u16, MinHashIndex>,
200    /// Per-column learned (PGM) range indexes for `IndexKind::LearnedRange`
201    /// columns, built from the single sorted run.
202    learned_range: HashMap<u16, ColumnLearnedRange>,
203    /// Reverse primary-key map for HOT cleanup on row-id deletes.
204    pk_by_row: HashMap<RowId, Vec<u8>>,
205    /// Refcounted pinned read snapshots (epoch → count); compaction must not GC
206    /// versions an active snapshot still needs.
207    pinned: BTreeMap<Epoch, usize>,
208    /// Live (non-deleted) row count — maintained incrementally for O(1)
209    /// `Table::count()` without a scan.
210    pub(crate) live_count: u64,
211    /// Uniform reservoir sample of row ids for approximate analytics
212    /// (Phase 8.2). Maintained incrementally on insert; repopulated on open.
213    reservoir: crate::reservoir::Reservoir,
214    /// False when `reservoir` needs a full rebuild from `visible_rows` before
215    /// [`Table::approx_aggregate`] can trust it (same lazy pattern as
216    /// [`Table::ensure_indexes_complete`]). Open and WAL-replay leave this
217    /// false instead of eagerly materializing every row — a full-table scan
218    /// no plain insert/update/delete needs — and the first approximate-
219    /// aggregate call pays the rebuild, after which `.offer()` calls maintain
220    /// it incrementally.
221    reservoir_complete: bool,
222    /// True once any row has been deleted. The incremental aggregate cache
223    /// (Phase 8.3) is only valid for append-only tables, so a single delete
224    /// permanently disables incremental maintenance for this table.
225    had_deletes: bool,
226    /// Incremental aggregate cache (Phase 8.3): caller-supplied key → the
227    /// mergeable aggregate state, the row-id watermark it covers, and the
228    /// epoch. A re-query after more inserts processes only the delta and merges.
229    agg_cache: HashMap<u64, CachedAgg>,
230    /// The manifest epoch the on-disk `_idx/global.idx` checkpoint covers (0 if
231    /// there is no checkpoint). Updated by [`Table::checkpoint_indexes`]; persisted
232    /// in the manifest so reopen loads the checkpoint instead of rebuilding.
233    global_idx_epoch: u64,
234    /// False when the live in-memory indexes are known to be incomplete (e.g.
235    /// after [`Table::bulk_load_columns`], which bypasses per-row indexing). A
236    /// flush in that state must NOT checkpoint; reopen rebuilds complete indexes
237    /// from the runs and resets this to true.
238    indexes_complete: bool,
239    /// Where bulk loads put the index-build cost (see [`IndexBuildPolicy`]).
240    index_build_policy: IndexBuildPolicy,
241    /// False when `pk_by_row` may be missing entries for rows present in
242    /// `hot`. Fresh tables start false and puts skip the reverse map — pure
243    /// ingest never pays for it. The first delete that needs it rebuilds it
244    /// from `hot` (the same lazy pattern as `ensure_indexes_complete`), after
245    /// which puts maintain it incrementally so a delete-active workload pays
246    /// the build exactly once.
247    pk_by_row_complete: bool,
248    /// Highest epoch whose data is durable in a sorted run (spec §7.1). Recovery
249    /// skips replaying WAL records whose commit epoch is `<= flushed_epoch`.
250    flushed_epoch: u64,
251    /// Shared, MVCC content-addressed page cache (Phase 9.2). Fed by every
252    /// `RunReader::read_page` so all readers share raw (decrypted) page bytes.
253    page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
254    /// Global snapshot-retention registry shared across all tables in a
255    /// `Database`. Single-table direct opens get a private one.
256    snapshots: Arc<crate::retention::SnapshotRegistry>,
257    /// Cross-table commit serializer (see [`SharedCtx::commit_lock`]).
258    commit_lock: Arc<parking_lot::Mutex<()>>,
259    /// Shared decoded-page cache (Phase 15.4): the post-decompress/decrypt typed
260    /// page, so repeat scans skip decode. Keyed by `(run_id, column_id, page)`.
261    decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
262    /// `run_id`s whose on-disk footer checksum has already been verified by a
263    /// `RunReader` construction in this process. `.sr` runs are immutable once
264    /// written, so re-hashing an already-verified run's full body on every
265    /// repeat `open_reader` call (every query, every `remove_hot_for_row`) is
266    /// pure waste for a warm/long-lived handle — this cache lets
267    /// `read_header_cached` skip straight to the cheap header+footer-magic
268    /// check after the first open. Scoped per-`Table` (not shared via
269    /// `SharedCtx`) since `run_id` is only unique within one table's own
270    /// manifest.
271    verified_runs: Arc<parking_lot::Mutex<std::collections::HashSet<u128>>>,
272    /// Table-level result cache (Phase 19.1): `canonical_query_key(conditions,
273    /// projection, epoch)` → the survivor columns as typed `NativeColumn`s. Shared
274    /// by the native `Condition` API and (via `query_cached`) the tool-call path,
275    /// which previously had no caching (only the SQL `MongrelSession` cache did).
276    /// Hardening (c): epoch is no longer in the key; instead, a `commit()`
277    /// invalidates only entries whose footprint or condition-columns intersect
278    /// the committed mutations, tracked in `pending_delete_rids` and
279    /// `pending_put_cols`.
280    result_cache: Arc<parking_lot::Mutex<ResultCache>>,
281    /// WAL DEK (for frame-level encryption). None for plaintext tables.
282    wal_dek: Option<Zeroizing<[u8; DEK_LEN]>>,
283    /// RowIds deleted since the last `commit()` — used by fine-grained cache
284    /// invalidation to check footprint intersection.
285    pending_delete_rids: roaring::RoaringBitmap,
286    /// Column IDs touched by `put`/`put_batch` since the last `commit()` — used
287    /// by conservative insert-newly-matches invalidation.
288    pending_put_cols: std::collections::HashSet<u16>,
289    /// B1/B2: rows staged by `put`/`put_batch` on a mounted (shared-WAL) table
290    /// but not yet applied to the memtable. They are re-stamped to the real
291    /// assigned epoch in `commit` (never a speculative `visible+1`), so a
292    /// concurrent reader can never observe them before their commit epoch.
293    /// Always empty on a standalone (private-WAL) table, which applies inline.
294    pending_rows: Vec<Row>,
295    pending_rows_auto_inc: Vec<bool>,
296    /// B1/B2: tombstones staged on a mounted table, applied at the assigned
297    /// epoch in `commit` (mirror of `pending_rows`).
298    pending_dels: Vec<RowId>,
299    /// B1/B2: truncate staged on a mounted table, applied at the assigned epoch
300    /// in `commit`; standalone tables also defer the physical clear until after
301    /// the private WAL is fsynced.
302    pending_truncate: Option<Epoch>,
303    /// Engine-managed `AUTO_INCREMENT` counter (`None` for tables without an
304    /// auto-increment primary key). See [`AutoIncState`].
305    auto_inc: Option<AutoIncState>,
306    /// Manifest-backed timestamp retention policy. Its wall-clock cutoff is
307    /// evaluated once per read/compaction operation, never cached by epoch.
308    ttl: Option<TtlPolicy>,
309}
310
311// `Table` is `Sync`: every field is either plain data, an `Arc`, a `Vec`/`HashMap`
312// of `Sync` data, or a thread-safe interior-mutability cell (`parking_lot::Mutex`,
313// `crossbeam`/`epoch` Arc-shared caches). The only `RefCell`-based type was
314// `FmIndex` (lazy rebuild of the BWT), which now uses a `Mutex`, so a `&Table`
315// can be safely shared across read threads (concurrent mutation still requires
316// the caller's `Mutex<Table>`).
317const _: () = {
318    const fn assert_sync<T: ?Sized + Sync>() {}
319    assert_sync::<Table>();
320};
321
322/// A cached query result — either survivor `Row`s (the tool-call/`query` path)
323/// or typed survivor columns (the pushdown/`query_columns_native` path). One
324/// canonical key maps to exactly one variant (a `query` with no projection vs a
325/// `query_columns_native` with a specific projection produce different keys), so
326/// there is no representation collision.
327enum CachedData {
328    Rows(Arc<Vec<Row>>),
329    Columns(Arc<Vec<(u16, columnar::NativeColumn)>>),
330}
331
332impl CachedData {
333    fn approx_bytes(&self) -> u64 {
334        match self {
335            CachedData::Rows(r) => r.iter().map(|r| r.estimated_bytes()).sum::<u64>(),
336            CachedData::Columns(c) => c
337                .iter()
338                .map(|(_, c)| c.approx_bytes())
339                .sum::<u64>()
340                .saturating_add(c.len() as u64 * 16),
341        }
342    }
343}
344
345/// A cached entry carrying the survivor `RowId` **footprint** (for precise
346/// delete-based invalidation) and the condition column IDs (for conservative
347/// insert-based invalidation). Hardening (c).
348struct CachedEntry {
349    data: CachedData,
350    footprint: roaring::RoaringBitmap,
351    condition_cols: Vec<u16>,
352}
353
354/// Size-bounded **access-order LRU** result cache (Phase 19.1 + hardening (a)).
355/// Every `get_*` promotes the key to the back (most-recently-used); eviction
356/// pops from the front (least-recently-used) — a true LRU, not FIFO.
357///
358/// Hardening (b): an optional on-disk persistent tier (`dir = Some(_)`). On a
359/// memory miss, the cache tries disk before falling through to re-resolution.
360/// On `insert`, the entry is also written to disk atomically (write + fsync +
361/// rename). On `invalidate`/`clear`, the matching disk files are deleted. On
362/// `Table::open`, existing disk entries are pre-loaded so fine-grained invalidation
363/// resumes across restart.
364struct ResultCache {
365    entries: std::collections::HashMap<u64, CachedEntry>,
366    order: std::collections::VecDeque<u64>,
367    bytes: u64,
368    max_bytes: u64,
369    dir: Option<std::path::PathBuf>,
370    #[allow(dead_code)]
371    cache_dek: Option<Zeroizing<[u8; DEK_LEN]>>,
372}
373
374/// Serialised form of a [`CachedEntry`] for the persistent on-disk tier (b).
375#[derive(serde::Serialize, serde::Deserialize)]
376struct SerializedEntry {
377    condition_cols: Vec<u16>,
378    footprint_bits: Vec<u32>,
379    data: SerializedData,
380}
381
382#[derive(serde::Serialize, serde::Deserialize)]
383enum SerializedData {
384    Rows(Vec<Row>),
385    Columns(Vec<(u16, columnar::NativeColumn)>),
386}
387
388impl SerializedEntry {
389    fn from_entry(entry: &CachedEntry) -> Self {
390        let footprint_bits: Vec<u32> = entry.footprint.iter().collect();
391        let data = match &entry.data {
392            CachedData::Rows(r) => SerializedData::Rows((**r).clone()),
393            CachedData::Columns(c) => SerializedData::Columns((**c).clone()),
394        };
395        Self {
396            condition_cols: entry.condition_cols.clone(),
397            footprint_bits,
398            data,
399        }
400    }
401
402    fn into_entry(self) -> Option<CachedEntry> {
403        let footprint: roaring::RoaringBitmap = self.footprint_bits.into_iter().collect();
404        let data = match self.data {
405            SerializedData::Rows(r) => CachedData::Rows(Arc::new(r)),
406            SerializedData::Columns(c) => {
407                // Validate deserialized columns (hardening (b)): reject corrupt
408                // data instead of panicking on access.
409                if !c.iter().all(|(_, col)| col.validate()) {
410                    return None;
411                }
412                CachedData::Columns(Arc::new(c))
413            }
414        };
415        Some(CachedEntry {
416            data,
417            footprint,
418            condition_cols: self.condition_cols,
419        })
420    }
421}
422
423impl ResultCache {
424    const DEFAULT_MAX_BYTES: u64 = 256 * 1024 * 1024;
425
426    fn new() -> Self {
427        Self::with_max_bytes(Self::DEFAULT_MAX_BYTES)
428    }
429
430    fn with_max_bytes(max_bytes: u64) -> Self {
431        Self {
432            entries: std::collections::HashMap::new(),
433            order: std::collections::VecDeque::new(),
434            bytes: 0,
435            max_bytes,
436            dir: None,
437            cache_dek: None,
438        }
439    }
440
441    fn with_dir(mut self, dir: std::path::PathBuf) -> Self {
442        let _ = std::fs::create_dir_all(&dir);
443        self.dir = Some(dir);
444        self
445    }
446
447    fn with_cache_dek(mut self, dek: Option<Zeroizing<[u8; DEK_LEN]>>) -> Self {
448        self.cache_dek = dek;
449        self
450    }
451
452    fn disk_path(&self, key: u64) -> Option<std::path::PathBuf> {
453        self.dir.as_ref().map(|d| d.join(format!("{key:016x}.bin")))
454    }
455
456    /// Atomically write `entry` to disk (write + rename). Best-effort: silently
457    /// ignores I/O errors (the in-memory cache is authoritative; the cache is
458    /// disposable — missing/stale files fall through to re-resolution).
459    fn store_to_disk(&self, key: u64, entry: &CachedEntry) {
460        let Some(path) = self.disk_path(key) else {
461            return;
462        };
463        let serialized = match bincode::serialize(&SerializedEntry::from_entry(entry)) {
464            Ok(s) => s,
465            Err(_) => return,
466        };
467        // Encrypt if a cache DEK is present.
468        let on_disk = if let Some(dek) = &self.cache_dek {
469            match self.encrypt_cache(&serialized, dek) {
470                Some(b) => b,
471                None => return,
472            }
473        } else {
474            serialized
475        };
476        let tmp = path.with_extension("tmp");
477        use std::io::Write;
478        let write = || -> std::io::Result<()> {
479            let mut f = std::fs::File::create(&tmp)?;
480            f.write_all(&on_disk)?;
481            f.flush()?;
482            Ok(())
483        };
484        if write().is_err() {
485            let _ = std::fs::remove_file(&tmp);
486            return;
487        }
488        let _ = std::fs::rename(&tmp, &path);
489    }
490
491    /// Try loading `key` from disk. Returns `None` on miss or error.
492    fn load_from_disk(&self, key: u64) -> Option<CachedEntry> {
493        let path = self.disk_path(key)?;
494        let bytes = std::fs::read(&path).ok()?;
495        let plaintext = if let Some(dek) = &self.cache_dek {
496            self.decrypt_cache(&bytes, dek)?
497        } else {
498            bytes
499        };
500        let serialized: SerializedEntry = bincode::deserialize(&plaintext).ok()?;
501        serialized.into_entry()
502    }
503
504    /// Delete the on-disk file for `key` if it exists. Best-effort.
505    fn remove_from_disk(&self, key: u64) {
506        if let Some(path) = self.disk_path(key) {
507            let _ = std::fs::remove_file(&path);
508        }
509    }
510
511    /// Encrypt cache data: `[nonce: 12B][ciphertext + GCM tag]`.
512    #[cfg(feature = "encryption")]
513    fn encrypt_cache(&self, plaintext: &[u8], dek: &Zeroizing<[u8; DEK_LEN]>) -> Option<Vec<u8>> {
514        use crate::encryption::Cipher;
515        let cipher = crate::encryption::AesCipher::new(&dek[..]).ok()?;
516        let mut nonce = [0u8; 12];
517        crate::encryption::fill_random(&mut nonce);
518        let ct = cipher.encrypt_page(&nonce, plaintext).ok()?;
519        let mut out = Vec::with_capacity(12 + ct.len());
520        out.extend_from_slice(&nonce);
521        out.extend_from_slice(&ct);
522        Some(out)
523    }
524
525    #[cfg(not(feature = "encryption"))]
526    fn encrypt_cache(&self, _plaintext: &[u8], _dek: &Zeroizing<[u8; DEK_LEN]>) -> Option<Vec<u8>> {
527        None
528    }
529
530    /// Decrypt cache data: reads nonce from first 12 bytes.
531    #[cfg(feature = "encryption")]
532    fn decrypt_cache(&self, bytes: &[u8], dek: &Zeroizing<[u8; DEK_LEN]>) -> Option<Vec<u8>> {
533        use crate::encryption::Cipher;
534        if bytes.len() < 28 {
535            return None;
536        }
537        let cipher = crate::encryption::AesCipher::new(&dek[..]).ok()?;
538        let nonce: [u8; 12] = bytes[..12].try_into().ok()?;
539        let ct = &bytes[12..];
540        cipher.decrypt_page(&nonce, ct).ok()
541    }
542
543    #[cfg(not(feature = "encryption"))]
544    fn decrypt_cache(&self, _bytes: &[u8], _dek: &Zeroizing<[u8; DEK_LEN]>) -> Option<Vec<u8>> {
545        None
546    }
547
548    /// Scan the cache directory and pre-load all entries into memory. Called
549    /// once on `Table::open`. Best-effort: corrupt/unreadable files are deleted.
550    fn load_persistent(&mut self) {
551        let Some(dir) = self.dir.as_ref().cloned() else {
552            return;
553        };
554        let entries = match std::fs::read_dir(&dir) {
555            Ok(e) => e,
556            Err(_) => return,
557        };
558        for entry in entries.flatten() {
559            let path = entry.path();
560            // Clean up orphan .tmp files from crashed store_to_disk calls.
561            if path.extension().and_then(|e| e.to_str()) == Some("tmp") {
562                let _ = std::fs::remove_file(&path);
563                continue;
564            }
565            if path.extension().and_then(|e| e.to_str()) != Some("bin") {
566                continue;
567            }
568            let stem = match path.file_stem().and_then(|s| s.to_str()) {
569                Some(s) => s,
570                None => continue,
571            };
572            let key = match u64::from_str_radix(stem, 16) {
573                Ok(k) => k,
574                Err(_) => continue,
575            };
576            let bytes = match std::fs::read(&path) {
577                Ok(b) => b,
578                Err(_) => continue,
579            };
580            // Decrypt if cache DEK is present.
581            let plaintext = if let Some(dek) = &self.cache_dek {
582                match self.decrypt_cache(&bytes, dek) {
583                    Some(p) => p,
584                    None => {
585                        let _ = std::fs::remove_file(&path);
586                        continue;
587                    }
588                }
589            } else {
590                bytes
591            };
592            match bincode::deserialize::<SerializedEntry>(&plaintext) {
593                Ok(serialized) => {
594                    if let Some(entry) = serialized.into_entry() {
595                        self.bytes = self.bytes.saturating_add(entry.data.approx_bytes());
596                        self.entries.insert(key, entry);
597                        self.order.push_back(key);
598                    } else {
599                        let _ = std::fs::remove_file(&path);
600                    }
601                }
602                Err(_) => {
603                    let _ = std::fs::remove_file(&path);
604                }
605            }
606        }
607        self.evict();
608    }
609
610    fn set_max_bytes(&mut self, max_bytes: u64) {
611        self.max_bytes = max_bytes;
612        self.evict();
613    }
614
615    /// Promote `key` to most-recently-used position (back of the deque).
616    fn touch(&mut self, key: u64) {
617        self.order.retain(|k| *k != key);
618        self.order.push_back(key);
619    }
620
621    fn get_rows(&mut self, key: u64) -> Option<Arc<Vec<Row>>> {
622        let res = self.entries.get(&key).and_then(|e| match &e.data {
623            CachedData::Rows(r) => Some(r.clone()),
624            CachedData::Columns(_) => None,
625        });
626        if res.is_some() {
627            self.touch(key);
628            return res;
629        }
630        // Memory miss → try the persistent tier (b).
631        if let Some(entry) = self.load_from_disk(key) {
632            let res = match &entry.data {
633                CachedData::Rows(r) => Some(r.clone()),
634                CachedData::Columns(_) => None,
635            };
636            if res.is_some() {
637                let approx = entry.data.approx_bytes();
638                self.bytes = self.bytes.saturating_add(approx);
639                self.entries.insert(key, entry);
640                self.order.push_back(key);
641                self.evict();
642                return res;
643            }
644        }
645        None
646    }
647
648    fn get_columns(&mut self, key: u64) -> Option<Arc<Vec<(u16, columnar::NativeColumn)>>> {
649        let res = self.entries.get(&key).and_then(|e| match &e.data {
650            CachedData::Columns(c) => Some(c.clone()),
651            CachedData::Rows(_) => None,
652        });
653        if res.is_some() {
654            self.touch(key);
655            return res;
656        }
657        // Memory miss → try the persistent tier (b).
658        if let Some(entry) = self.load_from_disk(key) {
659            let res = match &entry.data {
660                CachedData::Columns(c) => Some(c.clone()),
661                CachedData::Rows(_) => None,
662            };
663            if res.is_some() {
664                let approx = entry.data.approx_bytes();
665                self.bytes = self.bytes.saturating_add(approx);
666                self.entries.insert(key, entry);
667                self.order.push_back(key);
668                self.evict();
669                return res;
670            }
671        }
672        None
673    }
674
675    fn insert(&mut self, key: u64, entry: CachedEntry) {
676        let approx = entry.data.approx_bytes();
677        if self.entries.remove(&key).is_some() {
678            self.order.retain(|k| *k != key);
679            self.bytes = self.entries.values().map(|e| e.data.approx_bytes()).sum();
680        }
681        // Write to the persistent tier (b) before memory insert.
682        self.store_to_disk(key, &entry);
683        self.bytes = self.bytes.saturating_add(approx);
684        self.entries.insert(key, entry);
685        self.order.push_back(key);
686        self.evict();
687    }
688
689    /// Fine-grained invalidation (hardening (c)). Drop only entries that are
690    /// actually affected by the committed mutations:
691    /// - **Delete path**: if `delete_rids` intersects an entry's footprint, a
692    ///   survivor was deleted → stale. If the footprint is empty (multi-run or
693    ///   non-empty memtable — we couldn't resolve it), **any** delete
694    ///   conservatively invalidates the entry (correctness over precision).
695    /// - **Insert path**: if `put_cols` intersects an entry's `condition_cols`,
696    ///   a newly-inserted row might match the query → conservatively stale.
697    fn invalidate(
698        &mut self,
699        delete_rids: &roaring::RoaringBitmap,
700        put_cols: &std::collections::HashSet<u16>,
701    ) {
702        if self.entries.is_empty() {
703            return;
704        }
705        let has_deletes = !delete_rids.is_empty();
706        let to_remove: std::collections::HashSet<u64> = self
707            .entries
708            .iter()
709            .filter(|(_, e)| {
710                let delete_hit = if e.footprint.is_empty() {
711                    has_deletes
712                } else {
713                    e.footprint.intersection_len(delete_rids) > 0
714                };
715                let col_hit = e.condition_cols.iter().any(|c| put_cols.contains(c));
716                delete_hit || col_hit
717            })
718            .map(|(&k, _)| k)
719            .collect();
720        for key in &to_remove {
721            if let Some(e) = self.entries.remove(key) {
722                self.bytes = self.bytes.saturating_sub(e.data.approx_bytes());
723            }
724            self.remove_from_disk(*key);
725        }
726        if !to_remove.is_empty() {
727            self.order.retain(|k| !to_remove.contains(k));
728        }
729    }
730
731    fn clear(&mut self) {
732        // Delete all persistent files (b).
733        if let Some(dir) = &self.dir {
734            if let Ok(entries) = std::fs::read_dir(dir) {
735                for entry in entries.flatten() {
736                    let path = entry.path();
737                    if path.extension().and_then(|e| e.to_str()) == Some("bin") {
738                        let _ = std::fs::remove_file(&path);
739                    }
740                }
741            }
742        }
743        self.entries.clear();
744        self.order.clear();
745        self.bytes = 0;
746    }
747
748    fn evict(&mut self) {
749        while self.bytes > self.max_bytes {
750            let Some(k) = self.order.pop_front() else {
751                break;
752            };
753            if let Some(e) = self.entries.remove(&k) {
754                self.bytes = self.bytes.saturating_sub(e.data.approx_bytes());
755                // Also delete the disk file (hardening (b)): an evicted entry's
756                // disk file must not survive, or invalidate() — which only scans
757                // in-memory entries — would miss it and allow a stale disk hit.
758                self.remove_from_disk(k);
759            }
760        }
761    }
762}
763
764/// Derive per-column indexable-encryption keys (Phase 10.2) for every
765/// ENCRYPTED_INDEXABLE column from the KEK. Scheme is `OPE_RANGE` if the column
766/// has a `LearnedRange` index, else `HMAC_EQ` (equality). Keys are derived
767/// deterministically from the KEK so tokens are stable across runs. Empty when
768/// the table is plaintext (no KEK).
769/// Derive WAL and cache DEKs from the KEK (None when no encryption).
770type DekaOpt = Option<Zeroizing<[u8; DEK_LEN]>>;
771
772fn derive_subkeys(kek: Option<&Kek>, _table_id: u64) -> (DekaOpt, DekaOpt) {
773    let _ = kek;
774    #[cfg(feature = "encryption")]
775    {
776        if let Some(k) = kek {
777            return (
778                Some(k.derive_table_wal_key(_table_id)),
779                Some(k.derive_cache_key()),
780            );
781        }
782    }
783    (None, None)
784}
785
786/// Create a boxed cipher from a DEK (encryption feature only).
787#[cfg(feature = "encryption")]
788fn make_cipher(dek: &Zeroizing<[u8; DEK_LEN]>) -> Box<dyn crate::encryption::Cipher> {
789    Box::new(crate::encryption::AesCipher::new(&dek[..]).expect("DEK is 32 bytes"))
790}
791
792#[cfg(not(feature = "encryption"))]
793fn make_cipher(_dek: &Zeroizing<[u8; DEK_LEN]>) -> Box<dyn crate::encryption::Cipher> {
794    Box::new(crate::encryption::PlaintextCipher)
795}
796
797fn build_column_keys(kek: Option<&Kek>, schema: &Schema) -> HashMap<u16, ([u8; 32], u8)> {
798    let Some(kek) = kek else {
799        return HashMap::new();
800    };
801    #[cfg(feature = "encryption")]
802    {
803        use crate::encryption::{SCHEME_HMAC_EQ, SCHEME_OPE_RANGE};
804        schema
805            .columns
806            .iter()
807            .filter(|c| c.flags.contains(ColumnFlags::ENCRYPTED_INDEXABLE))
808            .map(|c| {
809                let scheme = if schema
810                    .indexes
811                    .iter()
812                    .any(|i| i.column_id == c.id && i.kind == IndexKind::LearnedRange)
813                {
814                    SCHEME_OPE_RANGE
815                } else {
816                    SCHEME_HMAC_EQ
817                };
818                let key: [u8; 32] = *kek.derive_column_key(c.id);
819                (c.id, (key, scheme))
820            })
821            .collect()
822    }
823    #[cfg(not(feature = "encryption"))]
824    {
825        let _ = (kek, schema);
826        HashMap::new()
827    }
828}
829
830/// Shared services injected into every `Table` owned by a `Database`: one epoch
831/// authority (single commit clock), one raw-page cache, one decoded-page cache,
832/// one snapshot-retention registry, and the DB-wide KEK. A directly-opened
833/// single table builds a private `SharedCtx` of its own.
834pub(crate) struct SharedCtx {
835    pub epoch: Arc<EpochAuthority>,
836    pub page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
837    pub decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
838    pub snapshots: Arc<crate::retention::SnapshotRegistry>,
839    pub kek: Option<Arc<Kek>>,
840    /// Serializes the commit critical section across all tables sharing this
841    /// context so the dual-counter's in-order-publish invariant holds: the
842    /// assigned ticket is reserved, the WAL fsynced, the manifest persisted,
843    /// and `visible` published as one atomic unit. P3 replaces this with the
844    /// bounded validate-first sequencer + group commit (overlapping fsync).
845    pub commit_lock: Arc<parking_lot::Mutex<()>>,
846    /// B1: when `Some`, the table is mounted in a `Database` and routes every
847    /// write through the one shared WAL (no private `_wal/` dir is created).
848    /// `None` for a directly-opened standalone table, which keeps a private WAL.
849    pub shared: Option<SharedWalCtx>,
850    /// The table's catalog name (for auth enforcement). `None` on standalone
851    /// direct-open tables that have no catalog entry.
852    pub table_name: Option<String>,
853    /// Auth checker for per-operation enforcement. `None` on credentialless
854    /// databases; cloned from the `Database`'s `auth_state` wrapper.
855    pub auth: Option<Arc<dyn crate::auth_state::TableAuthChecker>>,
856    /// Whether logical writes must be rejected for a replica database.
857    pub read_only: bool,
858}
859
860/// Handles a mounted table needs to write to the database's single shared WAL
861/// (B1): the WAL itself, the group-commit coordinator + poison flag (so a
862/// single-table commit honors the same durability/§9.3e semantics as a cross-
863/// table txn), and the shared txn-id allocator (so auto-commit ids never alias
864/// cross-table ones in the merged log).
865#[derive(Clone)]
866pub(crate) struct SharedWalCtx {
867    pub wal: Arc<parking_lot::Mutex<SharedWal>>,
868    pub group: Arc<GroupCommit>,
869    pub poisoned: Arc<AtomicBool>,
870    pub txn_ids: Arc<parking_lot::Mutex<u64>>,
871    pub change_wake: tokio::sync::broadcast::Sender<()>,
872}
873
874/// Where a table's WAL records go. A standalone table owns a `Private` WAL; a
875/// `Database`-mounted table writes to the one `Shared` WAL (B1).
876enum WalSink {
877    Private(Wal),
878    Shared(SharedWalCtx),
879}
880
881impl SharedCtx {
882    /// Build a fresh private (standalone) context. `cache_dir = Some(_)` enables
883    /// on-disk page cache persistence (single-table direct open); `None` keeps
884    /// it in-memory (shared across tables in a `Database`).
885    pub(crate) fn new(kek: Option<Arc<Kek>>, cache_dir: Option<PathBuf>) -> Self {
886        // §5.8: shard the caches to reduce lock contention under parallel
887        // rayon scans. The persistent (single-table) path uses 1 shard (no
888        // contention) so its on-disk load/spill stays simple.
889        let n_shards = if cache_dir.is_some() {
890            1
891        } else {
892            crate::cache::CACHE_SHARDS
893        };
894        let per_shard = PAGE_CACHE_CAPACITY / n_shards as u64;
895        let page_cache = if let Some(d) = cache_dir {
896            Arc::new(crate::cache::Sharded::new(1, || {
897                crate::cache::PageCache::new(PAGE_CACHE_CAPACITY).with_persistence(d.clone())
898            }))
899        } else {
900            Arc::new(crate::cache::Sharded::new(n_shards, || {
901                crate::cache::PageCache::new(per_shard)
902            }))
903        };
904        let decoded_per_shard = DECODED_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64;
905        let decoded_cache = Arc::new(crate::cache::Sharded::new(
906            crate::cache::CACHE_SHARDS,
907            || crate::cache::DecodedPageCache::new(decoded_per_shard),
908        ));
909        Self {
910            epoch: Arc::new(EpochAuthority::new(0)),
911            page_cache,
912            decoded_cache,
913            snapshots: Arc::new(crate::retention::SnapshotRegistry::new()),
914            kek,
915            commit_lock: Arc::new(parking_lot::Mutex::new(())),
916            shared: None,
917            table_name: None,
918            auth: None,
919            read_only: false,
920        }
921    }
922}
923
924/// §5.5: estimated per-condition resolution cost for cheap-first conjunction
925/// ordering. Lower is resolved first so a selective O(1) index lookup can
926/// short-circuit an expensive range/FM/vector scan.
927fn condition_cost_rank(c: &crate::query::Condition) -> u8 {
928    use crate::query::Condition;
929    match c {
930        // O(1) index lookups — resolve first.
931        Condition::Pk(_)
932        | Condition::BitmapEq { .. }
933        | Condition::BitmapIn { .. }
934        | Condition::BytesPrefix { .. }
935        | Condition::IsNull { .. }
936        | Condition::IsNotNull { .. } => 0,
937        // Page-pruned scan or LSH candidate lookup.
938        Condition::Range { .. } | Condition::RangeF64 { .. } | Condition::MinHashSimilar { .. } => {
939            1
940        }
941        // FM locate / vector scans — most expensive, resolve last.
942        Condition::FmContains { .. }
943        | Condition::FmContainsAll { .. }
944        | Condition::Ann { .. }
945        | Condition::SparseMatch { .. } => 2,
946    }
947}
948
949impl Table {
950    pub fn create(dir: impl AsRef<Path>, schema: Schema, table_id: u64) -> Result<Self> {
951        let dir = dir.as_ref().to_path_buf();
952        let ctx = SharedCtx::new(None, Some(dir.join(CACHE_DIR)));
953        Self::create_in(&dir, schema, table_id, ctx)
954    }
955
956    /// Create a new encrypted table, deriving the table Key-Encryption Key
957    /// (KEK) from `passphrase` via Argon2id + HKDF (§7). A fresh random salt is
958    /// generated and persisted under `_meta/keys` so the same passphrase
959    /// recreates the KEK on reopen. Each run gets its own wrapped DEK.
960    ///
961    /// **Scope (§7):** encryption is *page-granular* — only sorted-run page
962    /// payloads are encrypted. The live WAL (`_wal/`) holds rows as plaintext
963    /// between `put` and `flush`; call `flush()` (which rotates the WAL) before
964    /// treating sensitive data as fully at-rest-protected. Full WAL encryption
965    /// is deferred.
966    #[cfg(feature = "encryption")]
967    pub fn create_encrypted(
968        dir: impl AsRef<Path>,
969        schema: Schema,
970        table_id: u64,
971        passphrase: &str,
972    ) -> Result<Self> {
973        let dir = dir.as_ref();
974        std::fs::create_dir_all(dir.join(META_DIR))?;
975        let salt = crate::encryption::random_salt();
976        std::fs::write(dir.join(META_DIR).join(KEYS_FILENAME), salt)?;
977        let kek: Arc<Kek> = Arc::new(Kek::derive(passphrase, &salt)?);
978        let ctx = SharedCtx::new(Some(kek), Some(dir.to_path_buf().join(CACHE_DIR)));
979        Self::create_in(dir, schema, table_id, ctx)
980    }
981
982    /// Create a new encrypted table using a raw key (e.g. from a key file)
983    /// instead of a passphrase. Skips Argon2id — the key must already be
984    /// high-entropy (>= 32 bytes of random data). ~0.1ms vs ~50ms for the
985    /// passphrase path.
986    #[cfg(feature = "encryption")]
987    pub fn create_with_key(
988        dir: impl AsRef<Path>,
989        schema: Schema,
990        table_id: u64,
991        key: &[u8],
992    ) -> Result<Self> {
993        let dir = dir.as_ref();
994        std::fs::create_dir_all(dir.join(META_DIR))?;
995        let salt = crate::encryption::random_salt();
996        std::fs::write(dir.join(META_DIR).join(KEYS_FILENAME), salt)?;
997        let kek: Arc<Kek> = Arc::new(Kek::from_raw_key(key, &salt)?);
998        let ctx = SharedCtx::new(Some(kek), Some(dir.to_path_buf().join(CACHE_DIR)));
999        Self::create_in(dir, schema, table_id, ctx)
1000    }
1001
1002    /// Open an existing encrypted table using a raw key.
1003    #[cfg(feature = "encryption")]
1004    pub fn open_with_key(dir: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
1005        let dir = dir.as_ref();
1006        let salt_path = dir.join(META_DIR).join(KEYS_FILENAME);
1007        let salt_bytes = std::fs::read(&salt_path).map_err(|e| {
1008            MongrelError::NotFound(format!(
1009                "encryption salt file {:?}: {e} (table not encrypted, or corrupted)",
1010                salt_path
1011            ))
1012        })?;
1013        if salt_bytes.len() != crate::encryption::SALT_LEN {
1014            return Err(MongrelError::InvalidArgument(format!(
1015                "salt file is {} bytes, expected {}",
1016                salt_bytes.len(),
1017                crate::encryption::SALT_LEN
1018            )));
1019        }
1020        let mut salt = [0u8; crate::encryption::SALT_LEN];
1021        salt.copy_from_slice(&salt_bytes);
1022        let kek = Arc::new(Kek::from_raw_key(key, &salt)?);
1023        let ctx = SharedCtx::new(Some(kek), Some(dir.to_path_buf().join(CACHE_DIR)));
1024        Self::open_in(dir, ctx)
1025    }
1026
1027    pub(crate) fn create_in(
1028        dir: impl AsRef<Path>,
1029        schema: Schema,
1030        table_id: u64,
1031        ctx: SharedCtx,
1032    ) -> Result<Self> {
1033        schema.validate_auto_increment()?;
1034        schema.validate_defaults()?;
1035        schema.validate_ai()?;
1036        for index in &schema.indexes {
1037            index.validate_options()?;
1038        }
1039        let dir = dir.as_ref().to_path_buf();
1040        std::fs::create_dir_all(dir.join(RUNS_DIR))?;
1041        write_schema(&dir, &schema)?;
1042        let (wal_dek, cache_dek) = derive_subkeys(ctx.kek.as_deref(), table_id);
1043        // B1: a mounted table routes writes through the shared WAL and never
1044        // creates its own `_wal/` dir. A standalone table owns a private WAL.
1045        let (wal, current_txn_id) = match ctx.shared.clone() {
1046            Some(s) => (WalSink::Shared(s), 0),
1047            None => {
1048                std::fs::create_dir_all(dir.join(WAL_DIR))?;
1049                let mut w = if let Some(ref dk) = wal_dek {
1050                    Wal::create_with_cipher(
1051                        dir.join(WAL_DIR).join("seg-000000.wal"),
1052                        Epoch(0),
1053                        Some(make_cipher(dk)),
1054                        0,
1055                    )?
1056                } else {
1057                    Wal::create(dir.join(WAL_DIR).join("seg-000000.wal"), Epoch(0))?
1058                };
1059                w.set_sync_byte_threshold(DEFAULT_SYNC_BYTE_THRESHOLD);
1060                (WalSink::Private(w), 1)
1061            }
1062        };
1063        let mut manifest = Manifest::new(table_id, schema.schema_id);
1064        // Seal the create-time manifest with the meta DEK so an encrypted table
1065        // reopens even if no write/flush ever re-persists it (otherwise the
1066        // reopen's encrypted manifest read fails to authenticate a plaintext
1067        // blob — see `manifest_meta_dek`).
1068        let manifest_meta_dek = crate::encryption::meta_dek_for(ctx.kek.as_deref());
1069        manifest::write_atomic(&dir, &mut manifest, manifest_meta_dek.as_ref())?;
1070        let (bitmap, ann, fm, sparse, minhash) = empty_indexes(&schema);
1071        let column_keys = build_column_keys(ctx.kek.as_deref(), &schema);
1072        let auto_inc = resolve_auto_inc(&schema);
1073        let rcache_dir = dir.join(RCACHE_DIR);
1074        Ok(Self {
1075            dir,
1076            table_id,
1077            name: ctx.table_name.unwrap_or_default(),
1078            auth: ctx.auth,
1079            read_only: ctx.read_only,
1080            wal,
1081            memtable: Memtable::new(),
1082            mutable_run: MutableRun::new(),
1083            mutable_run_spill_bytes: DEFAULT_MUTABLE_RUN_SPILL_BYTES,
1084            compaction_zstd_level: 3,
1085            allocator: RowIdAllocator::new(0),
1086            epoch: ctx.epoch,
1087            persisted_epoch: 0,
1088            data_generation: 0,
1089            schema,
1090            hot: HotIndex::new(),
1091            kek: ctx.kek,
1092            column_keys,
1093            run_refs: Vec::new(),
1094            retiring: Vec::new(),
1095            next_run_id: 1,
1096            sync_byte_threshold: DEFAULT_SYNC_BYTE_THRESHOLD,
1097            current_txn_id,
1098            bitmap,
1099            ann,
1100            fm,
1101            sparse,
1102            minhash,
1103            learned_range: HashMap::new(),
1104            pk_by_row: HashMap::new(),
1105            pinned: BTreeMap::new(),
1106            live_count: 0,
1107            reservoir: crate::reservoir::Reservoir::default(),
1108            reservoir_complete: true,
1109            had_deletes: false,
1110            agg_cache: HashMap::new(),
1111            global_idx_epoch: 0,
1112            indexes_complete: true,
1113            index_build_policy: IndexBuildPolicy::default(),
1114            pk_by_row_complete: false,
1115            flushed_epoch: 0,
1116            page_cache: ctx.page_cache,
1117            decoded_cache: ctx.decoded_cache,
1118            verified_runs: Arc::new(parking_lot::Mutex::new(std::collections::HashSet::new())),
1119            snapshots: ctx.snapshots,
1120            commit_lock: ctx.commit_lock,
1121            result_cache: Arc::new(parking_lot::Mutex::new(
1122                ResultCache::new()
1123                    .with_dir(rcache_dir)
1124                    .with_cache_dek(cache_dek.clone()),
1125            )),
1126            pending_delete_rids: roaring::RoaringBitmap::new(),
1127            pending_put_cols: std::collections::HashSet::new(),
1128            pending_rows: Vec::new(),
1129            pending_rows_auto_inc: Vec::new(),
1130            pending_dels: Vec::new(),
1131            pending_truncate: None,
1132            wal_dek,
1133            auto_inc,
1134            ttl: None,
1135        })
1136    }
1137
1138    /// Open an existing table: load the manifest, replay the active WAL segment
1139    /// into the memtable, and rebuild the HOT + secondary indexes from the runs
1140    /// and replayed rows.
1141    pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
1142        let dir = dir.as_ref();
1143        let ctx = SharedCtx::new(None, Some(dir.to_path_buf().join(CACHE_DIR)));
1144        Self::open_in(dir, ctx)
1145    }
1146
1147    /// Open an existing encrypted table. `passphrase` must match the one used at
1148    /// create time (combined with the persisted salt to re-derive the KEK).
1149    #[cfg(feature = "encryption")]
1150    pub fn open_encrypted(dir: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
1151        let dir = dir.as_ref();
1152        let salt_path = dir.join(META_DIR).join(KEYS_FILENAME);
1153        let salt_bytes = std::fs::read(&salt_path).map_err(|e| {
1154            MongrelError::NotFound(format!(
1155                "encryption salt file {:?}: {e} (table not encrypted, or corrupted)",
1156                salt_path
1157            ))
1158        })?;
1159        let salt_len = crate::encryption::SALT_LEN;
1160        if salt_bytes.len() != salt_len {
1161            return Err(MongrelError::InvalidArgument(format!(
1162                "encryption salt is {} bytes, expected {salt_len}",
1163                salt_bytes.len()
1164            )));
1165        }
1166        let mut salt = [0u8; 16];
1167        salt.copy_from_slice(&salt_bytes);
1168        let kek: Arc<Kek> = Arc::new(Kek::derive(passphrase, &salt)?);
1169        let ctx = SharedCtx::new(Some(kek), Some(dir.to_path_buf().join(CACHE_DIR)));
1170        let t = Self::open_in(dir, ctx)?;
1171        Ok(t)
1172    }
1173
1174    pub(crate) fn open_in(dir: impl AsRef<Path>, ctx: SharedCtx) -> Result<Self> {
1175        let dir = dir.as_ref().to_path_buf();
1176        let manifest_meta_dek = crate::encryption::meta_dek_for(ctx.kek.as_deref());
1177        let manifest = manifest::read(&dir, manifest_meta_dek.as_ref())?;
1178        let schema: Schema = read_schema(&dir)?;
1179        schema.validate_ai()?;
1180        for index in &schema.indexes {
1181            index.validate_options()?;
1182        }
1183        let replay_epoch = Epoch(manifest.current_epoch);
1184        let (wal_dek, cache_dek) = derive_subkeys(ctx.kek.as_deref(), manifest.table_id);
1185        // B1: a mounted table has no private WAL — its committed records live in
1186        // the shared WAL and are replayed by `Database::recover_shared_wal`. A
1187        // standalone table replays + reopens its own `_wal/` segment here.
1188        let (wal, replayed, current_txn_id) = match ctx.shared.clone() {
1189            Some(s) => (WalSink::Shared(s), Vec::new(), 0),
1190            None => {
1191                let active = latest_wal_segment(&dir.join(WAL_DIR))?;
1192                // Replay BEFORE truncating: `Wal::create` would erase the segment.
1193                let replayed = match &active {
1194                    Some(path) => {
1195                        let cipher = wal_dek.as_ref().map(|dk| make_cipher(dk));
1196                        crate::wal::replay_with_cipher(path, cipher)?
1197                    }
1198                    None => Vec::new(),
1199                };
1200                let mut w = match &active {
1201                    Some(path) => Wal::create_with_cipher(
1202                        path,
1203                        replay_epoch,
1204                        wal_dek.as_ref().map(|dk| make_cipher(dk)),
1205                        0,
1206                    )?,
1207                    None => Wal::create_with_cipher(
1208                        dir.join(WAL_DIR).join("seg-000000.wal"),
1209                        replay_epoch,
1210                        wal_dek.as_ref().map(|dk| make_cipher(dk)),
1211                        0,
1212                    )?,
1213                };
1214                w.set_sync_byte_threshold(DEFAULT_SYNC_BYTE_THRESHOLD);
1215                (WalSink::Private(w), replayed, 1)
1216            }
1217        };
1218
1219        let mut memtable = Memtable::new();
1220        let mut allocator = RowIdAllocator::new(manifest.next_row_id);
1221        let persisted_epoch = manifest.current_epoch;
1222        // Seed the auto-increment counter from the manifest. `auto_inc_next == 0`
1223        // means unseeded (fresh table, or a legacy manifest migrated forward) —
1224        // the first allocation scans `max(PK)` to avoid colliding with existing
1225        // rows. WAL replay (below) and `recover_apply` additionally bump `next`
1226        // past replayed ids without marking it seeded, so the scan still covers
1227        // any rows that were already flushed to sorted runs.
1228        let mut auto_inc = resolve_auto_inc(&schema).map(|mut s| {
1229            s.next = manifest.auto_inc_next;
1230            s.seeded = manifest.auto_inc_next > 0;
1231            s
1232        });
1233
1234        // 1. Replay is two-phase and TxnCommit-gated: data records (Put/Delete)
1235        //    are staged per `txn_id` and only applied when a durable
1236        //    `TxnCommit{epoch}` for that txn is seen. Uncommitted / aborted /
1237        //    torn-tail txns are discarded. Indexing happens AFTER loading any
1238        //    checkpoint / run data (below) so the newer replayed versions
1239        //    overwrite the older run versions in the HOT index.
1240        let mut staged_puts: HashMap<u64, Vec<Row>> = HashMap::new();
1241        let mut staged_deletes: HashMap<u64, Vec<RowId>> = HashMap::new();
1242        let mut replayed_puts: std::collections::BTreeMap<Epoch, Vec<Row>> =
1243            std::collections::BTreeMap::new();
1244        let mut replayed_deletes: Vec<(RowId, Epoch)> = Vec::new();
1245        let mut saw_delete = false;
1246        for record in replayed {
1247            let txn_id = record.txn_id;
1248            match record.op {
1249                Op::Put { rows, .. } => {
1250                    let rows: Vec<Row> = bincode::deserialize(&rows)?;
1251                    for row in &rows {
1252                        allocator.advance_to(row.row_id);
1253                        if let Some(ai) = auto_inc.as_mut() {
1254                            if let Some(Value::Int64(n)) = row.columns.get(&ai.column_id) {
1255                                if *n + 1 > ai.next {
1256                                    ai.next = *n + 1;
1257                                }
1258                            }
1259                        }
1260                    }
1261                    staged_puts.entry(txn_id).or_default().extend(rows);
1262                }
1263                Op::Delete { row_ids, .. } => {
1264                    staged_deletes.entry(txn_id).or_default().extend(row_ids);
1265                }
1266                Op::TxnCommit { epoch, .. } => {
1267                    let commit_epoch = Epoch(epoch);
1268                    if let Some(puts) = staged_puts.remove(&txn_id) {
1269                        if commit_epoch.0 > manifest.flushed_epoch {
1270                            for row in &puts {
1271                                memtable.upsert(row.clone());
1272                            }
1273                            replayed_puts.entry(commit_epoch).or_default().extend(puts);
1274                        }
1275                    }
1276                    if let Some(dels) = staged_deletes.remove(&txn_id) {
1277                        saw_delete = true;
1278                        if commit_epoch.0 > manifest.flushed_epoch {
1279                            for rid in dels {
1280                                memtable.tombstone(rid, commit_epoch);
1281                                replayed_deletes.push((rid, commit_epoch));
1282                            }
1283                        }
1284                    }
1285                }
1286                Op::TxnAbort => {
1287                    staged_puts.remove(&txn_id);
1288                    staged_deletes.remove(&txn_id);
1289                }
1290                Op::TruncateTable { .. }
1291                | Op::ExternalTableState { .. }
1292                | Op::Flush { .. }
1293                | Op::Ddl(_)
1294                | Op::BeforeImage { .. }
1295                | Op::CommitTimestamp { .. } => {}
1296            }
1297        }
1298
1299        let rcache_dir = dir.join(RCACHE_DIR);
1300        let column_keys = build_column_keys(ctx.kek.as_deref(), &schema);
1301        let mut db = Self {
1302            dir,
1303            table_id: manifest.table_id,
1304            name: ctx.table_name.unwrap_or_default(),
1305            auth: ctx.auth,
1306            read_only: ctx.read_only,
1307            wal,
1308            memtable,
1309            mutable_run: MutableRun::new(),
1310            mutable_run_spill_bytes: DEFAULT_MUTABLE_RUN_SPILL_BYTES,
1311            compaction_zstd_level: 3,
1312            allocator,
1313            epoch: ctx.epoch,
1314            persisted_epoch,
1315            data_generation: persisted_epoch,
1316            schema,
1317            hot: HotIndex::new(),
1318            kek: ctx.kek,
1319            column_keys,
1320            run_refs: manifest.runs.clone(),
1321            retiring: manifest.retiring.clone(),
1322            next_run_id: manifest
1323                .runs
1324                .iter()
1325                .map(|r| r.run_id as u64 + 1)
1326                .max()
1327                .unwrap_or(1),
1328            sync_byte_threshold: DEFAULT_SYNC_BYTE_THRESHOLD,
1329            current_txn_id,
1330            bitmap: HashMap::new(),
1331            ann: HashMap::new(),
1332            fm: HashMap::new(),
1333            sparse: HashMap::new(),
1334            minhash: HashMap::new(),
1335            learned_range: HashMap::new(),
1336            pk_by_row: HashMap::new(),
1337            pinned: BTreeMap::new(),
1338            live_count: manifest.live_count,
1339            reservoir: crate::reservoir::Reservoir::default(),
1340            reservoir_complete: false,
1341            had_deletes: saw_delete
1342                || manifest.runs.iter().map(|run| run.row_count).sum::<u64>()
1343                    != manifest.live_count,
1344            agg_cache: HashMap::new(),
1345            global_idx_epoch: manifest.global_idx_epoch,
1346            indexes_complete: true,
1347            index_build_policy: IndexBuildPolicy::default(),
1348            pk_by_row_complete: false,
1349            flushed_epoch: manifest.flushed_epoch,
1350            page_cache: ctx.page_cache,
1351            decoded_cache: ctx.decoded_cache,
1352            verified_runs: Arc::new(parking_lot::Mutex::new(std::collections::HashSet::new())),
1353            snapshots: ctx.snapshots,
1354            commit_lock: ctx.commit_lock,
1355            result_cache: Arc::new(parking_lot::Mutex::new(
1356                ResultCache::new()
1357                    .with_dir(rcache_dir)
1358                    .with_cache_dek(cache_dek.clone()),
1359            )),
1360            pending_delete_rids: roaring::RoaringBitmap::new(),
1361            pending_put_cols: std::collections::HashSet::new(),
1362            pending_rows: Vec::new(),
1363            pending_rows_auto_inc: Vec::new(),
1364            pending_dels: Vec::new(),
1365            pending_truncate: None,
1366            wal_dek,
1367            auto_inc,
1368            ttl: manifest.ttl,
1369        };
1370
1371        // Advance the (possibly shared) epoch authority to this table's manifest
1372        // epoch so rebuild/index reads below observe the recovered watermark.
1373        db.epoch.advance_recovered(Epoch(db.persisted_epoch));
1374
1375        // 2. Fast path: load the persisted global-index checkpoint (Phase 9.1).
1376        //    Valid only when its embedded epoch matches the manifest-endorsed
1377        //    `global_idx_epoch` and every run was created at or before it, so the
1378        //    checkpoint covers all run data. Otherwise rebuild from the runs.
1379        let checkpoint = global_idx::read(&db.dir, db.idx_dek().as_deref())?;
1380        let checkpoint_valid = checkpoint.as_ref().is_some_and(|c| {
1381            c.epoch_built == manifest.global_idx_epoch
1382                && manifest.global_idx_epoch > 0
1383                && manifest
1384                    .runs
1385                    .iter()
1386                    .all(|r| r.epoch_created <= manifest.global_idx_epoch)
1387        });
1388        if let Some(loaded) = checkpoint {
1389            if checkpoint_valid {
1390                db.hot = loaded.hot;
1391                db.bitmap = loaded.bitmap;
1392                db.ann = loaded.ann;
1393                db.fm = loaded.fm;
1394                db.sparse = loaded.sparse;
1395                db.minhash = loaded.minhash;
1396                db.learned_range = loaded.learned_range;
1397                // `pk_by_row` stays lazy (`pk_by_row_complete == false`): the
1398                // first delete rebuilds it from the loaded HOT.
1399            }
1400        }
1401        if !checkpoint_valid {
1402            let (bitmap, ann, fm, sparse, minhash) = empty_indexes(&db.schema);
1403            db.bitmap = bitmap;
1404            db.ann = ann;
1405            db.fm = fm;
1406            db.sparse = sparse;
1407            db.minhash = minhash;
1408            db.rebuild_indexes_from_runs()?;
1409            db.build_learned_ranges()?;
1410        }
1411
1412        // 3. Index the replayed WAL rows on top so updates overwrite. Within a
1413        //    single transaction epoch duplicate PKs are upserted: only the last
1414        //    winner is indexed, losers are tombstoned in the already-replayed
1415        //    memtable.
1416        for (epoch, group) in replayed_puts {
1417            let (losers, winner_pks) = db.partition_pk_winners(&group);
1418            for (key, &row_id) in &winner_pks {
1419                if let Some(old_rid) = db.hot.get(key) {
1420                    if old_rid != row_id {
1421                        db.tombstone_row(old_rid, epoch, false);
1422                    }
1423                }
1424            }
1425            for &loser_rid in &losers {
1426                db.tombstone_row(loser_rid, epoch, false);
1427            }
1428            for (key, row_id) in winner_pks {
1429                db.insert_hot_pk(key, row_id);
1430            }
1431            if db.schema.primary_key().is_none() {
1432                for r in &group {
1433                    db.hot.insert(r.row_id.0.to_be_bytes().to_vec(), r.row_id);
1434                }
1435            }
1436            for r in &group {
1437                if !losers.contains(&r.row_id) {
1438                    db.index_row(r);
1439                }
1440            }
1441        }
1442        // Apply replayed deletes after the puts: a delete targets a specific row
1443        // id and only removes the HOT entry if it still points to that id, so a
1444        // newer upsert for the same PK is not accidentally erased.
1445        for (rid, epoch) in &replayed_deletes {
1446            db.remove_hot_for_row(*rid, *epoch);
1447        }
1448
1449        // The reservoir stays lazy (`reservoir_complete == false`, set above):
1450        // rebuilding it means materializing every visible row, which no plain
1451        // open/insert/update/delete needs. `ensure_reservoir_complete` pays
1452        // that cost on the first `approx_aggregate` call instead.
1453        // Load the persistent result-cache tier (hardening (b)) so fine-grained
1454        // invalidation resumes across restart.
1455        db.result_cache.lock().load_persistent();
1456        Ok(db)
1457    }
1458
1459    /// Rebuild `reservoir` from every visible row if it isn't already
1460    /// complete (lazy — same pattern as [`Self::ensure_indexes_complete`]).
1461    /// Open and WAL replay leave the reservoir stale rather than eagerly
1462    /// paying a full-table scan; this pays it once, on the first
1463    /// [`Self::approx_aggregate`] call.
1464    fn ensure_reservoir_complete(&mut self) -> Result<()> {
1465        if self.reservoir_complete {
1466            return Ok(());
1467        }
1468        self.rebuild_reservoir()?;
1469        self.reservoir_complete = true;
1470        Ok(())
1471    }
1472
1473    /// Repopulate the reservoir sample from all visible rows (used on open so a
1474    /// reopened table has an analytics sample without further inserts).
1475    fn rebuild_reservoir(&mut self) -> Result<()> {
1476        let snap = self.snapshot();
1477        let rows = self.visible_rows(snap)?;
1478        self.reservoir.reset();
1479        for r in rows {
1480            self.reservoir.offer(r.row_id.0);
1481        }
1482        Ok(())
1483    }
1484
1485    pub(crate) fn rebuild_indexes_from_runs(&mut self) -> Result<()> {
1486        self.hot = HotIndex::new();
1487        self.pk_by_row.clear();
1488        let (bitmap, ann, fm, sparse, minhash) = empty_indexes(&self.schema);
1489        self.bitmap = bitmap;
1490        self.ann = ann;
1491        self.fm = fm;
1492        self.sparse = sparse;
1493        self.minhash = minhash;
1494        let snapshot = Epoch(u64::MAX);
1495        let ttl_now = unix_nanos_now();
1496        for rr in self.run_refs.clone() {
1497            let mut reader = self.open_reader(rr.run_id)?;
1498            for row in reader.visible_rows(snapshot)? {
1499                if self.row_expired_at(&row, ttl_now) {
1500                    continue;
1501                }
1502                let tok_row = self.tokenized_for_indexes(&row);
1503                index_into(
1504                    &self.schema,
1505                    &tok_row,
1506                    &mut self.hot,
1507                    &mut self.bitmap,
1508                    &mut self.ann,
1509                    &mut self.fm,
1510                    &mut self.sparse,
1511                    &mut self.minhash,
1512                );
1513            }
1514        }
1515        for row in self.mutable_run.visible_versions(snapshot) {
1516            if row.deleted {
1517                self.remove_hot_for_row(row.row_id, snapshot);
1518            } else if !self.row_expired_at(&row, ttl_now) {
1519                self.index_row(&row);
1520            }
1521        }
1522        for row in self.memtable.visible_versions(snapshot) {
1523            if row.deleted {
1524                self.remove_hot_for_row(row.row_id, snapshot);
1525            } else if !self.row_expired_at(&row, ttl_now) {
1526                self.index_row(&row);
1527            }
1528        }
1529        self.refresh_pk_by_row_from_hot();
1530        Ok(())
1531    }
1532
1533    fn refresh_pk_by_row_from_hot(&mut self) {
1534        self.pk_by_row_complete = true;
1535        if self.schema.primary_key().is_none() {
1536            self.pk_by_row.clear();
1537            return;
1538        }
1539        // `.collect()` drives `HashMap`'s bulk-build `FromIterator` (reserves
1540        // once from the exact-size iterator), instead of growing-and-rehashing
1541        // through a one-at-a-time `insert()` loop — same fix as
1542        // `HotIndex::from_entries`, same hot path (first delete after a put
1543        // streak rebuilds this from the full HOT index).
1544        self.pk_by_row = self
1545            .hot
1546            .entries()
1547            .into_iter()
1548            .map(|(key, row_id)| (row_id, key))
1549            .collect();
1550    }
1551
1552    fn insert_hot_pk(&mut self, key: Vec<u8>, row_id: RowId) {
1553        if self.schema.primary_key().is_some() {
1554            self.pk_by_row.insert(row_id, key.clone());
1555        }
1556        self.hot.insert(key, row_id);
1557    }
1558
1559    /// (Re)build per-column learned (PGM) range indexes for `LearnedRange`
1560    /// columns from the single sorted run. Serves `Condition::Range` sub-linearly
1561    /// on the fast path; no-op when there isn't exactly one run.
1562    pub(crate) fn build_learned_ranges(&mut self) -> Result<()> {
1563        self.learned_range.clear();
1564        if self.run_refs.len() != 1 {
1565            return Ok(());
1566        }
1567        let cols: Vec<(u16, usize)> = self
1568            .schema
1569            .indexes
1570            .iter()
1571            .filter(|i| i.kind == IndexKind::LearnedRange)
1572            .map(|i| {
1573                (
1574                    i.column_id,
1575                    i.options
1576                        .learned_range
1577                        .as_ref()
1578                        .map(|options| options.epsilon)
1579                        .unwrap_or(16),
1580                )
1581            })
1582            .collect();
1583        if cols.is_empty() {
1584            return Ok(());
1585        }
1586        let mut reader = self.open_reader(self.run_refs[0].run_id)?;
1587        let row_ids: Vec<u64> = match reader.column_native(crate::sorted_run::SYS_ROW_ID)? {
1588            columnar::NativeColumn::Int64 { data, .. } => data.iter().map(|x| *x as u64).collect(),
1589            _ => return Ok(()),
1590        };
1591        for (cid, epsilon) in cols {
1592            let ty = self
1593                .schema
1594                .columns
1595                .iter()
1596                .find(|c| c.id == cid)
1597                .map(|c| c.ty.clone())
1598                .unwrap_or(TypeId::Int64);
1599            match ty {
1600                TypeId::Int64 | TypeId::TimestampNanos | TypeId::Date32 => {
1601                    if let columnar::NativeColumn::Int64 { data, .. } = reader.column_native(cid)? {
1602                        let pairs: Vec<(i64, u64)> = data
1603                            .iter()
1604                            .zip(row_ids.iter())
1605                            .map(|(v, r)| (*v, *r))
1606                            .collect();
1607                        self.learned_range.insert(
1608                            cid,
1609                            ColumnLearnedRange::build_i64_with_epsilon(&pairs, epsilon),
1610                        );
1611                    }
1612                }
1613                TypeId::Float64 => {
1614                    if let columnar::NativeColumn::Float64 { data, .. } =
1615                        reader.column_native(cid)?
1616                    {
1617                        let pairs: Vec<(f64, u64)> = data
1618                            .iter()
1619                            .zip(row_ids.iter())
1620                            .map(|(v, r)| (*v, *r))
1621                            .collect();
1622                        self.learned_range.insert(
1623                            cid,
1624                            ColumnLearnedRange::build_f64_with_epsilon(&pairs, epsilon),
1625                        );
1626                    }
1627                }
1628                _ => {}
1629            }
1630        }
1631        Ok(())
1632    }
1633
1634    /// Phase 14.7: if the live indexes are known incomplete (after a bulk
1635    /// ingest that deferred index building — see [`IndexBuildPolicy`]),
1636    /// rebuild them from the runs now. Called lazily by `query` /
1637    /// `query_columns_native` / `flush`; public so external index consumers
1638    /// (SQL scans, joins, PK point lookups on a shared handle) can pay the
1639    /// one-time build before reading a `&self` index view.
1640    pub fn ensure_indexes_complete(&mut self) -> Result<()> {
1641        if self.indexes_complete {
1642            crate::trace::QueryTrace::record(|t| {
1643                t.index_rebuild = crate::trace::IndexRebuild::AlreadyComplete;
1644            });
1645            return Ok(());
1646        }
1647        crate::trace::QueryTrace::record(|t| {
1648            t.index_rebuild = crate::trace::IndexRebuild::Rebuilt;
1649        });
1650        self.rebuild_indexes_from_runs()?;
1651        self.build_learned_ranges()?;
1652        self.indexes_complete = true;
1653        let epoch = self.current_epoch();
1654        self.checkpoint_indexes(epoch);
1655        Ok(())
1656    }
1657
1658    fn pending_epoch(&self) -> Epoch {
1659        Epoch(self.epoch.visible().0 + 1)
1660    }
1661
1662    /// True when this table is mounted in a `Database` (writes route through the
1663    /// shared WAL).
1664    fn is_shared(&self) -> bool {
1665        matches!(self.wal, WalSink::Shared(_))
1666    }
1667
1668    /// Return the current auto-commit txn id, allocating a fresh one from the
1669    /// shared allocator on a mounted table when a new span starts (sentinel 0).
1670    /// A standalone table uses its private monotonic counter (never 0).
1671    fn ensure_txn_id(&mut self) -> u64 {
1672        if self.current_txn_id == 0 {
1673            let id = match &self.wal {
1674                WalSink::Shared(s) => {
1675                    let mut g = s.txn_ids.lock();
1676                    let v = *g;
1677                    *g = g.wrapping_add(1);
1678                    v
1679                }
1680                WalSink::Private(_) => 1,
1681            };
1682            self.current_txn_id = id;
1683        }
1684        self.current_txn_id
1685    }
1686
1687    /// Append a data record (`Put`/`Delete`) for the current auto-commit txn to
1688    /// whichever WAL backs this table.
1689    fn wal_append_data(&mut self, op: Op) -> Result<()> {
1690        self.ensure_writable()?;
1691        let txn_id = self.ensure_txn_id();
1692        let table_id = self.table_id;
1693        match &mut self.wal {
1694            WalSink::Private(w) => {
1695                w.append_txn(txn_id, op)?;
1696            }
1697            WalSink::Shared(s) => {
1698                s.wal.lock().append(txn_id, table_id, op)?;
1699            }
1700        }
1701        Ok(())
1702    }
1703
1704    fn ensure_writable(&self) -> Result<()> {
1705        if self.read_only {
1706            Err(MongrelError::ReadOnlyReplica)
1707        } else {
1708            Ok(())
1709        }
1710    }
1711
1712    /// Upsert a row. Allocates a [`RowId`], appends a (non-fsynced) WAL record,
1713    /// and updates the memtable + indexes. Returns the new row id. Durability
1714    /// arrives at the next [`Table::commit`] (or [`Table::flush`]).
1715    ///
1716    /// For an `AUTO_INCREMENT` primary key, omit the column (or pass
1717    /// Auth enforcement helpers. Each delegates to the optional
1718    /// [`TableAuthChecker`] (set at mount time from the `Database`'s auth
1719    /// state). On a credentialless database (`auth = None`), these are
1720    /// no-ops. The `name` field provides the table name for the permission
1721    /// check without needing a reference back to `Database`.
1722    fn require(&self, perm: crate::auth_state::RequiredPermission) -> Result<()> {
1723        match &self.auth {
1724            Some(checker) => checker.check(&self.name, perm),
1725            None => Ok(()),
1726        }
1727    }
1728    /// Check `Select` permission on this table. Public so that read entry
1729    /// points that don't go through `Table::query` (e.g. `MongrelProvider::scan`,
1730    /// `Table::count`) can enforce before reading. On a credentialless database
1731    /// this is a no-op.
1732    pub fn require_select(&self) -> Result<()> {
1733        self.require(crate::auth_state::RequiredPermission::Select)
1734    }
1735    fn require_insert(&self) -> Result<()> {
1736        self.require(crate::auth_state::RequiredPermission::Insert)
1737    }
1738    /// Currently unused on `Table` directly (updates go through `Transaction`),
1739    /// but kept for API completeness — the four `require_*` helpers mirror the
1740    /// four table-level permission kinds.
1741    #[allow(dead_code)]
1742    fn require_update(&self) -> Result<()> {
1743        self.require(crate::auth_state::RequiredPermission::Update)
1744    }
1745    fn require_delete(&self) -> Result<()> {
1746        self.require(crate::auth_state::RequiredPermission::Delete)
1747    }
1748
1749    /// [`Value::Null`]) and the engine assigns the next counter value; use
1750    /// [`Table::put_returning`] to learn that assigned value.
1751    pub fn put(&mut self, columns: Vec<(u16, Value)>) -> Result<RowId> {
1752        self.require_insert()?;
1753        Ok(self.put_returning(columns)?.0)
1754    }
1755
1756    /// Like [`Table::put`] but also returns the engine-assigned `AUTO_INCREMENT`
1757    /// value (`Some` only when the column was omitted/null and the engine filled
1758    /// it; `None` when the table has no auto-increment column or the caller
1759    /// supplied an explicit value).
1760    pub fn put_returning(
1761        &mut self,
1762        mut columns: Vec<(u16, Value)>,
1763    ) -> Result<(RowId, Option<i64>)> {
1764        self.require_insert()?;
1765        let assigned = self.fill_auto_inc(&mut columns)?;
1766        self.apply_defaults(&mut columns)?;
1767        self.schema.validate_values(&columns)?;
1768        // For clustered (WITHOUT ROWID) tables, derive RowId deterministically
1769        // from the PK value so the same PK always maps to the same row (no
1770        // allocator waste, idempotent upserts). For standard tables, use the
1771        // monotonic allocator.
1772        let row_id = if self.schema.clustered {
1773            self.derive_clustered_row_id(&columns)?
1774        } else {
1775            self.allocator.alloc()
1776        };
1777        let epoch = self.pending_epoch();
1778        let mut row = Row::new(row_id, epoch);
1779        for (col_id, val) in columns {
1780            row.columns.insert(col_id, val);
1781        }
1782        self.commit_rows(vec![row], assigned.is_some())?;
1783        Ok((row_id, assigned))
1784    }
1785
1786    /// Bulk upsert: many rows under a single WAL record + one index pass. Far
1787    /// cheaper than `put` in a loop for batch ingest.
1788    pub fn put_batch(&mut self, batch: Vec<Vec<(u16, Value)>>) -> Result<Vec<RowId>> {
1789        self.require_insert()?;
1790        Ok(self
1791            .put_batch_returning(batch)?
1792            .into_iter()
1793            .map(|(r, _)| r)
1794            .collect())
1795    }
1796
1797    /// Like [`Table::put_batch`] but each entry is paired with the engine-
1798    /// assigned `AUTO_INCREMENT` value (`Some` only when filled by the engine).
1799    pub fn put_batch_returning(
1800        &mut self,
1801        batch: Vec<Vec<(u16, Value)>>,
1802    ) -> Result<Vec<(RowId, Option<i64>)>> {
1803        let mut filled: Vec<FilledAutoIncRow> = Vec::with_capacity(batch.len());
1804        for mut cols in batch {
1805            let assigned = self.fill_auto_inc(&mut cols)?;
1806            self.apply_defaults(&mut cols)?;
1807            filled.push((cols, assigned));
1808        }
1809        for (cols, _) in &filled {
1810            self.schema.validate_values(cols)?;
1811        }
1812        let epoch = self.pending_epoch();
1813        let mut rows = Vec::with_capacity(filled.len());
1814        let mut ids = Vec::with_capacity(filled.len());
1815        for (cols, assigned) in filled {
1816            let row_id = if self.schema.clustered {
1817                self.derive_clustered_row_id(&cols)?
1818            } else {
1819                self.allocator.alloc()
1820            };
1821            let mut row = Row::new(row_id, epoch);
1822            for (c, v) in cols {
1823                row.columns.insert(c, v);
1824            }
1825            ids.push((row_id, assigned));
1826            rows.push(row);
1827        }
1828        let all_auto_generated = ids.iter().all(|(_, assigned)| assigned.is_some());
1829        self.commit_rows(rows, all_auto_generated)?;
1830        Ok(ids)
1831    }
1832
1833    /// Fill the `AUTO_INCREMENT` column for an upcoming row. When the column is
1834    /// omitted or [`Value::Null`] the next counter value is allocated and the
1835    /// cell is appended/replaced in `columns`; an explicit `Int64` is honored
1836    /// and advances the counter past it. Returns `Some(value)` when the engine
1837    /// allocated (so the caller can surface it), `None` otherwise.
1838    pub fn fill_auto_inc(&mut self, columns: &mut Vec<(u16, Value)>) -> Result<Option<i64>> {
1839        self.ensure_writable()?;
1840        let Some(cid) = self.auto_inc.as_ref().map(|a| a.column_id) else {
1841            return Ok(None);
1842        };
1843        let pos = columns.iter().position(|(c, _)| *c == cid);
1844        let assigned = match pos {
1845            Some(i) => match &columns[i].1 {
1846                Value::Null => {
1847                    let next = self.alloc_auto_inc_value()?;
1848                    columns[i].1 = Value::Int64(next);
1849                    Some(next)
1850                }
1851                Value::Int64(n) => {
1852                    self.advance_auto_inc_past(*n)?;
1853                    None
1854                }
1855                other => {
1856                    return Err(MongrelError::InvalidArgument(format!(
1857                        "AUTO_INCREMENT column {cid} must be Int64 or NULL, got {:?}",
1858                        other
1859                    )))
1860                }
1861            },
1862            None => {
1863                let next = self.alloc_auto_inc_value()?;
1864                columns.push((cid, Value::Int64(next)));
1865                Some(next)
1866            }
1867        };
1868        Ok(assigned)
1869    }
1870
1871    /// Apply column default expressions to `columns` at stage time (before
1872    /// NOT NULL validation). For each column carrying a `default_value`, if the
1873    /// column is omitted or explicitly `Null`, the default is applied. Explicit
1874    /// values are never overridden. Called after [`fill_auto_inc`](Self::fill_auto_inc)
1875    /// and before `validate_not_null`.
1876    pub fn apply_defaults(&self, columns: &mut Vec<(u16, Value)>) -> Result<()> {
1877        for col in &self.schema.columns {
1878            let Some(expr) = &col.default_value else {
1879                continue;
1880            };
1881            // Skip AUTO_INCREMENT columns — handled by fill_auto_inc.
1882            if col.flags.contains(ColumnFlags::AUTO_INCREMENT) {
1883                continue;
1884            }
1885            let pos = columns.iter().position(|(c, _)| *c == col.id);
1886            let needs_default = match pos {
1887                None => true,
1888                Some(i) => matches!(columns[i].1, Value::Null),
1889            };
1890            if !needs_default {
1891                continue;
1892            }
1893            let v = match expr {
1894                crate::schema::DefaultExpr::Static(v) => v.clone(),
1895                crate::schema::DefaultExpr::Now => Value::Bytes(iso_now_bytes()),
1896                crate::schema::DefaultExpr::Uuid => {
1897                    let mut buf = [0u8; 16];
1898                    getrandom::getrandom(&mut buf)
1899                        .map_err(|e| MongrelError::Other(format!("UUID generation failed: {e}")))?;
1900                    Value::Uuid(buf)
1901                }
1902            };
1903            match pos {
1904                None => columns.push((col.id, v)),
1905                Some(i) => columns[i].1 = v,
1906            }
1907        }
1908        Ok(())
1909    }
1910
1911    /// Allocate the next identity value, seeding the counter first if needed.
1912    fn alloc_auto_inc_value(&mut self) -> Result<i64> {
1913        self.ensure_auto_inc_seeded()?;
1914        // Borrow checker: re-read after the mutable `ensure` call returns.
1915        let ai = self.auto_inc.as_mut().expect("auto-inc column present");
1916        let v = ai.next;
1917        ai.next = ai.next.saturating_add(1);
1918        Ok(v)
1919    }
1920
1921    /// Advance the counter past an explicit id, seeding first if needed so a
1922    /// pre-existing higher id elsewhere is never ignored.
1923    fn advance_auto_inc_past(&mut self, used: i64) -> Result<()> {
1924        self.ensure_auto_inc_seeded()?;
1925        let ai = self.auto_inc.as_mut().expect("auto-inc column present");
1926        let floor = used.saturating_add(1).max(1);
1927        if ai.next < floor {
1928            ai.next = floor;
1929        }
1930        Ok(())
1931    }
1932
1933    /// Seed the counter on first use by scanning `max(PK)` over all visible
1934    /// rows, so an upgraded table (legacy client-assigned ids, or a manifest
1935    /// migrated from `auto_inc_next == 0`) never hands out a colliding id.
1936    /// Idempotent: a no-op once seeded.
1937    fn ensure_auto_inc_seeded(&mut self) -> Result<()> {
1938        let needs_seed = match self.auto_inc {
1939            Some(ai) => !ai.seeded,
1940            None => return Ok(()),
1941        };
1942        if !needs_seed {
1943            return Ok(());
1944        }
1945        if self.seed_empty_auto_inc() {
1946            return Ok(());
1947        }
1948        let cid = self
1949            .auto_inc
1950            .as_ref()
1951            .expect("auto-inc column present")
1952            .column_id;
1953        let max = self.scan_max_int64(cid)?;
1954        let ai = self.auto_inc.as_mut().expect("auto-inc column present");
1955        let floor = max.saturating_add(1).max(1);
1956        if ai.next < floor {
1957            ai.next = floor;
1958        }
1959        ai.seeded = true;
1960        Ok(())
1961    }
1962
1963    fn alloc_auto_inc_range(&mut self, n: usize) -> Result<Option<i64>> {
1964        if n == 0 || self.auto_inc.is_none() {
1965            return Ok(None);
1966        }
1967        self.ensure_auto_inc_seeded()?;
1968        let ai = self.auto_inc.as_mut().expect("auto-inc column present");
1969        let start = ai.next;
1970        ai.next = ai.next.saturating_add(n as i64);
1971        Ok(Some(start))
1972    }
1973
1974    /// One-time `max(Int64 column)` over all MVCC-visible rows. Used to seed the
1975    /// auto-increment counter. Runs at most once per table (the manifest then
1976    /// checkpoints the seeded counter).
1977    fn scan_max_int64(&mut self, column_id: u16) -> Result<i64> {
1978        let mut max: i64 = 0;
1979        for r in self.memtable.visible_versions(Epoch(u64::MAX)) {
1980            if let Some(Value::Int64(n)) = r.columns.get(&column_id) {
1981                if *n > max {
1982                    max = *n;
1983                }
1984            }
1985        }
1986        for r in self.mutable_run.visible_versions(Epoch(u64::MAX)) {
1987            if let Some(Value::Int64(n)) = r.columns.get(&column_id) {
1988                if *n > max {
1989                    max = *n;
1990                }
1991            }
1992        }
1993        for rr in self.run_refs.clone() {
1994            let reader = self.open_reader(rr.run_id)?;
1995            if let Some(stats) = reader.column_page_stats(column_id) {
1996                for s in stats {
1997                    if let Some(n) = crate::sorted_run::be_i64(s.max.as_deref()) {
1998                        if n > max {
1999                            max = n;
2000                        }
2001                    }
2002                }
2003            } else if reader.has_column(column_id) {
2004                if let columnar::NativeColumn::Int64 { data, validity } =
2005                    reader.column_native_shared(column_id)?
2006                {
2007                    for (i, n) in data.iter().enumerate() {
2008                        if (validity.is_empty() || columnar::validity_bit(&validity, i)) && *n > max
2009                        {
2010                            max = *n;
2011                        }
2012                    }
2013                }
2014            }
2015        }
2016        Ok(max)
2017    }
2018
2019    fn seed_empty_auto_inc(&mut self) -> bool {
2020        let Some(ai) = self.auto_inc.as_mut() else {
2021            return false;
2022        };
2023        if ai.seeded || self.live_count != 0 {
2024            return false;
2025        }
2026        if ai.next < 1 {
2027            ai.next = 1;
2028        }
2029        ai.seeded = true;
2030        true
2031    }
2032
2033    fn advance_auto_inc_from_native_columns(
2034        &mut self,
2035        columns: &[(u16, columnar::NativeColumn)],
2036        n: usize,
2037        live_before: u64,
2038    ) -> Result<()> {
2039        let Some(ai) = self.auto_inc.as_mut() else {
2040            return Ok(());
2041        };
2042        let Some((_, col)) = columns.iter().find(|(cid, _)| *cid == ai.column_id) else {
2043            return Ok(());
2044        };
2045        let columnar::NativeColumn::Int64 { data, validity } = col else {
2046            return Err(MongrelError::InvalidArgument(format!(
2047                "AUTO_INCREMENT column {} must be Int64",
2048                ai.column_id
2049            )));
2050        };
2051        let max = if native_int64_strictly_increasing(col, n) {
2052            data.get(n.saturating_sub(1)).copied()
2053        } else {
2054            data.iter()
2055                .take(n)
2056                .enumerate()
2057                .filter_map(|(i, v)| {
2058                    if validity.is_empty() || columnar::validity_bit(validity, i) {
2059                        Some(*v)
2060                    } else {
2061                        None
2062                    }
2063                })
2064                .max()
2065        };
2066        if let Some(max) = max {
2067            let floor = max.saturating_add(1).max(1);
2068            if ai.next < floor {
2069                ai.next = floor;
2070            }
2071            if ai.seeded || live_before == 0 {
2072                ai.seeded = true;
2073            }
2074        }
2075        Ok(())
2076    }
2077
2078    fn fill_auto_inc_native_columns(
2079        &mut self,
2080        columns: &mut Vec<(u16, columnar::NativeColumn)>,
2081        n: usize,
2082    ) -> Result<()> {
2083        let Some(cid) = self.auto_inc.as_ref().map(|a| a.column_id) else {
2084            return Ok(());
2085        };
2086        let Some(pos) = columns.iter().position(|(id, _)| *id == cid) else {
2087            if let Some(start) = self.alloc_auto_inc_range(n)? {
2088                columns.push((
2089                    cid,
2090                    columnar::NativeColumn::Int64 {
2091                        data: (start..start.saturating_add(n as i64)).collect(),
2092                        validity: vec![0xFF; n.div_ceil(8)],
2093                    },
2094                ));
2095            }
2096            return Ok(());
2097        };
2098
2099        let columnar::NativeColumn::Int64 { data, validity } = &mut columns[pos].1 else {
2100            return Err(MongrelError::InvalidArgument(format!(
2101                "AUTO_INCREMENT column {cid} must be Int64"
2102            )));
2103        };
2104        if data.len() < n {
2105            return Err(MongrelError::InvalidArgument(format!(
2106                "AUTO_INCREMENT column {cid} has {} rows, expected {n}",
2107                data.len()
2108            )));
2109        }
2110        if columnar::all_non_null(validity, n) {
2111            return Ok(());
2112        }
2113        if validity.iter().all(|b| *b == 0) {
2114            if let Some(start) = self.alloc_auto_inc_range(n)? {
2115                for (i, slot) in data.iter_mut().take(n).enumerate() {
2116                    *slot = start.saturating_add(i as i64);
2117                }
2118                *validity = vec![0xFF; n.div_ceil(8)];
2119            }
2120            return Ok(());
2121        }
2122
2123        let new_validity = vec![0xFF; data.len().div_ceil(8)];
2124        for (i, slot) in data.iter_mut().enumerate().take(n) {
2125            if columnar::validity_bit(validity, i) {
2126                self.advance_auto_inc_past(*slot)?;
2127            } else {
2128                *slot = self.alloc_auto_inc_value()?;
2129            }
2130        }
2131        *validity = new_validity;
2132        Ok(())
2133    }
2134
2135    /// Reserve (but do not insert) the next `AUTO_INCREMENT` value, advancing
2136    /// the in-memory counter. Returns `None` when the table has no
2137    /// auto-increment column.
2138    ///
2139    /// This is the escape hatch for callers that stage the row with an explicit
2140    /// id inside a cross-table [`crate::Transaction`] — where the engine cannot
2141    /// fill the column on the `put` path (the row id + cells are only assembled
2142    /// at commit). Unlike the old Kit `__kit_sequences` sequence row, the
2143    /// reservation is a pure in-memory counter bump: no hot row, no second
2144    /// commit. It becomes durable when a row carrying the reserved id commits
2145    /// (the counter is checkpointed to the manifest in the same commit); an
2146    /// aborted reservation simply leaves a gap, which the never-reuse rule
2147    /// permits.
2148    pub fn reserve_auto_inc(&mut self) -> Result<Option<i64>> {
2149        self.ensure_writable()?;
2150        if self.auto_inc.is_none() {
2151            return Ok(None);
2152        }
2153        Ok(Some(self.alloc_auto_inc_value()?))
2154    }
2155
2156    /// Append `rows` under one WAL record. On a standalone table they are folded
2157    /// into the memtable + indexes immediately (single clock — no speculative-
2158    /// epoch hazard). On a mounted table (B1/B2) they are staged in
2159    /// `pending_rows` and applied at the real assigned epoch in `commit`, so a
2160    /// concurrent reader can never see them before their commit epoch.
2161    fn commit_rows(&mut self, rows: Vec<Row>, auto_inc_generated: bool) -> Result<()> {
2162        let payload = bincode::serialize(&rows)?;
2163        self.wal_append_data(Op::Put {
2164            table_id: self.table_id,
2165            rows: payload,
2166        })?;
2167        if self.is_shared() {
2168            self.pending_rows_auto_inc
2169                .extend(std::iter::repeat(auto_inc_generated).take(rows.len()));
2170            self.pending_rows.extend(rows);
2171        } else {
2172            self.apply_put_rows_inner(rows, !auto_inc_generated)?;
2173        }
2174        Ok(())
2175    }
2176
2177    /// Apply already-durable put rows to the memtable + indexes + allocator +
2178    /// live count WITHOUT appending to the per-table WAL (the WAL — shared or
2179    /// per-table — is the caller's responsibility). Used by the cross-table
2180    /// `Transaction` commit path (P2.5) after it has written the shared WAL.
2181    pub(crate) fn apply_put_rows(&mut self, rows: Vec<Row>) -> Result<()> {
2182        self.apply_put_rows_inner(rows, true)
2183    }
2184
2185    fn apply_put_rows_inner(&mut self, rows: Vec<Row>, check_existing_pk: bool) -> Result<()> {
2186        if check_existing_pk {
2187            self.ensure_indexes_complete()?;
2188        }
2189        // Single-row puts — the hot operational path — cannot contain an
2190        // intra-batch duplicate, so the winner/loser partition maps are pure
2191        // overhead. Same semantics as the batch path below with `losers = ∅`.
2192        if rows.len() == 1 {
2193            let row = rows.into_iter().next().expect("len checked");
2194            return self.apply_put_row_single(row, check_existing_pk);
2195        }
2196        // One pass per row: track mutated columns, tombstone the previous
2197        // owner of the row's PK, index (which places the HOT entry), sample,
2198        // and materialize. Each row is applied completely — including its
2199        // memtable upsert — before the next row processes, so "the last row
2200        // wins" falls out naturally for an intra-batch duplicate PK: the
2201        // earlier row is already materialized and gets tombstoned like any
2202        // other displaced owner (same visible state as pre-partitioning the
2203        // batch into winners and losers, without materializing a winner map
2204        // over the whole batch).
2205        //
2206        // Upsert probing is skipped entirely when no PK owner can be
2207        // displaced: `check_existing_pk == false` means every PK is a fresh
2208        // engine-assigned AUTO_INCREMENT value; an empty HOT index plus
2209        // strictly-increasing batch PKs (the append-style batch, mirroring
2210        // `bulk_pk_winner_indices`' fast path) rules out both pre-existing
2211        // owners and intra-batch duplicates.
2212        let pk_id = self.schema.primary_key().map(|c| c.id);
2213        let probe = match pk_id {
2214            Some(pid) => {
2215                check_existing_pk
2216                    && !(self.hot.is_empty() && rows_pk_strictly_increasing(&rows, pid))
2217            }
2218            None => false,
2219        };
2220        // The PK reverse map is maintained inline only once a delete has built
2221        // it (`pk_by_row_complete`); ingest-only tables never pay for it.
2222        let maintain_pk_by_row = pk_id.is_some() && self.pk_by_row_complete;
2223        for r in rows {
2224            for &cid in r.columns.keys() {
2225                self.pending_put_cols.insert(cid);
2226            }
2227            match pk_id {
2228                Some(pid) if probe || maintain_pk_by_row => {
2229                    if let Some(pk_val) = r.columns.get(&pid) {
2230                        let key = self.index_lookup_key(pid, pk_val);
2231                        if probe {
2232                            if let Some(old_rid) = self.hot.get(&key) {
2233                                if old_rid != r.row_id {
2234                                    self.tombstone_row(old_rid, r.committed_epoch, true);
2235                                }
2236                            }
2237                        }
2238                        if maintain_pk_by_row {
2239                            self.pk_by_row.insert(r.row_id, key);
2240                        }
2241                    }
2242                }
2243                Some(_) => {}
2244                None => {
2245                    self.hot.insert(r.row_id.0.to_be_bytes().to_vec(), r.row_id);
2246                }
2247            }
2248            self.index_row(&r);
2249            self.reservoir.offer(r.row_id.0);
2250            self.memtable.upsert(r);
2251            // Count as each row lands so a later duplicate's tombstone
2252            // decrement (in `tombstone_row`) sees an up-to-date value.
2253            self.live_count = self.live_count.saturating_add(1);
2254        }
2255        self.data_generation = self.data_generation.wrapping_add(1);
2256        Ok(())
2257    }
2258
2259    /// One-row specialization of [`Table::apply_put_rows_inner`]: identical
2260    /// upsert semantics (tombstone the previous PK owner, insert into HOT,
2261    /// index, sample, materialize) without the per-batch winner/loser maps.
2262    fn apply_put_row_single(&mut self, row: Row, check_existing_pk: bool) -> Result<()> {
2263        for &cid in row.columns.keys() {
2264            self.pending_put_cols.insert(cid);
2265        }
2266        let epoch = row.committed_epoch;
2267        if let Some(pk_col) = self.schema.primary_key() {
2268            let pk_id = pk_col.id;
2269            if let Some(pk_val) = row.columns.get(&pk_id) {
2270                // `index_row` below writes the HOT entry (`index_into` covers
2271                // the PK). The reverse map is maintained inline only once a
2272                // delete has built it; ingest-only tables never pay for it.
2273                let maintain_pk_by_row = self.pk_by_row_complete;
2274                if check_existing_pk || maintain_pk_by_row {
2275                    let key = self.index_lookup_key(pk_id, pk_val);
2276                    if check_existing_pk {
2277                        if let Some(old_rid) = self.hot.get(&key) {
2278                            if old_rid != row.row_id {
2279                                self.tombstone_row(old_rid, epoch, true);
2280                            }
2281                        }
2282                    }
2283                    if maintain_pk_by_row {
2284                        self.pk_by_row.insert(row.row_id, key);
2285                    }
2286                }
2287            }
2288        } else {
2289            self.hot
2290                .insert(row.row_id.0.to_be_bytes().to_vec(), row.row_id);
2291        }
2292        self.index_row(&row);
2293        self.reservoir.offer(row.row_id.0);
2294        self.memtable.upsert(row);
2295        self.live_count = self.live_count.saturating_add(1);
2296        self.data_generation = self.data_generation.wrapping_add(1);
2297        Ok(())
2298    }
2299
2300    /// Allocate a fresh row id (advancing the table's allocator). Used by the
2301    /// cross-table `Transaction` to assign ids before sealing a row.
2302    pub(crate) fn alloc_row_id(&mut self) -> RowId {
2303        self.allocator.alloc()
2304    }
2305
2306    /// For clustered (WITHOUT ROWID) tables: derive a deterministic `RowId`
2307    /// from the primary-key value so the same PK always maps to the same row.
2308    /// Uses a stable hash of the PK's `encode_key()` bytes, cast to `u64`.
2309    /// This gives WITHOUT ROWID tables idempotent upsert semantics (same PK →
2310    /// same RowId, no allocator waste) without changing the storage format.
2311    fn derive_clustered_row_id(&self, columns: &[(u16, Value)]) -> Result<RowId> {
2312        let pk = self.schema.primary_key().ok_or_else(|| {
2313            MongrelError::Schema("clustered table requires a single-column primary key".into())
2314        })?;
2315        let pk_val = columns
2316            .iter()
2317            .find(|(id, _)| *id == pk.id)
2318            .map(|(_, v)| v)
2319            .ok_or_else(|| {
2320                MongrelError::Schema(format!(
2321                    "clustered table missing primary key column {} ({})",
2322                    pk.id, pk.name
2323                ))
2324            })?;
2325        let key_bytes = pk_val.encode_key();
2326        // Stable hash (FNV-1a 64-bit) — deterministic across runs and processes.
2327        let mut hash: u64 = 0xcbf29ce484222325;
2328        for &b in &key_bytes {
2329            hash ^= b as u64;
2330            hash = hash.wrapping_mul(0x100000001b3);
2331        }
2332        // Ensure non-zero (RowId 0 is valid but we want to avoid collision with
2333        // allocator-generated ids which start at 0 for non-clustered tables).
2334        Ok(RowId(hash.max(1)))
2335    }
2336
2337    /// Apply the metadata for rows that were spilled to a linked uniform-epoch
2338    /// run (P3.4): update the HOT + secondary indexes, the reservoir, the
2339    /// allocator high-water mark, and `live_count` — but **do NOT** insert the
2340    /// rows into the memtable. The rows are served from the linked run (which the
2341    /// scan/merge path reads at the run's commit epoch), so materializing them in
2342    /// the memtable too would defeat the point of spilling (peak memory stays
2343    /// bounded). Caller must have linked the run before reads can resolve indexes.
2344    pub(crate) fn apply_run_metadata(&mut self, rows: &[Row]) -> Result<()> {
2345        self.ensure_indexes_complete()?;
2346        let n = rows.len();
2347        for r in rows {
2348            for &cid in r.columns.keys() {
2349                self.pending_put_cols.insert(cid);
2350            }
2351        }
2352        let (losers, winner_pks) = self.partition_pk_winners(rows);
2353        let epoch = rows.first().map(|r| r.committed_epoch).unwrap_or(Epoch(0));
2354        // Tombstone pre-existing rows that conflict with winners.
2355        for (key, &row_id) in &winner_pks {
2356            if let Some(old_rid) = self.hot.get(key) {
2357                if old_rid != row_id {
2358                    self.tombstone_row(old_rid, epoch, true);
2359                }
2360            }
2361        }
2362        // Hide duplicate-PK rows inside this uniform-epoch run by tombstoning
2363        // their row ids in the memtable overlay (the overlay wins over the run).
2364        for &loser_rid in &losers {
2365            self.tombstone_row(loser_rid, epoch, false);
2366        }
2367        // Insert the winners into HOT.
2368        for (key, row_id) in winner_pks {
2369            self.insert_hot_pk(key, row_id);
2370        }
2371        if self.schema.primary_key().is_none() {
2372            for r in rows {
2373                self.hot.insert(r.row_id.0.to_be_bytes().to_vec(), r.row_id);
2374            }
2375        }
2376        for r in rows {
2377            self.allocator.advance_to(r.row_id);
2378            if !losers.contains(&r.row_id) {
2379                self.index_row(r);
2380            }
2381        }
2382        for r in rows {
2383            if !losers.contains(&r.row_id) {
2384                self.reservoir.offer(r.row_id.0);
2385            }
2386        }
2387        self.live_count = self.live_count.saturating_add((n - losers.len()) as u64);
2388        self.data_generation = self.data_generation.wrapping_add(1);
2389        Ok(())
2390    }
2391
2392    /// Apply already-committed puts + tombstones during shared-WAL recovery
2393    /// (spec §15 pass 2). Advances the allocator, upserts/tombstones the
2394    /// memtable, and indexes the rows — but does NOT touch `live_count` (the
2395    /// manifest is authoritative) and does NOT append to the WAL.
2396    pub(crate) fn recover_apply(
2397        &mut self,
2398        rows: Vec<Row>,
2399        deletes: Vec<(RowId, Epoch)>,
2400    ) -> Result<()> {
2401        // Rows from different transactions have different epochs and can be
2402        // upserted sequentially. Rows inside one transaction share an epoch, so
2403        // duplicate PKs within that transaction must keep only the last winner.
2404        let mut by_epoch: std::collections::BTreeMap<Epoch, Vec<Row>> =
2405            std::collections::BTreeMap::new();
2406        for row in rows {
2407            self.allocator.advance_to(row.row_id);
2408            // Mirror the row-id advance for the AUTO_INCREMENT counter: WAL
2409            // replay must not hand out an id a recovered row already claimed.
2410            // `seeded` is intentionally left untouched so a still-unseeded
2411            // counter still scans `max(PK)` to cover already-flushed rows.
2412            if let Some(ai) = self.auto_inc.as_mut() {
2413                if let Some(Value::Int64(n)) = row.columns.get(&ai.column_id) {
2414                    if *n + 1 > ai.next {
2415                        ai.next = *n + 1;
2416                    }
2417                }
2418            }
2419            by_epoch.entry(row.committed_epoch).or_default().push(row);
2420        }
2421        for (epoch, group) in by_epoch {
2422            let (losers, winner_pks) = self.partition_pk_winners(&group);
2423            // Tombstone pre-existing PK owners.
2424            for (key, &row_id) in &winner_pks {
2425                if let Some(old_rid) = self.hot.get(key) {
2426                    if old_rid != row_id {
2427                        self.tombstone_row(old_rid, epoch, false);
2428                    }
2429                }
2430            }
2431            for (key, row_id) in winner_pks {
2432                self.insert_hot_pk(key, row_id);
2433            }
2434            if self.schema.primary_key().is_none() {
2435                for r in &group {
2436                    self.hot.insert(r.row_id.0.to_be_bytes().to_vec(), r.row_id);
2437                }
2438            }
2439            for r in &group {
2440                if !losers.contains(&r.row_id) {
2441                    self.memtable.upsert(r.clone());
2442                    self.index_row(r);
2443                }
2444            }
2445        }
2446        for (rid, epoch) in deletes {
2447            self.memtable.tombstone(rid, epoch);
2448            self.remove_hot_for_row(rid, epoch);
2449        }
2450        // Reservoir stays lazy — see `ensure_reservoir_complete` — rather than
2451        // eagerly materializing every row on every WAL-replay batch.
2452        self.reservoir_complete = false;
2453        Ok(())
2454    }
2455
2456    /// Highest epoch whose data is durable in a sorted run (spec §7.1).
2457    pub(crate) fn flushed_epoch(&self) -> u64 {
2458        self.flushed_epoch
2459    }
2460
2461    pub(crate) fn set_flushed_epoch(&mut self, epoch: Epoch) {
2462        self.flushed_epoch = self.flushed_epoch.max(epoch.0);
2463    }
2464
2465    /// Validate that `cells` satisfy the schema's NOT NULL constraints.
2466    pub(crate) fn validate_cells_not_null(&self, cells: &[(u16, Value)]) -> Result<()> {
2467        self.schema.validate_values(cells)
2468    }
2469
2470    /// Column-major NOT NULL validation for the bulk-load paths. Every schema
2471    /// column that is not marked NULLABLE must be present in `columns` and have
2472    /// no null validity bits over its first `n` rows.
2473    fn validate_columns_not_null(
2474        &self,
2475        columns: &[(u16, columnar::NativeColumn)],
2476        n: usize,
2477    ) -> Result<()> {
2478        let by_id: HashMap<u16, &columnar::NativeColumn> =
2479            columns.iter().map(|(id, c)| (*id, c)).collect();
2480        for col in &self.schema.columns {
2481            if !col.flags.contains(ColumnFlags::NULLABLE) {
2482                match by_id.get(&col.id) {
2483                    None => {
2484                        return Err(MongrelError::InvalidArgument(format!(
2485                            "column '{}' ({}) is NOT NULL but was omitted from the bulk load",
2486                            col.name, col.id
2487                        )));
2488                    }
2489                    Some(c) => {
2490                        if c.null_count(n) != 0 {
2491                            return Err(MongrelError::InvalidArgument(format!(
2492                                "column '{}' ({}) is NOT NULL but the bulk load contains nulls",
2493                                col.name, col.id
2494                            )));
2495                        }
2496                    }
2497                }
2498            }
2499            if let TypeId::Enum { variants } = &col.ty {
2500                let Some(columnar::NativeColumn::Bytes { .. }) = by_id.get(&col.id).copied() else {
2501                    if by_id.contains_key(&col.id) {
2502                        return Err(MongrelError::InvalidArgument(format!(
2503                            "column '{}' ({}) enum requires a bytes column",
2504                            col.name, col.id
2505                        )));
2506                    }
2507                    continue;
2508                };
2509                for index in 0..n {
2510                    let Some(value) = columnar::native_bytes_at(by_id[&col.id], index) else {
2511                        continue;
2512                    };
2513                    if !variants.iter().any(|variant| variant.as_bytes() == value) {
2514                        return Err(MongrelError::InvalidArgument(format!(
2515                            "column '{}' ({}) enum value {:?} is not one of {:?}",
2516                            col.name,
2517                            col.id,
2518                            String::from_utf8_lossy(value),
2519                            variants
2520                        )));
2521                    }
2522                }
2523            }
2524        }
2525        Ok(())
2526    }
2527
2528    /// For a bulk-loaded batch, compute the row indices that survive primary-
2529    /// key upsert: for each PK value the last occurrence wins, earlier
2530    /// duplicates are dropped. Rows with a null PK value are always kept. Returns
2531    /// `None` when there is no primary key or no compaction is needed.
2532    fn bulk_pk_winner_indices(
2533        &self,
2534        columns: &[(u16, columnar::NativeColumn)],
2535        n: usize,
2536    ) -> Option<Vec<usize>> {
2537        let pk_col = self.schema.primary_key()?;
2538        let pk_id = pk_col.id;
2539        let pk_ty = pk_col.ty.clone();
2540        let by_id: HashMap<u16, &columnar::NativeColumn> =
2541            columns.iter().map(|(id, c)| (*id, c)).collect();
2542        let pk_native = by_id.get(&pk_id)?;
2543        if native_int64_strictly_increasing(pk_native, n) {
2544            return None;
2545        }
2546        // key -> index of the last row that carried that PK value.
2547        let mut last: HashMap<Vec<u8>, usize> = HashMap::new();
2548        let mut null_pk_rows: Vec<usize> = Vec::new();
2549        for i in 0..n {
2550            match bulk_index_key(&self.column_keys, pk_id, pk_ty.clone(), pk_native, i) {
2551                Some(key) => {
2552                    last.insert(key, i);
2553                }
2554                None => null_pk_rows.push(i),
2555            }
2556        }
2557        let mut winners: HashSet<usize> = last.values().copied().collect();
2558        for i in null_pk_rows {
2559            winners.insert(i);
2560        }
2561        Some((0..n).filter(|i| winners.contains(i)).collect())
2562    }
2563
2564    /// Logically delete `row_id` (effective at the next commit).
2565    pub fn delete(&mut self, row_id: RowId) -> Result<()> {
2566        self.require_delete()?;
2567        let epoch = self.pending_epoch();
2568        self.wal_append_data(Op::Delete {
2569            table_id: self.table_id,
2570            row_ids: vec![row_id],
2571        })?;
2572        if self.is_shared() {
2573            self.pending_dels.push(row_id);
2574        } else {
2575            self.apply_delete(row_id, epoch);
2576        }
2577        Ok(())
2578    }
2579
2580    pub fn delete_returning(&mut self, row_id: RowId) -> Result<Option<OwnedRow>> {
2581        let pre = self.get(row_id, self.snapshot());
2582        self.delete(row_id)?;
2583        Ok(pre.map(|row| {
2584            let mut columns: Vec<_> = row.columns.into_iter().collect();
2585            columns.sort_by_key(|(id, _)| *id);
2586            OwnedRow { columns }
2587        }))
2588    }
2589
2590    /// Durably remove every row in the table once the current write span commits.
2591    pub fn truncate(&mut self) -> Result<()> {
2592        self.require_delete()?;
2593        let epoch = self.pending_epoch();
2594        self.wal_append_data(Op::TruncateTable {
2595            table_id: self.table_id,
2596        })?;
2597        self.pending_rows.clear();
2598        self.pending_rows_auto_inc.clear();
2599        self.pending_dels.clear();
2600        self.pending_truncate = Some(epoch);
2601        Ok(())
2602    }
2603
2604    /// Apply an already-durable truncate without appending to the WAL.
2605    pub(crate) fn apply_truncate(&mut self, _epoch: Epoch) -> Result<()> {
2606        for rr in std::mem::take(&mut self.run_refs) {
2607            let _ = std::fs::remove_file(self.run_path(rr.run_id as u64));
2608        }
2609        for r in std::mem::take(&mut self.retiring) {
2610            let _ = std::fs::remove_file(self.run_path(r.run_id as u64));
2611        }
2612        self.memtable = Memtable::new();
2613        self.mutable_run = MutableRun::new();
2614        self.hot = HotIndex::new();
2615        let (bitmap, ann, fm, sparse, minhash) = empty_indexes(&self.schema);
2616        self.bitmap = bitmap;
2617        self.ann = ann;
2618        self.fm = fm;
2619        self.sparse = sparse;
2620        self.minhash = minhash;
2621        self.learned_range.clear();
2622        self.pk_by_row.clear();
2623        self.pk_by_row_complete = false;
2624        self.live_count = 0;
2625        self.reservoir = crate::reservoir::Reservoir::default();
2626        self.reservoir_complete = true;
2627        self.had_deletes = true;
2628        self.agg_cache.clear();
2629        self.global_idx_epoch = 0;
2630        self.indexes_complete = true;
2631        self.pending_delete_rids.clear();
2632        self.pending_put_cols.clear();
2633        self.pending_rows.clear();
2634        self.pending_rows_auto_inc.clear();
2635        self.pending_dels.clear();
2636        self.clear_result_cache();
2637        self.invalidate_index_checkpoint();
2638        self.data_generation = self.data_generation.wrapping_add(1);
2639        Ok(())
2640    }
2641
2642    /// Apply a tombstone (already-durable on the WAL) at `epoch` without
2643    /// appending to the per-table WAL. Used by the cross-table `Transaction`.
2644    pub(crate) fn apply_delete(&mut self, row_id: RowId, epoch: Epoch) {
2645        self.remove_hot_for_row(row_id, epoch);
2646        self.tombstone_row(row_id, epoch, true);
2647        self.data_generation = self.data_generation.wrapping_add(1);
2648    }
2649
2650    /// Tombstone `row_id` at `epoch`. When `adjust_live_count` is true the
2651    /// table's `live_count` is decremented (used on the live write path); during
2652    /// recovery the manifest is authoritative so the flag is false.
2653    fn tombstone_row(&mut self, row_id: RowId, epoch: Epoch, adjust_live_count: bool) {
2654        let tombstone = Row {
2655            row_id,
2656            committed_epoch: epoch,
2657            columns: std::collections::HashMap::new(),
2658            deleted: true,
2659        };
2660        self.memtable.upsert(tombstone);
2661        self.pk_by_row.remove(&row_id);
2662        if adjust_live_count {
2663            self.live_count = self.live_count.saturating_sub(1);
2664        }
2665        // Track for fine-grained cache invalidation (c).
2666        self.pending_delete_rids.insert(row_id.0 as u32);
2667        // A delete makes the incremental aggregate cache (row-id watermark
2668        // delta) unsafe — permanently disable it for this table.
2669        self.had_deletes = true;
2670        self.agg_cache.clear();
2671    }
2672
2673    /// If `row_id` has a primary-key value and the HOT index currently maps
2674    /// that PK to this row id, remove the entry. Keeps the PK→RowId mapping
2675    /// consistent after deletes and before upserts.
2676    fn remove_hot_for_row(&mut self, row_id: RowId, epoch: Epoch) {
2677        let Some(pk_col) = self.schema.primary_key() else {
2678            return;
2679        };
2680        // Warm path: a prior delete in this process already paid the
2681        // reverse-map rebuild below, so it's kept up to date — O(1).
2682        if self.pk_by_row_complete {
2683            if let Some(key) = self.pk_by_row.remove(&row_id) {
2684                if self.hot.get(&key) == Some(row_id) {
2685                    self.hot.remove(&key);
2686                }
2687            }
2688            return;
2689        }
2690        // Cold path (the common case: a short-lived process — CLI,
2691        // NAPI-per-call — that deletes once and exits): derive the PK
2692        // straight from the row's own pre-delete version via a targeted
2693        // get_version lookup (memtable -> mutable_run -> runs, the same
2694        // page-pruned lookup `Table::get` uses) instead of paying
2695        // `refresh_pk_by_row_from_hot`'s O(table-size) rebuild for a single
2696        // delete. `pk_by_row` is deliberately left incomplete here — same
2697        // "puts leave the reverse map stale" tradeoff, extended to this path.
2698        //
2699        // Look up at `epoch - 1`, not `epoch`: on the live-delete call site
2700        // this delete's own tombstone hasn't landed yet either way, but on
2701        // the WAL-replay call sites (`recover_apply`, `open_in`) the
2702        // memtable tombstone for this exact row/epoch is already applied
2703        // before this runs. Querying `epoch` would see that tombstone
2704        // (empty columns) and fall through to the full rebuild every time a
2705        // replayed delete exists; `epoch - 1` is still >= any real prior
2706        // version's committed_epoch (epochs are unique and monotonic), so it
2707        // finds the same pre-delete row either way.
2708        let lookup_epoch = Epoch(epoch.0.saturating_sub(1));
2709        if self.indexes_complete {
2710            let pk_val = self
2711                .memtable
2712                .get_version(row_id, lookup_epoch)
2713                .and_then(|(_, r)| r.columns.get(&pk_col.id).cloned())
2714                .or_else(|| {
2715                    self.mutable_run
2716                        .get_version(row_id, lookup_epoch)
2717                        .filter(|(_, r)| !r.deleted)
2718                        .and_then(|(_, r)| r.columns.get(&pk_col.id).cloned())
2719                })
2720                .or_else(|| {
2721                    self.run_refs.iter().find_map(|rr| {
2722                        let mut reader = self.open_reader(rr.run_id).ok()?;
2723                        let (_, deleted, val) = reader
2724                            .get_version_column(row_id, lookup_epoch, pk_col.id)
2725                            .ok()??;
2726                        if deleted {
2727                            return None;
2728                        }
2729                        val
2730                    })
2731                });
2732            if let Some(pk_val) = pk_val {
2733                let key = self.index_lookup_key(pk_col.id, &pk_val);
2734                if self.hot.get(&key) == Some(row_id) {
2735                    self.hot.remove(&key);
2736                }
2737                return;
2738            }
2739        }
2740        // Fallback: full reverse-map rebuild, guaranteed correct. Reached
2741        // when indexes aren't complete yet, or the row was already gone by
2742        // the time this ran (e.g. already tombstoned in an overlay ahead of
2743        // this HOT cleanup, as `rebuild_indexes_from_runs` does).
2744        self.refresh_pk_by_row_from_hot();
2745        if let Some(key) = self.pk_by_row.remove(&row_id) {
2746            if self.hot.get(&key) == Some(row_id) {
2747                self.hot.remove(&key);
2748            }
2749        }
2750    }
2751
2752    /// For a batch of rows that share the same commit epoch, decide which rows
2753    /// win for each primary-key value. Returns the set of "loser" row ids that
2754    /// must be skipped/overwritten, and a map from PK lookup key to the winning
2755    /// row id. Rows without a PK value are always winners.
2756    fn partition_pk_winners(
2757        &self,
2758        rows: &[Row],
2759    ) -> (
2760        std::collections::HashSet<RowId>,
2761        std::collections::HashMap<Vec<u8>, RowId>,
2762    ) {
2763        let mut losers = std::collections::HashSet::new();
2764        let Some(pk_col) = self.schema.primary_key() else {
2765            return (losers, std::collections::HashMap::new());
2766        };
2767        let pk_id = pk_col.id;
2768        let mut winners: std::collections::HashMap<Vec<u8>, RowId> =
2769            std::collections::HashMap::new();
2770        for r in rows {
2771            let Some(pk_val) = r.columns.get(&pk_id) else {
2772                continue;
2773            };
2774            let key = self.index_lookup_key(pk_id, pk_val);
2775            if let Some(&old_rid) = winners.get(&key) {
2776                losers.insert(old_rid);
2777            }
2778            winners.insert(key, r.row_id);
2779        }
2780        (losers, winners)
2781    }
2782
2783    fn index_row(&mut self, row: &Row) {
2784        if row.deleted {
2785            return;
2786        }
2787        // Partial index filtering: skip rows that don't match any index's
2788        // predicate. The predicate is a SQL WHERE clause string evaluated
2789        // against the row's column values. For now, we support a simple
2790        // "column_name IS NOT NULL" and "column_name = value" syntax that
2791        // covers the common partial-index patterns (e.g. WHERE deleted_at
2792        // IS NULL). More complex predicates require a full expression
2793        // evaluator in core (future work).
2794        let any_predicate = self
2795            .schema
2796            .indexes
2797            .iter()
2798            .any(|idx| idx.predicate.is_some());
2799        if any_predicate {
2800            let columns_map: HashMap<u16, &Value> =
2801                row.columns.iter().map(|(k, v)| (*k, v)).collect();
2802            let name_to_id: HashMap<&str, u16> = self
2803                .schema
2804                .columns
2805                .iter()
2806                .map(|c| (c.name.as_str(), c.id))
2807                .collect();
2808            for idx in &self.schema.indexes {
2809                if let Some(pred) = &idx.predicate {
2810                    if !eval_partial_predicate(pred, &columns_map, &name_to_id) {
2811                        continue; // skip this index for this row
2812                    }
2813                }
2814                // Index the row into this specific index only.
2815                index_into_single(
2816                    idx,
2817                    &self.schema,
2818                    row,
2819                    &mut self.hot,
2820                    &mut self.bitmap,
2821                    &mut self.ann,
2822                    &mut self.fm,
2823                    &mut self.sparse,
2824                    &mut self.minhash,
2825                );
2826            }
2827            return;
2828        }
2829        // Plaintext tables index the row as-is; only ENCRYPTED_INDEXABLE
2830        // columns need the tokenized copy (`tokenized_for_indexes` clones the
2831        // whole row, which would tax every put on unencrypted tables).
2832        if self.column_keys.is_empty() {
2833            index_into(
2834                &self.schema,
2835                row,
2836                &mut self.hot,
2837                &mut self.bitmap,
2838                &mut self.ann,
2839                &mut self.fm,
2840                &mut self.sparse,
2841                &mut self.minhash,
2842            );
2843            return;
2844        }
2845        let effective_row = self.tokenized_for_indexes(row);
2846        index_into(
2847            &self.schema,
2848            &effective_row,
2849            &mut self.hot,
2850            &mut self.bitmap,
2851            &mut self.ann,
2852            &mut self.fm,
2853            &mut self.sparse,
2854            &mut self.minhash,
2855        );
2856    }
2857
2858    /// Produce the row view that indexes should see. For ENCRYPTED_INDEXABLE
2859    /// equality (HMAC-eq) columns the plaintext value is replaced by its token,
2860    /// so the bitmap/HOT indexes store tokens. OPE-range columns keep their raw
2861    /// value (their range index is rebuilt from runs over plaintext). Plaintext
2862    /// tables return the row unchanged.
2863    fn tokenized_for_indexes(&self, row: &Row) -> Row {
2864        if self.column_keys.is_empty() {
2865            return row.clone();
2866        }
2867        #[cfg(feature = "encryption")]
2868        {
2869            use crate::encryption::SCHEME_HMAC_EQ;
2870            let mut tok = row.clone();
2871            for (&cid, &(_, scheme)) in &self.column_keys {
2872                if scheme != SCHEME_HMAC_EQ {
2873                    continue;
2874                }
2875                if let Some(v) = tok.columns.get(&cid).cloned() {
2876                    if let Some(t) = self.tokenize_value(cid, &v) {
2877                        tok.columns.insert(cid, t);
2878                    }
2879                }
2880            }
2881            tok
2882        }
2883        #[cfg(not(feature = "encryption"))]
2884        {
2885            row.clone()
2886        }
2887    }
2888
2889    /// Group-commit: make all pending writes durable, advance the epoch so they
2890    /// become visible, and persist the manifest. Dispatches on the WAL sink: a
2891    /// standalone table fsyncs its private WAL; a mounted table seals into the
2892    /// shared WAL and defers the fsync to the group-commit coordinator (B1).
2893    pub fn commit(&mut self) -> Result<Epoch> {
2894        self.ensure_writable()?;
2895        if self.is_shared() {
2896            self.commit_shared()
2897        } else {
2898            self.commit_private()
2899        }
2900    }
2901
2902    /// Standalone commit: fsync the private WAL under the commit lock.
2903    fn commit_private(&mut self) -> Result<Epoch> {
2904        // Serialize the assign→fsync→publish critical section across all tables
2905        // sharing the epoch authority so `visible` is published strictly in
2906        // assigned order (the dual-counter invariant).
2907        let commit_lock = Arc::clone(&self.commit_lock);
2908        let _g = commit_lock.lock();
2909        let new_epoch = self.epoch.bump_assigned();
2910        let txn_id = self.current_txn_id;
2911        // Seal the staged records under a TxnCommit marker carrying the commit
2912        // epoch, then a single group fsync. Recovery applies only records whose
2913        // txn has a durable TxnCommit (uncommitted/torn tails are discarded).
2914        match &mut self.wal {
2915            WalSink::Private(w) => {
2916                w.append_txn(
2917                    txn_id,
2918                    Op::TxnCommit {
2919                        epoch: new_epoch.0,
2920                        added_runs: Vec::new(),
2921                    },
2922                )?;
2923                w.sync()?;
2924            }
2925            WalSink::Shared(_) => unreachable!("commit_private on a shared sink"),
2926        }
2927        // The truncate record is now durable; apply the physical clear.
2928        if let Some(epoch) = self.pending_truncate.take() {
2929            self.apply_truncate(epoch)?;
2930        }
2931        self.invalidate_pending_cache();
2932        self.persist_manifest(new_epoch)?;
2933        // Publish through the shared in-order gate so a `Table::commit` can never
2934        // advance the watermark past an in-flight cross-table transaction's
2935        // lower assigned epoch whose writes are not yet applied (spec §9.3e).
2936        self.epoch.publish_in_order(new_epoch);
2937        self.current_txn_id += 1;
2938        self.data_generation = self.data_generation.wrapping_add(1);
2939        Ok(new_epoch)
2940    }
2941
2942    /// Mounted commit (B1/B2): mirror the cross-table sequencer. Seal a
2943    /// `TxnCommit` into the shared WAL under the WAL lock (assigning the epoch in
2944    /// WAL-append order), make it durable via the group-commit coordinator (one
2945    /// leader fsync for the whole batch), then apply the staged rows at the
2946    /// assigned epoch and publish in order. Honors the shared poison flag.
2947    fn commit_shared(&mut self) -> Result<Epoch> {
2948        use std::sync::atomic::Ordering;
2949        let s = match &self.wal {
2950            WalSink::Shared(s) => s.clone(),
2951            WalSink::Private(_) => unreachable!("commit_shared on a private sink"),
2952        };
2953        if s.poisoned.load(Ordering::Relaxed) {
2954            return Err(MongrelError::Other(
2955                "database poisoned by fsync error".into(),
2956            ));
2957        }
2958        // Serialize the whole single-table commit critical section (assign →
2959        // durable → publish) under the shared commit lock so concurrent
2960        // `Table::commit`s publish strictly in assigned order and each returns
2961        // only once its epoch is visible (read-your-writes after commit). The
2962        // fsync still defers to the group-commit coordinator, which can batch a
2963        // held commit with concurrent cross-table `transaction()` committers.
2964        let commit_lock = Arc::clone(&self.commit_lock);
2965        let _g = commit_lock.lock();
2966        // Always seal a txn (allocating an id if this span had no writes) so the
2967        // epoch advances monotonically like the standalone path.
2968        let txn_id = self.ensure_txn_id();
2969        let (new_epoch, commit_seq) = {
2970            let mut wal = s.wal.lock();
2971            let new_epoch = self.epoch.bump_assigned();
2972            let seq = wal.append_commit(txn_id, new_epoch, &[])?;
2973            (new_epoch, seq)
2974        };
2975        s.group
2976            .await_durable(&s.wal, commit_seq)
2977            .inspect_err(|_| s.poisoned.store(true, Ordering::Relaxed))?;
2978
2979        // Apply staged truncate/rows/tombstones at the real assigned epoch (B2): nothing
2980        // was stamped speculatively, and nothing is visible until publish below.
2981        if self.pending_truncate.take().is_some() {
2982            self.apply_truncate(new_epoch)?;
2983        }
2984        let mut rows = std::mem::take(&mut self.pending_rows);
2985        if !rows.is_empty() {
2986            for r in &mut rows {
2987                r.committed_epoch = new_epoch;
2988            }
2989            let auto_inc_flags = std::mem::take(&mut self.pending_rows_auto_inc);
2990            let all_auto_generated =
2991                auto_inc_flags.len() == rows.len() && auto_inc_flags.iter().all(|b| *b);
2992            self.apply_put_rows_inner(rows, !all_auto_generated)?;
2993        } else {
2994            self.pending_rows_auto_inc.clear();
2995        }
2996        let dels = std::mem::take(&mut self.pending_dels);
2997        for rid in dels {
2998            self.apply_delete(rid, new_epoch);
2999        }
3000
3001        self.invalidate_pending_cache();
3002        self.persist_manifest(new_epoch)?;
3003        self.epoch.publish_in_order(new_epoch);
3004        let _ = s.change_wake.send(());
3005        // Next auto-commit span allocates a fresh shared txn id.
3006        self.current_txn_id = 0;
3007        self.data_generation = self.data_generation.wrapping_add(1);
3008        Ok(new_epoch)
3009    }
3010
3011    /// Commit, then drain the memtable into the mutable-run LSM tier (Phase
3012    /// 11.1). The tier absorbs flushes in place and only spills to an immutable
3013    /// `.sr` sorted run once it crosses the spill watermark — coalescing many
3014    /// small flushes into fewer, larger runs. While the tier holds un-spilled
3015    /// data the WAL is **not** rotated: the Flush marker / WAL rotation is
3016    /// deferred until the data is durably in a run, so crash recovery replays
3017    /// those rows back into the memtable (the tier rebuilds from replay).
3018    pub fn flush(&mut self) -> Result<Epoch> {
3019        self.ensure_indexes_complete()?;
3020        let epoch = self.commit()?;
3021        let rows = self.memtable.drain_sorted();
3022        if !rows.is_empty() {
3023            self.mutable_run.insert_many(rows);
3024        }
3025        if self.mutable_run.approx_bytes() >= self.mutable_run_spill_bytes {
3026            self.spill_mutable_run(epoch)?;
3027            // The tier is now empty and its data is durably in a run → safe to
3028            // mark the WAL flushed (and, for a private WAL, rotate to a fresh
3029            // segment so the flushed records aren't replayed).
3030            self.mark_flushed(epoch)?;
3031            self.persist_manifest(epoch)?;
3032            self.build_learned_ranges()?;
3033            // Memtable is drained and runs are stable → checkpoint the indexes so
3034            // the next open skips the full run scan (Phase 9.1).
3035            self.checkpoint_indexes(epoch);
3036        }
3037        // else: data coalesced in the in-memory tier; the WAL still covers it
3038        // and the manifest epoch was already persisted by `commit`.
3039        Ok(epoch)
3040    }
3041
3042    /// Force a full flush to a `.sr` sorted run regardless of the spill
3043    /// threshold. Temporarily lowers `mutable_run_spill_bytes` to 1 so the
3044    /// threshold check in [`Self::flush`] always fires. Used by
3045    /// [`Self::close`] and the Kit's flush-on-close path (§4.4) so a
3046    /// short-lived process (CLI, one-shot script) leaves all pending writes
3047    /// durable in a run — keeping WAL segment count bounded across repeated
3048    /// invocations. Best-effort: errors are propagated but the threshold is
3049    /// always restored.
3050    pub fn force_flush(&mut self) -> Result<Epoch> {
3051        let saved = self.mutable_run_spill_bytes;
3052        self.mutable_run_spill_bytes = 1;
3053        let result = self.flush();
3054        self.mutable_run_spill_bytes = saved;
3055        result
3056    }
3057
3058    /// Best-effort close: force-flush any pending writes to a sorted run so
3059    /// the WAL segments can be reaped on the next open. Never panics — a
3060    /// flush error is logged and returned but the threshold is always
3061    /// restored. Call this as the last action before a short-lived process
3062    /// exits (CLI, one-shot script). Not needed for the daemon (its
3063    /// background auto-compactor handles run management). (§4.4)
3064    pub fn close(&mut self) -> Result<()> {
3065        if self.memtable_len() > 0 || self.mutable_run_len() > 0 {
3066            self.force_flush()?;
3067        }
3068        Ok(())
3069    }
3070
3071    /// Mark `epoch` as flushed: append a `Flush` marker to the WAL, advance
3072    /// `flushed_epoch`, and — for a private WAL only — rotate to a fresh segment
3073    /// so the now-durable-in-a-run records are not replayed. A mounted table's
3074    /// shared WAL is never rotated per-table; recovery skips its already-flushed
3075    /// records via the manifest `flushed_epoch` gate, and segment GC (B3c) reaps
3076    /// them once every table has flushed past them.
3077    fn mark_flushed(&mut self, epoch: Epoch) -> Result<()> {
3078        let op = Op::Flush {
3079            table_id: self.table_id,
3080            flushed_epoch: epoch.0,
3081        };
3082        match &mut self.wal {
3083            WalSink::Private(w) => {
3084                w.append_system(op)?;
3085                w.sync()?;
3086            }
3087            WalSink::Shared(s) => {
3088                // Informational in the shared log (recovery gates on the manifest
3089                // `flushed_epoch`); not separately fsynced — the run + manifest
3090                // are the durability point and the underlying rows were already
3091                // fsynced at their commit.
3092                s.wal.lock().append_system(op)?;
3093            }
3094        }
3095        self.flushed_epoch = epoch.0;
3096        if matches!(self.wal, WalSink::Private(_)) {
3097            self.rotate_wal(epoch)?;
3098        }
3099        Ok(())
3100    }
3101
3102    /// Spill the mutable-run tier to a new immutable level-0 sorted run. The
3103    /// caller owns the Flush-marker / WAL-rotation / manifest steps (only valid
3104    /// once all in-flight data is in runs). No-op when the tier is empty.
3105    fn spill_mutable_run(&mut self, epoch: Epoch) -> Result<()> {
3106        let rows = self.mutable_run.drain_sorted();
3107        if rows.is_empty() {
3108            return Ok(());
3109        }
3110        let run_id = self.next_run_id;
3111        self.next_run_id += 1;
3112        let path = self.run_path(run_id);
3113        let mut writer = RunWriter::new(&self.schema, run_id as u128, epoch, 0);
3114        if let Some(kek) = &self.kek {
3115            writer = writer.with_encryption(kek.as_ref(), self.indexable_column_specs());
3116        }
3117        let header = writer.write(&path, &rows)?;
3118        self.run_refs.push(RunRef {
3119            run_id: run_id as u128,
3120            level: 0,
3121            epoch_created: epoch.0,
3122            row_count: header.row_count,
3123        });
3124        Ok(())
3125    }
3126
3127    /// Tune the mutable-run spill watermark (bytes). A smaller threshold spills
3128    /// sooner (more, smaller runs — closer to the pre-Phase-11.1 behavior); a
3129    /// larger one coalesces more flushes in memory.
3130    pub fn set_mutable_run_spill_bytes(&mut self, bytes: u64) {
3131        self.mutable_run_spill_bytes = bytes.max(1);
3132    }
3133
3134    /// Set the zstd compression level for compaction output (Phase 18.1).
3135    /// Default 3; higher values give better compression ratio at the cost of
3136    /// slower compaction.
3137    pub fn set_compaction_zstd_level(&mut self, level: i32) {
3138        self.compaction_zstd_level = level;
3139    }
3140
3141    /// Set the result-cache byte budget (Phase 19.1 hardening (a)). Entries are
3142    /// evicted in access-order LRU past this limit. Takes effect immediately
3143    /// (may evict entries if the new limit is smaller than the current footprint).
3144    pub fn set_result_cache_max_bytes(&mut self, max_bytes: u64) {
3145        self.result_cache.lock().set_max_bytes(max_bytes);
3146    }
3147
3148    /// Drop every cached result (used by compaction, schema evolution, and bulk
3149    /// load — paths that change run layout or data without going through the
3150    /// fine-grained `pending_*` tracking).
3151    pub(crate) fn clear_result_cache(&mut self) {
3152        self.result_cache.lock().clear();
3153    }
3154
3155    /// Number of versions currently held in the mutable-run tier.
3156    pub fn mutable_run_len(&self) -> usize {
3157        self.mutable_run.len()
3158    }
3159
3160    /// Drain every version from the mutable-run tier (ascending `(RowId,
3161    /// Epoch)` order). Used by compaction to fold the tier into its merge.
3162    pub(crate) fn drain_mutable_run(&mut self) -> Vec<Row> {
3163        self.mutable_run.drain_sorted()
3164    }
3165
3166    /// Bulk-load: write `batch` directly to a new sorted run, bypassing the WAL
3167    /// and the memtable entirely (no per-row bincode, no skip-list inserts). The
3168    /// run + a rotated WAL + the manifest are fsynced once — the fast ingest
3169    /// path for large analytical loads. Indexes are still maintained.
3170    pub fn bulk_load(&mut self, batch: Vec<Vec<(u16, Value)>>) -> Result<Epoch> {
3171        let epoch = self.commit()?;
3172        let n = batch.len();
3173        if n == 0 {
3174            return Ok(epoch);
3175        }
3176        for row in &batch {
3177            self.schema.validate_values(row)?;
3178        }
3179        let live_before = self.live_count;
3180        // Spill any pending mutable-run data first: bulk_load writes a Flush
3181        // marker + rotates the WAL below, which is only safe once all in-flight
3182        // data is durably in a run.
3183        self.spill_mutable_run(epoch)?;
3184        let eager_index_build = self.index_build_policy == IndexBuildPolicy::Eager
3185            && self.indexes_complete
3186            && self.run_refs.is_empty()
3187            && self.memtable.is_empty()
3188            && self.mutable_run.is_empty();
3189        // Phase 14.7: route the legacy Value API through the same parallel
3190        // encode + typed batch-index path as `bulk_load_columns`. Transpose the
3191        // row-major sparse batch → column-major typed columns (in parallel),
3192        // then `write_native` + `index_columns_bulk`, instead of per-row
3193        // `Row { HashMap }` + `index_into` + the sequential `Value` writer.
3194        let mut user_columns: Vec<(u16, columnar::NativeColumn)> = {
3195            use rayon::prelude::*;
3196            self.schema
3197                .columns
3198                .par_iter()
3199                .map(|cdef| {
3200                    (
3201                        cdef.id,
3202                        columnar::rows_to_native(cdef.ty.clone(), &batch, cdef.id),
3203                    )
3204                })
3205                .collect::<Vec<_>>()
3206        };
3207        drop(batch);
3208        // Enforce NOT NULL constraints and primary-key upsert semantics before
3209        // any row id is allocated or bytes hit the run file. Losers of a
3210        // duplicate primary key are dropped from the encoded run entirely so
3211        // the dedup survives reopen (no ephemeral memtable tombstone).
3212        self.fill_auto_inc_native_columns(&mut user_columns, n)?;
3213        self.validate_columns_not_null(&user_columns, n)?;
3214        let winner_idx = self
3215            .bulk_pk_winner_indices(&user_columns, n)
3216            .filter(|idx| idx.len() != n);
3217        let (write_columns, write_n): (Vec<(u16, columnar::NativeColumn)>, usize) =
3218            match winner_idx.as_deref() {
3219                Some(idx) => {
3220                    let compacted = user_columns
3221                        .iter()
3222                        .map(|(id, c)| (*id, c.gather(idx)))
3223                        .collect();
3224                    (compacted, idx.len())
3225                }
3226                None => (std::mem::take(&mut user_columns), n),
3227            };
3228        self.advance_auto_inc_from_native_columns(&write_columns, write_n, live_before)?;
3229        let first = self.allocator.alloc_range(write_n as u64).0;
3230        for rid in first..first + write_n as u64 {
3231            self.reservoir.offer(rid);
3232        }
3233        let run_id = self.next_run_id;
3234        self.next_run_id += 1;
3235        let path = self.run_path(run_id);
3236        let mut writer = RunWriter::new(&self.schema, run_id as u128, epoch, 0)
3237            .clean(true)
3238            .with_lz4()
3239            .with_native_endian();
3240        if let Some(kek) = &self.kek {
3241            writer = writer.with_encryption(kek.as_ref(), self.indexable_column_specs());
3242        }
3243        let header = writer.write_native(&path, &write_columns, write_n, first)?;
3244        self.run_refs.push(RunRef {
3245            run_id: run_id as u128,
3246            level: 0,
3247            epoch_created: epoch.0,
3248            row_count: header.row_count,
3249        });
3250        self.live_count = self.live_count.saturating_add(write_n as u64);
3251        if eager_index_build {
3252            let row_ids: Vec<u64> = (first..first + write_n as u64).collect();
3253            self.index_columns_bulk(&write_columns, &row_ids);
3254            self.indexes_complete = true;
3255            self.build_learned_ranges()?;
3256        } else {
3257            self.indexes_complete = false;
3258        }
3259        self.mark_flushed(epoch)?;
3260        self.persist_manifest(epoch)?;
3261        if eager_index_build {
3262            self.checkpoint_indexes(epoch);
3263        }
3264        self.clear_result_cache();
3265        Ok(epoch)
3266    }
3267
3268    /// Rotate the private WAL to a fresh segment. Only valid for a standalone
3269    /// table — a mounted table never rotates the shared WAL per-table.
3270    fn rotate_wal(&mut self, epoch: Epoch) -> Result<()> {
3271        let segment = next_wal_segment(&self.dir.join(WAL_DIR))?;
3272        let cipher = self.wal_dek.as_ref().map(|dk| make_cipher(dk));
3273        // The segment number (from the filename) namespaces nonces under the
3274        // constant WAL DEK — pass it through to the writer.
3275        let segment_no = segment
3276            .file_stem()
3277            .and_then(|s| s.to_str())
3278            .and_then(|s| s.strip_prefix("seg-"))
3279            .and_then(|s| s.parse::<u64>().ok())
3280            .unwrap_or(0);
3281        let mut wal = Wal::create_with_cipher(segment, epoch, cipher, segment_no)?;
3282        wal.set_sync_byte_threshold(self.sync_byte_threshold);
3283        wal.sync()?;
3284        self.wal = WalSink::Private(wal);
3285        Ok(())
3286    }
3287
3288    /// Fine-grained result-cache invalidation (hardening (c)): drop only
3289    /// entries whose footprint intersects a deleted RowId or whose
3290    /// condition-columns intersect a mutated column, then clear the pending
3291    /// sets. Called by `commit` and the cross-table transaction path.
3292    pub(crate) fn invalidate_pending_cache(&mut self) {
3293        self.result_cache
3294            .lock()
3295            .invalidate(&self.pending_delete_rids, &self.pending_put_cols);
3296        self.pending_delete_rids.clear();
3297        self.pending_put_cols.clear();
3298    }
3299
3300    pub(crate) fn persist_manifest(&self, epoch: Epoch) -> Result<()> {
3301        let mut m = Manifest::new(self.table_id, self.schema.schema_id);
3302        m.current_epoch = epoch.0;
3303        m.next_row_id = self.allocator.current().0;
3304        m.runs = self.run_refs.clone();
3305        m.live_count = self.live_count;
3306        m.global_idx_epoch = self.global_idx_epoch;
3307        m.flushed_epoch = self.flushed_epoch;
3308        m.retiring = self.retiring.clone();
3309        // Persist the authoritative counter only when seeded; otherwise write 0
3310        // so the next open still scans `max(PK)` on first use (an unseeded
3311        // lower bound from WAL replay is not safe to trust across a flush).
3312        m.auto_inc_next = match self.auto_inc {
3313            Some(ai) if ai.seeded => ai.next,
3314            _ => 0,
3315        };
3316        m.ttl = self.ttl;
3317        let meta_dek = self.manifest_meta_dek();
3318        manifest::write_atomic(&self.dir, &mut m, meta_dek.as_ref())?;
3319        Ok(())
3320    }
3321
3322    /// Checkpoint the in-memory secondary indexes to `_idx/global.idx` and stamp
3323    /// the manifest's `global_idx_epoch` (Phase 9.1). Call after the runs are
3324    /// stable and the memtable is drained (flush/bulk-load/compact) so the
3325    /// checkpoint exactly matches the run data; subsequent [`Table::open`] loads it
3326    /// directly instead of scanning every run.
3327    pub(crate) fn checkpoint_indexes(&mut self, epoch: Epoch) {
3328        // Never persist an incomplete index set (e.g. after bulk_load_columns,
3329        // which bypasses per-row indexing) — reopen rebuilds from the runs.
3330        if !self.indexes_complete {
3331            return;
3332        }
3333        let snap = global_idx::IndexSnapshot {
3334            hot: &self.hot,
3335            bitmap: &self.bitmap,
3336            ann: &self.ann,
3337            fm: &self.fm,
3338            sparse: &self.sparse,
3339            minhash: &self.minhash,
3340            learned_range: &self.learned_range,
3341        };
3342        // Best-effort: a failed checkpoint just means the next open rebuilds.
3343        let idx_dek = self.idx_dek();
3344        if global_idx::write_atomic(&self.dir, self.table_id, epoch.0, snap, idx_dek.as_deref())
3345            .is_ok()
3346        {
3347            self.global_idx_epoch = epoch.0;
3348            let _ = self.persist_manifest(epoch);
3349        }
3350    }
3351
3352    /// Drop any on-disk index checkpoint so the next open rebuilds from runs
3353    /// (used when the live indexes are known stale, e.g. compaction to empty).
3354    pub(crate) fn invalidate_index_checkpoint(&mut self) {
3355        self.global_idx_epoch = 0;
3356        global_idx::remove(&self.dir);
3357        let _ = self.persist_manifest(self.epoch.visible());
3358    }
3359
3360    pub(crate) fn mark_indexes_incomplete(&mut self) {
3361        self.indexes_complete = false;
3362        self.invalidate_index_checkpoint();
3363    }
3364
3365    /// Read the row at `row_id` visible to `snapshot`, merging the newest
3366    /// version across the memtable and all sorted runs.
3367    pub fn get(&self, row_id: RowId, snapshot: Snapshot) -> Option<Row> {
3368        let mut best: Option<(Epoch, Row)> = self.memtable.get_version(row_id, snapshot.epoch);
3369        if let Some((epoch, row)) = self.mutable_run.get_version(row_id, snapshot.epoch) {
3370            if best.as_ref().map(|(be, _)| epoch > *be).unwrap_or(true) {
3371                best = Some((epoch, row));
3372            }
3373        }
3374        for rr in &self.run_refs {
3375            let Ok(mut reader) = self.open_reader(rr.run_id) else {
3376                continue;
3377            };
3378            let Ok(Some((epoch, row))) = reader.get_version(row_id, snapshot.epoch) else {
3379                continue;
3380            };
3381            if best.as_ref().map(|(be, _)| epoch > *be).unwrap_or(true) {
3382                best = Some((epoch, row));
3383            }
3384        }
3385        let now_nanos = unix_nanos_now();
3386        match best {
3387            Some((_, r)) if r.deleted || self.row_expired_at(&r, now_nanos) => None,
3388            Some((_, r)) => Some(r),
3389            None => None,
3390        }
3391    }
3392
3393    /// All rows visible at `snapshot` (newest version per `RowId`, tombstones
3394    /// dropped), merged across the memtable, the mutable-run tier, and all
3395    /// runs. Ascending `RowId`.
3396    pub fn visible_rows(&self, snapshot: Snapshot) -> Result<Vec<Row>> {
3397        let mut best: HashMap<u64, (Epoch, Row)> = HashMap::new();
3398        let mut fold = |row: Row| {
3399            best.entry(row.row_id.0)
3400                .and_modify(|e| {
3401                    if row.committed_epoch > e.0 {
3402                        *e = (row.committed_epoch, row.clone());
3403                    }
3404                })
3405                .or_insert_with(|| (row.committed_epoch, row));
3406        };
3407        for row in self.memtable.visible_versions(snapshot.epoch) {
3408            fold(row);
3409        }
3410        for row in self.mutable_run.visible_versions(snapshot.epoch) {
3411            fold(row);
3412        }
3413        for rr in &self.run_refs {
3414            let mut reader = self.open_reader(rr.run_id)?;
3415            for row in reader.visible_versions(snapshot.epoch)? {
3416                fold(row);
3417            }
3418        }
3419        let now_nanos = unix_nanos_now();
3420        let mut out: Vec<Row> = best
3421            .into_values()
3422            .filter_map(|(_, r)| {
3423                if r.deleted || self.row_expired_at(&r, now_nanos) {
3424                    None
3425                } else {
3426                    Some(r)
3427                }
3428            })
3429            .collect();
3430        out.sort_by_key(|r| r.row_id);
3431        Ok(out)
3432    }
3433
3434    /// Visible data as columns (column_id → values) rather than rows — the
3435    /// vectorized scan path. Fast path: when the memtable is empty and there is
3436    /// exactly one run (the common post-flush analytical case), it computes the
3437    /// visible index set once and gathers each column, with **no per-row
3438    /// `HashMap`/`Row` materialization**. Falls back to [`Self::visible_rows`]
3439    /// pivoted to columns when the memtable is live or runs overlap.
3440    pub fn visible_columns(&self, snapshot: Snapshot) -> Result<Vec<(u16, Vec<Value>)>> {
3441        if self.ttl.is_none()
3442            && self.memtable.is_empty()
3443            && self.mutable_run.is_empty()
3444            && self.run_refs.len() == 1
3445        {
3446            let rr = self.run_refs[0].clone();
3447            let mut reader = self.open_reader(rr.run_id)?;
3448            let idxs = reader.visible_indices(snapshot.epoch)?;
3449            let mut cols = Vec::with_capacity(self.schema.columns.len());
3450            for cdef in &self.schema.columns {
3451                cols.push((cdef.id, reader.gather_column(cdef.id, &idxs)?));
3452            }
3453            return Ok(cols);
3454        }
3455        // Fallback: row merge, then pivot to columns.
3456        let rows = self.visible_rows(snapshot)?;
3457        let mut cols: Vec<(u16, Vec<Value>)> = self
3458            .schema
3459            .columns
3460            .iter()
3461            .map(|c| (c.id, Vec::with_capacity(rows.len())))
3462            .collect();
3463        for r in &rows {
3464            for (cid, vec) in cols.iter_mut() {
3465                vec.push(r.columns.get(cid).cloned().unwrap_or(Value::Null));
3466            }
3467        }
3468        Ok(cols)
3469    }
3470
3471    /// Resolve a primary-key value to a row id (latest version).
3472    pub fn lookup_pk(&self, key: &[u8]) -> Option<RowId> {
3473        let row_id = self.hot.get(key)?;
3474        if self.ttl.is_none() || self.get(row_id, Snapshot::at(Epoch(u64::MAX))).is_some() {
3475            Some(row_id)
3476        } else {
3477            None
3478        }
3479    }
3480
3481    /// Run a conjunctive query over the shared row-id space: each condition
3482    /// yields a candidate row-id set, the sets are intersected, and the
3483    /// survivors are materialized at the current snapshot. This is the AI-native
3484    /// "compose primitives" surface (`semsearch ∩ fm_contains ∩ cat_in`).
3485    pub fn query(&mut self, q: &crate::query::Query) -> Result<Vec<Row>> {
3486        self.query_at_with_allowed(q, self.snapshot(), None)
3487    }
3488
3489    /// Execute a conjunctive query at one snapshot, applying authorization
3490    /// before ranked ANN, Sparse, and MinHash top-k selection.
3491    pub fn query_at_with_allowed(
3492        &mut self,
3493        q: &crate::query::Query,
3494        snapshot: Snapshot,
3495        allowed: Option<&std::collections::HashSet<RowId>>,
3496    ) -> Result<Vec<Row>> {
3497        self.require_select()?;
3498        self.ensure_indexes_complete()?;
3499        if q.conditions.len() > crate::query::MAX_HARD_CONDITIONS {
3500            return Err(MongrelError::InvalidArgument(format!(
3501                "query exceeds {} conditions",
3502                crate::query::MAX_HARD_CONDITIONS
3503            )));
3504        }
3505        if let Some(limit) = q.limit {
3506            if limit == 0 || limit > crate::query::MAX_FINAL_LIMIT {
3507                return Err(MongrelError::InvalidArgument(format!(
3508                    "query limit must be between 1 and {}",
3509                    crate::query::MAX_FINAL_LIMIT
3510                )));
3511            }
3512        }
3513        self.query_conditions_at(&q.conditions, snapshot, allowed, q.limit, q.offset)
3514    }
3515
3516    /// Unbounded internal SQL join helper. Public request surfaces must use
3517    /// [`Self::query_at_with_allowed`] and its result ceiling.
3518    #[doc(hidden)]
3519    pub fn query_all_at(
3520        &mut self,
3521        conditions: &[crate::query::Condition],
3522        snapshot: Snapshot,
3523    ) -> Result<Vec<Row>> {
3524        self.require_select()?;
3525        self.ensure_indexes_complete()?;
3526        if conditions.len() > crate::query::MAX_HARD_CONDITIONS {
3527            return Err(MongrelError::InvalidArgument(format!(
3528                "query exceeds {} conditions",
3529                crate::query::MAX_HARD_CONDITIONS
3530            )));
3531        }
3532        self.query_conditions_at(conditions, snapshot, None, None, 0)
3533    }
3534
3535    fn query_conditions_at(
3536        &self,
3537        conditions: &[crate::query::Condition],
3538        snapshot: Snapshot,
3539        allowed: Option<&std::collections::HashSet<RowId>>,
3540        limit: Option<usize>,
3541        offset: usize,
3542    ) -> Result<Vec<Row>> {
3543        crate::trace::QueryTrace::record(|t| {
3544            t.run_count = self.run_refs.len();
3545            t.memtable_rows = self.memtable.len();
3546            t.mutable_run_rows = self.mutable_run.len();
3547        });
3548        // A conjunction with no predicates matches every visible row (the
3549        // documented "Empty ⇒ all rows" contract); `intersect_sets` of zero
3550        // sets would otherwise wrongly yield the empty set.
3551        if conditions.is_empty() {
3552            crate::trace::QueryTrace::record(|t| {
3553                t.scan_mode = crate::trace::ScanMode::Materialized;
3554                t.row_materialized = true;
3555            });
3556            let mut rows = self.visible_rows(snapshot)?;
3557            if let Some(allowed) = allowed {
3558                rows.retain(|row| allowed.contains(&row.row_id));
3559            }
3560            rows.drain(..offset.min(rows.len()));
3561            if let Some(limit) = limit {
3562                rows.truncate(limit);
3563            }
3564            return Ok(rows);
3565        }
3566        crate::trace::QueryTrace::record(|t| {
3567            t.conditions_pushed = conditions.len();
3568            t.scan_mode = crate::trace::ScanMode::Materialized;
3569            t.row_materialized = true;
3570        });
3571        // §5.5: resolve conditions CHEAP-FIRST and early-exit the moment a
3572        // condition yields an empty survivor set. Previously every condition
3573        // (including an expensive range/FM page scan) was resolved before
3574        // `intersect_many` noticed an empty set; now a selective bitmap/PK that
3575        // eliminates all rows short-circuits the rest. Correctness is unchanged
3576        // (intersection with an empty set is empty either way).
3577        let mut ordered: Vec<&crate::query::Condition> = conditions.iter().collect();
3578        ordered.sort_by_key(|c| condition_cost_rank(c));
3579        let mut sets: Vec<RowIdSet> = Vec::with_capacity(ordered.len());
3580        for c in &ordered {
3581            let s = self.resolve_condition_with_allowed(c, snapshot, allowed)?;
3582            let empty = s.is_empty();
3583            sets.push(s);
3584            if empty {
3585                break;
3586            }
3587        }
3588        let mut rids = RowIdSet::intersect_many(sets).into_sorted_vec();
3589        if let Some(allowed) = allowed {
3590            rids.retain(|row_id| allowed.contains(&RowId(*row_id)));
3591        }
3592        rids.drain(..offset.min(rids.len()));
3593        if let Some(limit) = limit {
3594            rids.truncate(limit);
3595        }
3596        self.rows_for_rids(&rids, snapshot)
3597    }
3598
3599    /// Return an index's ordered candidates without discarding scores.
3600    pub fn retrieve(
3601        &mut self,
3602        retriever: &crate::query::Retriever,
3603    ) -> Result<Vec<crate::query::RetrieverHit>> {
3604        self.retrieve_with_allowed(retriever, None)
3605    }
3606
3607    pub fn retrieve_at(
3608        &mut self,
3609        retriever: &crate::query::Retriever,
3610        snapshot: Snapshot,
3611        allowed: Option<&std::collections::HashSet<RowId>>,
3612    ) -> Result<Vec<crate::query::RetrieverHit>> {
3613        self.retrieve_at_with_allowed(retriever, snapshot, allowed)
3614    }
3615
3616    /// Scored retrieval restricted to caller-authorized row IDs. Core MVCC,
3617    /// tombstone, and TTL eligibility is always applied before ranking.
3618    pub fn retrieve_with_allowed(
3619        &mut self,
3620        retriever: &crate::query::Retriever,
3621        allowed: Option<&std::collections::HashSet<RowId>>,
3622    ) -> Result<Vec<crate::query::RetrieverHit>> {
3623        self.retrieve_at_with_allowed(retriever, self.snapshot(), allowed)
3624    }
3625
3626    pub fn retrieve_at_with_allowed(
3627        &mut self,
3628        retriever: &crate::query::Retriever,
3629        snapshot: Snapshot,
3630        allowed: Option<&std::collections::HashSet<RowId>>,
3631    ) -> Result<Vec<crate::query::RetrieverHit>> {
3632        self.retrieve_at_with_allowed_and_context(retriever, snapshot, allowed, None)
3633    }
3634
3635    pub fn retrieve_at_with_allowed_and_context(
3636        &mut self,
3637        retriever: &crate::query::Retriever,
3638        snapshot: Snapshot,
3639        allowed: Option<&std::collections::HashSet<RowId>>,
3640        context: Option<&crate::query::AiExecutionContext>,
3641    ) -> Result<Vec<crate::query::RetrieverHit>> {
3642        self.require_select()?;
3643        self.ensure_indexes_complete()?;
3644        self.validate_retriever(retriever)?;
3645        self.retrieve_filtered(retriever, snapshot, None, allowed, None, context)
3646    }
3647
3648    pub fn retrieve_at_with_candidate_authorization_and_context(
3649        &mut self,
3650        retriever: &crate::query::Retriever,
3651        snapshot: Snapshot,
3652        authorization: Option<&crate::security::CandidateAuthorization<'_>>,
3653        context: Option<&crate::query::AiExecutionContext>,
3654    ) -> Result<Vec<crate::query::RetrieverHit>> {
3655        self.require_select()?;
3656        self.ensure_indexes_complete()?;
3657        self.validate_retriever(retriever)?;
3658        self.retrieve_filtered(retriever, snapshot, None, None, authorization, context)
3659    }
3660
3661    fn validate_retriever(&self, retriever: &crate::query::Retriever) -> Result<()> {
3662        use crate::query::{Retriever, MAX_RETRIEVER_K, MAX_SET_MEMBERS, MAX_SPARSE_TERMS};
3663        let (column_id, k) = match retriever {
3664            Retriever::Ann {
3665                column_id,
3666                query,
3667                k,
3668            } => {
3669                let index = self.ann.get(column_id).ok_or_else(|| {
3670                    MongrelError::InvalidArgument(format!("column {column_id} has no ANN index"))
3671                })?;
3672                if query.len() != index.dim() {
3673                    return Err(MongrelError::InvalidArgument(format!(
3674                        "ANN query dimension must be {}, got {}",
3675                        index.dim(),
3676                        query.len()
3677                    )));
3678                }
3679                if query.iter().any(|value| !value.is_finite()) {
3680                    return Err(MongrelError::InvalidArgument(
3681                        "ANN query values must be finite".into(),
3682                    ));
3683                }
3684                (*column_id, *k)
3685            }
3686            Retriever::Sparse {
3687                column_id,
3688                query,
3689                k,
3690            } => {
3691                if !self.sparse.contains_key(column_id) {
3692                    return Err(MongrelError::InvalidArgument(format!(
3693                        "column {column_id} has no Sparse index"
3694                    )));
3695                }
3696                if query.is_empty() || query.iter().any(|(_, weight)| !weight.is_finite()) {
3697                    return Err(MongrelError::InvalidArgument(
3698                        "Sparse query must be non-empty with finite weights".into(),
3699                    ));
3700                }
3701                if query.len() > MAX_SPARSE_TERMS {
3702                    return Err(MongrelError::InvalidArgument(format!(
3703                        "Sparse query exceeds {MAX_SPARSE_TERMS} terms"
3704                    )));
3705                }
3706                (*column_id, *k)
3707            }
3708            Retriever::MinHash {
3709                column_id,
3710                members,
3711                k,
3712            } => {
3713                if !self.minhash.contains_key(column_id) {
3714                    return Err(MongrelError::InvalidArgument(format!(
3715                        "column {column_id} has no MinHash index"
3716                    )));
3717                }
3718                if members.is_empty() {
3719                    return Err(MongrelError::InvalidArgument(
3720                        "MinHash members must not be empty".into(),
3721                    ));
3722                }
3723                if members.len() > MAX_SET_MEMBERS {
3724                    return Err(MongrelError::InvalidArgument(format!(
3725                        "MinHash query exceeds {MAX_SET_MEMBERS} members"
3726                    )));
3727                }
3728                (*column_id, *k)
3729            }
3730        };
3731        if k == 0 {
3732            return Err(MongrelError::InvalidArgument(
3733                "retriever k must be > 0".into(),
3734            ));
3735        }
3736        if k > MAX_RETRIEVER_K {
3737            return Err(MongrelError::InvalidArgument(format!(
3738                "retriever k exceeds {MAX_RETRIEVER_K}"
3739            )));
3740        }
3741        debug_assert!(self
3742            .schema
3743            .columns
3744            .iter()
3745            .any(|column| column.id == column_id));
3746        Ok(())
3747    }
3748
3749    fn validate_condition(&self, condition: &crate::query::Condition) -> Result<()> {
3750        use crate::query::Condition;
3751        match condition {
3752            Condition::Ann {
3753                column_id,
3754                query,
3755                k,
3756            } => self.validate_retriever(&crate::query::Retriever::Ann {
3757                column_id: *column_id,
3758                query: query.clone(),
3759                k: *k,
3760            }),
3761            Condition::SparseMatch {
3762                column_id,
3763                query,
3764                k,
3765            } => self.validate_retriever(&crate::query::Retriever::Sparse {
3766                column_id: *column_id,
3767                query: query.clone(),
3768                k: *k,
3769            }),
3770            Condition::MinHashSimilar {
3771                column_id,
3772                query,
3773                k,
3774            } => {
3775                if !self.minhash.contains_key(column_id) {
3776                    return Err(MongrelError::InvalidArgument(format!(
3777                        "column {column_id} has no MinHash index"
3778                    )));
3779                }
3780                if query.is_empty() || *k == 0 {
3781                    return Err(MongrelError::InvalidArgument(
3782                        "MinHash query must be non-empty and k must be > 0".into(),
3783                    ));
3784                }
3785                if query.len() > crate::query::MAX_SET_MEMBERS || *k > crate::query::MAX_RETRIEVER_K
3786                {
3787                    return Err(MongrelError::InvalidArgument(format!(
3788                        "MinHash query must have <= {} members and k <= {}",
3789                        crate::query::MAX_SET_MEMBERS,
3790                        crate::query::MAX_RETRIEVER_K
3791                    )));
3792                }
3793                Ok(())
3794            }
3795            Condition::BitmapIn { values, .. } if values.len() > crate::query::MAX_SET_MEMBERS => {
3796                Err(MongrelError::InvalidArgument(format!(
3797                    "bitmap IN exceeds {} values",
3798                    crate::query::MAX_SET_MEMBERS
3799                )))
3800            }
3801            Condition::FmContainsAll { patterns, .. }
3802                if patterns.len() > crate::query::MAX_HARD_CONDITIONS =>
3803            {
3804                Err(MongrelError::InvalidArgument(format!(
3805                    "FM query exceeds {} patterns",
3806                    crate::query::MAX_HARD_CONDITIONS
3807                )))
3808            }
3809            _ => Ok(()),
3810        }
3811    }
3812
3813    fn retrieve_filtered(
3814        &self,
3815        retriever: &crate::query::Retriever,
3816        snapshot: Snapshot,
3817        hard_filter: Option<&RowIdSet>,
3818        allowed: Option<&std::collections::HashSet<RowId>>,
3819        candidate_authorization: Option<&crate::security::CandidateAuthorization<'_>>,
3820        context: Option<&crate::query::AiExecutionContext>,
3821    ) -> Result<Vec<crate::query::RetrieverHit>> {
3822        use crate::query::{Retriever, RetrieverHit, RetrieverScore, SetMember};
3823        let started = std::time::Instant::now();
3824        let scored: Vec<(RowId, RetrieverScore)> = match retriever {
3825            Retriever::Ann {
3826                column_id,
3827                query,
3828                k,
3829            } => {
3830                let Some(index) = self.ann.get(column_id) else {
3831                    return Ok(Vec::new());
3832                };
3833                let cap = index.len();
3834                if cap == 0 {
3835                    return Ok(Vec::new());
3836                }
3837                let mut breadth = (*k).max(1).min(cap);
3838                let mut eligibility = std::collections::HashMap::new();
3839                let mut filtered = loop {
3840                    let mut seen = std::collections::HashSet::new();
3841                    if let Some(context) = context {
3842                        context.checkpoint()?;
3843                    }
3844                    let raw = index.search_with_context(query, breadth, context)?;
3845                    let unchecked: Vec<_> = raw
3846                        .iter()
3847                        .map(|(row_id, _)| *row_id)
3848                        .filter(|row_id| !eligibility.contains_key(row_id))
3849                        .filter(|row_id| {
3850                            hard_filter.map_or(true, |filter| filter.contains(row_id.0))
3851                                && allowed.map_or(true, |allowed| allowed.contains(row_id))
3852                        })
3853                        .collect();
3854                    let eligible = self.eligible_and_authorized_candidate_ids(
3855                        &unchecked,
3856                        *column_id,
3857                        snapshot,
3858                        candidate_authorization,
3859                        context,
3860                    )?;
3861                    for row_id in unchecked {
3862                        eligibility.insert(row_id, eligible.contains(&row_id));
3863                    }
3864                    let filtered: Vec<_> = raw
3865                        .into_iter()
3866                        .filter(|(row_id, _)| {
3867                            seen.insert(*row_id)
3868                                && eligibility.get(row_id).copied().unwrap_or(false)
3869                        })
3870                        .map(|(row_id, score)| (row_id, RetrieverScore::AnnHammingDistance(score)))
3871                        .collect();
3872                    if filtered.len() >= *k || breadth >= cap {
3873                        break filtered;
3874                    }
3875                    breadth = breadth.saturating_mul(2).min(cap);
3876                };
3877                filtered.truncate(*k);
3878                filtered
3879            }
3880            Retriever::Sparse {
3881                column_id,
3882                query,
3883                k,
3884            } => self
3885                .sparse
3886                .get(column_id)
3887                .map(|index| -> Result<Vec<_>> {
3888                    let mut breadth = (*k).max(1);
3889                    let mut eligibility = std::collections::HashMap::new();
3890                    loop {
3891                        if let Some(context) = context {
3892                            context.checkpoint()?;
3893                        }
3894                        let raw = index.search_with_context(query, breadth, context)?;
3895                        let unchecked: Vec<_> = raw
3896                            .iter()
3897                            .map(|(row_id, _)| *row_id)
3898                            .filter(|row_id| !eligibility.contains_key(row_id))
3899                            .filter(|row_id| {
3900                                hard_filter.map_or(true, |filter| filter.contains(row_id.0))
3901                                    && allowed.map_or(true, |allowed| allowed.contains(row_id))
3902                            })
3903                            .collect();
3904                        let eligible = self.eligible_and_authorized_candidate_ids(
3905                            &unchecked,
3906                            *column_id,
3907                            snapshot,
3908                            candidate_authorization,
3909                            context,
3910                        )?;
3911                        for row_id in unchecked {
3912                            eligibility.insert(row_id, eligible.contains(&row_id));
3913                        }
3914                        let filtered: Vec<_> = raw
3915                            .iter()
3916                            .filter(|(row_id, _)| eligibility.get(row_id).copied().unwrap_or(false))
3917                            .take(*k)
3918                            .map(|(row_id, score)| {
3919                                (*row_id, RetrieverScore::SparseDotProduct(*score))
3920                            })
3921                            .collect();
3922                        if filtered.len() >= *k || raw.len() < breadth {
3923                            break Ok(filtered);
3924                        }
3925                        let next = breadth.saturating_mul(2);
3926                        if next == breadth {
3927                            break Ok(filtered);
3928                        }
3929                        breadth = next;
3930                    }
3931                })
3932                .transpose()?
3933                .unwrap_or_default(),
3934            Retriever::MinHash {
3935                column_id,
3936                members,
3937                k,
3938            } => self
3939                .minhash
3940                .get(column_id)
3941                .map(|index| -> Result<Vec<_>> {
3942                    let hashes: Vec<_> = members.iter().map(SetMember::hash_v1).collect();
3943                    let mut breadth = (*k).max(1);
3944                    let mut eligibility = std::collections::HashMap::new();
3945                    loop {
3946                        if let Some(context) = context {
3947                            context.checkpoint()?;
3948                        }
3949                        let raw = index.search_with_context(&hashes, breadth, context)?;
3950                        let unchecked: Vec<_> = raw
3951                            .iter()
3952                            .map(|(row_id, _)| *row_id)
3953                            .filter(|row_id| !eligibility.contains_key(row_id))
3954                            .filter(|row_id| {
3955                                hard_filter.map_or(true, |filter| filter.contains(row_id.0))
3956                                    && allowed.map_or(true, |allowed| allowed.contains(row_id))
3957                            })
3958                            .collect();
3959                        let eligible = self.eligible_and_authorized_candidate_ids(
3960                            &unchecked,
3961                            *column_id,
3962                            snapshot,
3963                            candidate_authorization,
3964                            context,
3965                        )?;
3966                        for row_id in unchecked {
3967                            eligibility.insert(row_id, eligible.contains(&row_id));
3968                        }
3969                        let filtered: Vec<_> = raw
3970                            .iter()
3971                            .filter(|(row_id, _)| eligibility.get(row_id).copied().unwrap_or(false))
3972                            .take(*k)
3973                            .map(|(row_id, score)| {
3974                                (*row_id, RetrieverScore::MinHashEstimatedJaccard(*score))
3975                            })
3976                            .collect();
3977                        if filtered.len() >= *k || raw.len() < breadth {
3978                            break Ok(filtered);
3979                        }
3980                        let next = breadth.saturating_mul(2);
3981                        if next == breadth {
3982                            break Ok(filtered);
3983                        }
3984                        breadth = next;
3985                    }
3986                })
3987                .transpose()?
3988                .unwrap_or_default(),
3989        };
3990        let elapsed = started.elapsed().as_nanos() as u64;
3991        crate::trace::QueryTrace::record(|trace| {
3992            match retriever {
3993                Retriever::Ann { .. } => {
3994                    trace.ann_candidate_nanos = trace.ann_candidate_nanos.saturating_add(elapsed)
3995                }
3996                Retriever::Sparse { .. } => {
3997                    trace.sparse_candidate_nanos =
3998                        trace.sparse_candidate_nanos.saturating_add(elapsed)
3999                }
4000                Retriever::MinHash { .. } => {
4001                    trace.minhash_candidate_nanos =
4002                        trace.minhash_candidate_nanos.saturating_add(elapsed)
4003                }
4004            }
4005            trace.candidate_count = trace.candidate_count.saturating_add(scored.len());
4006        });
4007        Ok(scored
4008            .into_iter()
4009            .enumerate()
4010            .map(|(rank, (row_id, score))| RetrieverHit {
4011                row_id,
4012                rank: rank + 1,
4013                score,
4014            })
4015            .collect())
4016    }
4017
4018    fn eligible_candidate_ids(
4019        &self,
4020        candidates: &[RowId],
4021        _column_id: u16,
4022        snapshot: Snapshot,
4023        context: Option<&crate::query::AiExecutionContext>,
4024    ) -> Result<std::collections::HashSet<RowId>> {
4025        if !self.had_deletes
4026            && self.ttl.is_none()
4027            && self.pending_put_cols.is_empty()
4028            && snapshot.epoch == self.snapshot().epoch
4029        {
4030            return Ok(candidates.iter().copied().collect());
4031        }
4032        let mut readers: Vec<_> = self
4033            .run_refs
4034            .iter()
4035            .map(|run| self.open_reader(run.run_id))
4036            .collect::<Result<_>>()?;
4037        let now = context.map_or_else(unix_nanos_now, |context| context.query_time_nanos());
4038        let mut eligible = std::collections::HashSet::with_capacity(candidates.len());
4039        for &row_id in candidates {
4040            if let Some(context) = context {
4041                context.consume(1)?;
4042            }
4043            let mem = self.memtable.get_version(row_id, snapshot.epoch);
4044            let mutable = self.mutable_run.get_version(row_id, snapshot.epoch);
4045            let overlay = match (mem, mutable) {
4046                (Some(left), Some(right)) => Some(if left.0 >= right.0 { left } else { right }),
4047                (Some(value), None) | (None, Some(value)) => Some(value),
4048                (None, None) => None,
4049            };
4050            if let Some((_, row)) = overlay {
4051                if !row.deleted && !self.row_expired_at(&row, now) {
4052                    eligible.insert(row_id);
4053                }
4054                continue;
4055            }
4056            let mut best: Option<(Epoch, bool, usize)> = None;
4057            for (index, reader) in readers.iter_mut().enumerate() {
4058                if let Some((epoch, deleted)) =
4059                    reader.get_version_visibility(row_id, snapshot.epoch)?
4060                {
4061                    if best
4062                        .as_ref()
4063                        .map(|(best_epoch, ..)| epoch > *best_epoch)
4064                        .unwrap_or(true)
4065                    {
4066                        best = Some((epoch, deleted, index));
4067                    }
4068                }
4069            }
4070            let Some((_, false, reader_index)) = best else {
4071                continue;
4072            };
4073            if let Some(ttl) = self.ttl {
4074                if let Some((_, _, Some(Value::Int64(timestamp)))) = readers[reader_index]
4075                    .get_version_column(row_id, snapshot.epoch, ttl.column_id)?
4076                {
4077                    if timestamp.saturating_add(ttl.duration_nanos as i64) <= now {
4078                        continue;
4079                    }
4080                }
4081            }
4082            eligible.insert(row_id);
4083        }
4084        Ok(eligible)
4085    }
4086
4087    fn eligible_and_authorized_candidate_ids(
4088        &self,
4089        candidates: &[RowId],
4090        column_id: u16,
4091        snapshot: Snapshot,
4092        authorization: Option<&crate::security::CandidateAuthorization<'_>>,
4093        context: Option<&crate::query::AiExecutionContext>,
4094    ) -> Result<std::collections::HashSet<RowId>> {
4095        let eligible = self.eligible_candidate_ids(candidates, column_id, snapshot, context)?;
4096        let Some(authorization) = authorization else {
4097            return Ok(eligible);
4098        };
4099        let candidates: Vec<_> = eligible.into_iter().collect();
4100        self.policy_allowed_candidate_ids(&candidates, snapshot, authorization, context)
4101    }
4102
4103    fn policy_allowed_candidate_ids(
4104        &self,
4105        candidates: &[RowId],
4106        snapshot: Snapshot,
4107        authorization: &crate::security::CandidateAuthorization<'_>,
4108        context: Option<&crate::query::AiExecutionContext>,
4109    ) -> Result<std::collections::HashSet<RowId>> {
4110        let started = std::time::Instant::now();
4111        if candidates.is_empty()
4112            || authorization.principal.is_admin
4113            || !authorization.security.rls_enabled(authorization.table)
4114        {
4115            return Ok(candidates.iter().copied().collect());
4116        }
4117        if let Some(context) = context {
4118            context.checkpoint()?;
4119        }
4120        let row_ids: Vec<_> = candidates.iter().map(|row_id| row_id.0).collect();
4121        let mut rows: std::collections::HashMap<RowId, Row> = candidates
4122            .iter()
4123            .map(|row_id| {
4124                (
4125                    *row_id,
4126                    Row {
4127                        row_id: *row_id,
4128                        committed_epoch: snapshot.epoch,
4129                        columns: std::collections::HashMap::new(),
4130                        deleted: false,
4131                    },
4132                )
4133            })
4134            .collect();
4135        let columns = authorization
4136            .security
4137            .select_policy_columns(authorization.table, authorization.principal);
4138        let query_now = context.map_or_else(unix_nanos_now, |context| context.query_time_nanos());
4139        let mut decoded = 0usize;
4140        for column_id in &columns {
4141            if let Some(context) = context {
4142                context.checkpoint()?;
4143            }
4144            for (row_id, value) in self.values_for_rids_batch_at_with_context(
4145                &row_ids, *column_id, snapshot, query_now, context,
4146            )? {
4147                if let Some(row) = rows.get_mut(&row_id) {
4148                    row.columns.insert(*column_id, value);
4149                    decoded = decoded.saturating_add(1);
4150                }
4151            }
4152        }
4153        if let Some(context) = context {
4154            context.consume(candidates.len().saturating_add(decoded))?;
4155        }
4156        let allowed = rows
4157            .into_values()
4158            .filter_map(|row| {
4159                authorization
4160                    .security
4161                    .row_allowed(
4162                        authorization.table,
4163                        crate::security::PolicyCommand::Select,
4164                        &row,
4165                        authorization.principal,
4166                        false,
4167                    )
4168                    .then_some(row.row_id)
4169            })
4170            .collect();
4171        crate::trace::QueryTrace::record(|trace| {
4172            trace.rls_rows_evaluated = trace.rls_rows_evaluated.saturating_add(candidates.len());
4173            trace.rls_policy_columns_decoded =
4174                trace.rls_policy_columns_decoded.saturating_add(decoded);
4175            trace.authorization_nanos = trace
4176                .authorization_nanos
4177                .saturating_add(started.elapsed().as_nanos() as u64);
4178        });
4179        Ok(allowed)
4180    }
4181
4182    /// Filter-aware union and reciprocal-rank fusion over scored retrievers.
4183    pub fn search(
4184        &mut self,
4185        request: &crate::query::SearchRequest,
4186    ) -> Result<Vec<crate::query::SearchHit>> {
4187        self.search_with_allowed(request, None)
4188    }
4189
4190    pub fn search_at(
4191        &mut self,
4192        request: &crate::query::SearchRequest,
4193        snapshot: Snapshot,
4194        authorized: Option<&std::collections::HashSet<RowId>>,
4195    ) -> Result<Vec<crate::query::SearchHit>> {
4196        self.search_at_with_allowed(request, snapshot, authorized)
4197    }
4198
4199    pub fn search_with_allowed(
4200        &mut self,
4201        request: &crate::query::SearchRequest,
4202        authorized: Option<&std::collections::HashSet<RowId>>,
4203    ) -> Result<Vec<crate::query::SearchHit>> {
4204        self.search_at_with_allowed(request, self.snapshot(), authorized)
4205    }
4206
4207    pub fn search_at_with_allowed(
4208        &mut self,
4209        request: &crate::query::SearchRequest,
4210        snapshot: Snapshot,
4211        authorized: Option<&std::collections::HashSet<RowId>>,
4212    ) -> Result<Vec<crate::query::SearchHit>> {
4213        self.search_at_with_allowed_and_context(request, snapshot, authorized, None)
4214    }
4215
4216    pub fn search_at_with_allowed_and_context(
4217        &mut self,
4218        request: &crate::query::SearchRequest,
4219        snapshot: Snapshot,
4220        authorized: Option<&std::collections::HashSet<RowId>>,
4221        context: Option<&crate::query::AiExecutionContext>,
4222    ) -> Result<Vec<crate::query::SearchHit>> {
4223        self.search_at_with_filters_and_context(request, snapshot, authorized, None, context)
4224    }
4225
4226    pub fn search_at_with_candidate_authorization_and_context(
4227        &mut self,
4228        request: &crate::query::SearchRequest,
4229        snapshot: Snapshot,
4230        authorization: Option<&crate::security::CandidateAuthorization<'_>>,
4231        context: Option<&crate::query::AiExecutionContext>,
4232    ) -> Result<Vec<crate::query::SearchHit>> {
4233        self.search_at_with_filters_and_context(request, snapshot, None, authorization, context)
4234    }
4235
4236    fn search_at_with_filters_and_context(
4237        &mut self,
4238        request: &crate::query::SearchRequest,
4239        snapshot: Snapshot,
4240        authorized: Option<&std::collections::HashSet<RowId>>,
4241        candidate_authorization: Option<&crate::security::CandidateAuthorization<'_>>,
4242        context: Option<&crate::query::AiExecutionContext>,
4243    ) -> Result<Vec<crate::query::SearchHit>> {
4244        use crate::query::{
4245            ComponentScore, Condition, Fusion, SearchHit, MAX_FINAL_LIMIT, MAX_HARD_CONDITIONS,
4246            MAX_PROJECTION_COLUMNS, MAX_RETRIEVERS, MAX_RETRIEVER_WEIGHT,
4247        };
4248        let total_started = std::time::Instant::now();
4249        self.require_select()?;
4250        self.ensure_indexes_complete()?;
4251        if request.limit == 0 {
4252            return Err(MongrelError::InvalidArgument(
4253                "search limit must be > 0".into(),
4254            ));
4255        }
4256        if request.limit > MAX_FINAL_LIMIT {
4257            return Err(MongrelError::InvalidArgument(format!(
4258                "search limit exceeds {MAX_FINAL_LIMIT}"
4259            )));
4260        }
4261        if request.retrievers.is_empty() {
4262            return Err(MongrelError::InvalidArgument(
4263                "search requires at least one retriever".into(),
4264            ));
4265        }
4266        if request.retrievers.len() > MAX_RETRIEVERS {
4267            return Err(MongrelError::InvalidArgument(format!(
4268                "search exceeds {MAX_RETRIEVERS} retrievers"
4269            )));
4270        }
4271        if request.must.len() > MAX_HARD_CONDITIONS {
4272            return Err(MongrelError::InvalidArgument(format!(
4273                "search exceeds {MAX_HARD_CONDITIONS} hard conditions"
4274            )));
4275        }
4276        if request.must.iter().any(|condition| {
4277            matches!(
4278                condition,
4279                Condition::Ann { .. }
4280                    | Condition::SparseMatch { .. }
4281                    | Condition::MinHashSimilar { .. }
4282            )
4283        }) {
4284            return Err(MongrelError::InvalidArgument(
4285                "ranked ANN, Sparse, and MinHash conditions must be retrievers, not must filters"
4286                    .into(),
4287            ));
4288        }
4289        let mut names = std::collections::HashSet::new();
4290        for named in &request.retrievers {
4291            if named.name.is_empty() || !names.insert(named.name.as_str()) {
4292                return Err(MongrelError::InvalidArgument(
4293                    "retriever names must be non-empty and unique".into(),
4294                ));
4295            }
4296            if !named.weight.is_finite()
4297                || named.weight < 0.0
4298                || named.weight > MAX_RETRIEVER_WEIGHT
4299            {
4300                return Err(MongrelError::InvalidArgument(format!(
4301                    "retriever weight must be finite, non-negative, and <= {MAX_RETRIEVER_WEIGHT}"
4302                )));
4303            }
4304            self.validate_retriever(&named.retriever)?;
4305        }
4306        let projection = request
4307            .projection
4308            .clone()
4309            .unwrap_or_else(|| self.schema.columns.iter().map(|column| column.id).collect());
4310        if projection.len() > MAX_PROJECTION_COLUMNS {
4311            return Err(MongrelError::InvalidArgument(format!(
4312                "projection exceeds {MAX_PROJECTION_COLUMNS} columns"
4313            )));
4314        }
4315        for column_id in &projection {
4316            if !self
4317                .schema
4318                .columns
4319                .iter()
4320                .any(|column| column.id == *column_id)
4321            {
4322                return Err(MongrelError::ColumnNotFound(column_id.to_string()));
4323            }
4324        }
4325        if let Some(crate::query::Rerank::ExactVector {
4326            embedding_column,
4327            query,
4328            candidate_limit,
4329            weight,
4330            ..
4331        }) = &request.rerank
4332        {
4333            if *candidate_limit < request.limit || *candidate_limit > crate::query::MAX_RETRIEVER_K
4334            {
4335                return Err(MongrelError::InvalidArgument(format!(
4336                    "rerank candidate_limit must be between search limit and {}",
4337                    crate::query::MAX_RETRIEVER_K
4338                )));
4339            }
4340            if !weight.is_finite() || *weight < 0.0 || *weight > MAX_RETRIEVER_WEIGHT {
4341                return Err(MongrelError::InvalidArgument(format!(
4342                    "rerank weight must be finite, non-negative, and <= {MAX_RETRIEVER_WEIGHT}"
4343                )));
4344            }
4345            let column = self
4346                .schema
4347                .columns
4348                .iter()
4349                .find(|column| column.id == *embedding_column)
4350                .ok_or_else(|| MongrelError::ColumnNotFound(embedding_column.to_string()))?;
4351            let crate::schema::TypeId::Embedding { dim } = column.ty else {
4352                return Err(MongrelError::InvalidArgument(format!(
4353                    "rerank column {embedding_column} is not an embedding"
4354                )));
4355            };
4356            if query.len() != dim as usize || query.iter().any(|value| !value.is_finite()) {
4357                return Err(MongrelError::InvalidArgument(format!(
4358                    "rerank query must contain {dim} finite values"
4359                )));
4360            }
4361        }
4362
4363        let hard_filter_started = std::time::Instant::now();
4364        let hard_filter = if request.must.is_empty() {
4365            None
4366        } else {
4367            let mut sets = Vec::with_capacity(request.must.len());
4368            for condition in &request.must {
4369                if let Some(context) = context {
4370                    context.checkpoint()?;
4371                }
4372                sets.push(self.resolve_condition(condition, snapshot)?);
4373            }
4374            Some(RowIdSet::intersect_many(sets))
4375        };
4376        crate::trace::QueryTrace::record(|trace| {
4377            trace.hard_filter_nanos = trace
4378                .hard_filter_nanos
4379                .saturating_add(hard_filter_started.elapsed().as_nanos() as u64);
4380        });
4381        if hard_filter.as_ref().is_some_and(RowIdSet::is_empty) {
4382            return Ok(Vec::new());
4383        }
4384
4385        let constant = match request.fusion {
4386            Fusion::ReciprocalRank { constant } => constant,
4387        };
4388        let mut retrievers: Vec<_> = request.retrievers.iter().collect();
4389        retrievers.sort_by(|a, b| a.name.cmp(&b.name));
4390        let mut fusion_nanos = 0u64;
4391        let mut fused: std::collections::HashMap<RowId, (f64, Vec<ComponentScore>)> =
4392            std::collections::HashMap::new();
4393        for named in retrievers {
4394            if named.weight == 0.0 {
4395                continue;
4396            }
4397            if let Some(context) = context {
4398                context.checkpoint()?;
4399            }
4400            let hits = self.retrieve_filtered(
4401                &named.retriever,
4402                snapshot,
4403                hard_filter.as_ref(),
4404                authorized,
4405                candidate_authorization,
4406                context,
4407            )?;
4408            let fusion_started = std::time::Instant::now();
4409            for hit in hits {
4410                if let Some(context) = context {
4411                    context.consume(1)?;
4412                }
4413                let contribution = named.weight / (constant as f64 + hit.rank as f64);
4414                if !contribution.is_finite() {
4415                    return Err(MongrelError::InvalidArgument(
4416                        "retriever contribution must be finite".into(),
4417                    ));
4418                }
4419                let max_fused_candidates = context.map_or(
4420                    crate::query::MAX_FUSED_CANDIDATES,
4421                    crate::query::AiExecutionContext::max_fused_candidates,
4422                );
4423                if !fused.contains_key(&hit.row_id) && fused.len() >= max_fused_candidates {
4424                    return Err(MongrelError::WorkBudgetExceeded);
4425                }
4426                let entry = fused.entry(hit.row_id).or_default();
4427                entry.0 += contribution;
4428                if !entry.0.is_finite() {
4429                    return Err(MongrelError::InvalidArgument(
4430                        "fused score must be finite".into(),
4431                    ));
4432                }
4433                entry.1.push(ComponentScore {
4434                    retriever_name: named.name.clone(),
4435                    rank: hit.rank,
4436                    raw_score: hit.score,
4437                    contribution,
4438                });
4439            }
4440            fusion_nanos = fusion_nanos.saturating_add(fusion_started.elapsed().as_nanos() as u64);
4441        }
4442        let union_size = fused.len();
4443        let mut ranked: Vec<_> = fused
4444            .into_iter()
4445            .map(|(row_id, (fused_score, components))| {
4446                (row_id, fused_score, components, None, fused_score)
4447            })
4448            .collect();
4449        let order = |(a_row, _, _, _, a_score): &(
4450            RowId,
4451            f64,
4452            Vec<ComponentScore>,
4453            Option<f32>,
4454            f64,
4455        ),
4456                     (b_row, _, _, _, b_score): &(
4457            RowId,
4458            f64,
4459            Vec<ComponentScore>,
4460            Option<f32>,
4461            f64,
4462        )| { b_score.total_cmp(a_score).then_with(|| a_row.cmp(b_row)) };
4463        if let Some(crate::query::Rerank::ExactVector {
4464            embedding_column,
4465            query,
4466            metric,
4467            candidate_limit,
4468            weight,
4469        }) = &request.rerank
4470        {
4471            let fused_order = |(a_row, a_score, ..): &(
4472                RowId,
4473                f64,
4474                Vec<ComponentScore>,
4475                Option<f32>,
4476                f64,
4477            ),
4478                               (b_row, b_score, ..): &(
4479                RowId,
4480                f64,
4481                Vec<ComponentScore>,
4482                Option<f32>,
4483                f64,
4484            )| {
4485                b_score.total_cmp(a_score).then_with(|| a_row.cmp(b_row))
4486            };
4487            let selection_started = std::time::Instant::now();
4488            if let Some(context) = context {
4489                context.consume(ranked.len())?;
4490            }
4491            if ranked.len() > *candidate_limit {
4492                let (_, _, _) = ranked.select_nth_unstable_by(*candidate_limit, fused_order);
4493                ranked.truncate(*candidate_limit);
4494            }
4495            ranked.sort_by(fused_order);
4496            fusion_nanos =
4497                fusion_nanos.saturating_add(selection_started.elapsed().as_nanos() as u64);
4498            let row_ids: Vec<_> = ranked.iter().map(|(row_id, ..)| row_id.0).collect();
4499            if let Some(context) = context {
4500                context.consume(row_ids.len())?;
4501            }
4502            let query_now =
4503                context.map_or_else(unix_nanos_now, |context| context.query_time_nanos());
4504            let gather_started = std::time::Instant::now();
4505            let vectors = self.values_for_rids_batch_at_with_context(
4506                &row_ids,
4507                *embedding_column,
4508                snapshot,
4509                query_now,
4510                context,
4511            )?;
4512            let gather_nanos = gather_started.elapsed().as_nanos() as u64;
4513            let query_norm = query
4514                .iter()
4515                .map(|value| f64::from(*value).powi(2))
4516                .sum::<f64>()
4517                .sqrt();
4518            let score_started = std::time::Instant::now();
4519            let mut scores = std::collections::HashMap::with_capacity(vectors.len());
4520            for (row_id, value) in vectors {
4521                if let Some(context) = context {
4522                    context.consume(1)?;
4523                }
4524                let Value::Embedding(vector) = value else {
4525                    continue;
4526                };
4527                let dot = query
4528                    .iter()
4529                    .zip(&vector)
4530                    .map(|(left, right)| f64::from(*left) * f64::from(*right))
4531                    .sum::<f64>();
4532                let score = match metric {
4533                    crate::query::VectorMetric::DotProduct => dot,
4534                    crate::query::VectorMetric::Cosine => {
4535                        let norm = vector
4536                            .iter()
4537                            .map(|value| f64::from(*value).powi(2))
4538                            .sum::<f64>()
4539                            .sqrt();
4540                        if query_norm == 0.0 || norm == 0.0 {
4541                            0.0
4542                        } else {
4543                            dot / (query_norm * norm)
4544                        }
4545                    }
4546                    crate::query::VectorMetric::Euclidean => query
4547                        .iter()
4548                        .zip(&vector)
4549                        .map(|(left, right)| (f64::from(*left) - f64::from(*right)).powi(2))
4550                        .sum::<f64>()
4551                        .sqrt(),
4552                };
4553                if !score.is_finite() {
4554                    return Err(MongrelError::InvalidArgument(
4555                        "exact rerank score must be finite".into(),
4556                    ));
4557                }
4558                scores.insert(row_id, score as f32);
4559            }
4560            let mut reranked = Vec::with_capacity(ranked.len());
4561            for (row_id, fused_score, components, _, _) in ranked.drain(..) {
4562                let Some(score) = scores.get(&row_id).copied() else {
4563                    continue;
4564                };
4565                let ordering_score = match metric {
4566                    crate::query::VectorMetric::Euclidean => -f64::from(score),
4567                    crate::query::VectorMetric::Cosine | crate::query::VectorMetric::DotProduct => {
4568                        f64::from(score)
4569                    }
4570                };
4571                let final_score = fused_score + *weight * ordering_score;
4572                if !final_score.is_finite() {
4573                    return Err(MongrelError::InvalidArgument(
4574                        "final rerank score must be finite".into(),
4575                    ));
4576                }
4577                reranked.push((row_id, fused_score, components, Some(score), final_score));
4578            }
4579            ranked = reranked;
4580            ranked.sort_by(order);
4581            crate::trace::QueryTrace::record(|trace| {
4582                trace.exact_vector_gather_nanos =
4583                    trace.exact_vector_gather_nanos.saturating_add(gather_nanos);
4584                trace.exact_vector_score_nanos = trace
4585                    .exact_vector_score_nanos
4586                    .saturating_add(score_started.elapsed().as_nanos() as u64);
4587            });
4588        }
4589        let projection_started = std::time::Instant::now();
4590        let sentinel = projection
4591            .first()
4592            .copied()
4593            .or_else(|| self.schema.columns.first().map(|column| column.id));
4594        let query_now = context.map_or_else(unix_nanos_now, |context| context.query_time_nanos());
4595        let mut out = Vec::with_capacity(request.limit.min(ranked.len()));
4596        let mut projection_rows = 0usize;
4597        let mut projection_cells = 0usize;
4598        while out.len() < request.limit && !ranked.is_empty() {
4599            if let Some(context) = context {
4600                context.checkpoint()?;
4601                context.consume(ranked.len())?;
4602            }
4603            let needed = request.limit - out.len();
4604            let window_size = ranked
4605                .len()
4606                .min(needed.saturating_mul(2).max(needed.saturating_add(8)));
4607            let selection_started = std::time::Instant::now();
4608            let mut remainder = if ranked.len() > window_size {
4609                let (_, _, _) = ranked.select_nth_unstable_by(window_size, order);
4610                ranked.split_off(window_size)
4611            } else {
4612                Vec::new()
4613            };
4614            ranked.sort_by(order);
4615            fusion_nanos =
4616                fusion_nanos.saturating_add(selection_started.elapsed().as_nanos() as u64);
4617            let row_ids: Vec<_> = ranked.iter().map(|(row_id, ..)| row_id.0).collect();
4618            let gathered_columns = projection.len().max(usize::from(sentinel.is_some()));
4619            if let Some(context) = context {
4620                context.consume(row_ids.len().saturating_mul(gathered_columns))?;
4621            }
4622            projection_rows = projection_rows.saturating_add(row_ids.len());
4623            projection_cells =
4624                projection_cells.saturating_add(row_ids.len().saturating_mul(gathered_columns));
4625            let mut cells: std::collections::HashMap<RowId, std::collections::HashMap<u16, Value>> =
4626                std::collections::HashMap::new();
4627            if let Some(column_id) = sentinel {
4628                for (row_id, value) in self.values_for_rids_batch_at_with_context(
4629                    &row_ids, column_id, snapshot, query_now, context,
4630                )? {
4631                    cells.entry(row_id).or_default().insert(column_id, value);
4632                }
4633            }
4634            for &column_id in &projection {
4635                if Some(column_id) == sentinel {
4636                    continue;
4637                }
4638                for (row_id, value) in self.values_for_rids_batch_at_with_context(
4639                    &row_ids, column_id, snapshot, query_now, context,
4640                )? {
4641                    cells.entry(row_id).or_default().insert(column_id, value);
4642                }
4643            }
4644            for (row_id, fused_score, mut components, exact_rerank_score, final_score) in
4645                ranked.drain(..)
4646            {
4647                let Some(row_cells) = cells.remove(&row_id) else {
4648                    continue;
4649                };
4650                components.sort_by(|a, b| a.retriever_name.cmp(&b.retriever_name));
4651                let final_rank = out.len() + 1;
4652                out.push(SearchHit {
4653                    row_id,
4654                    cells: projection
4655                        .iter()
4656                        .filter_map(|column_id| {
4657                            row_cells
4658                                .get(column_id)
4659                                .cloned()
4660                                .map(|value| (*column_id, value))
4661                        })
4662                        .collect(),
4663                    components,
4664                    fused_score,
4665                    exact_rerank_score,
4666                    final_score,
4667                    final_rank,
4668                });
4669                if out.len() == request.limit {
4670                    break;
4671                }
4672            }
4673            ranked.append(&mut remainder);
4674        }
4675        crate::trace::QueryTrace::record(|trace| {
4676            trace.union_size = union_size;
4677            trace.fusion_nanos = trace.fusion_nanos.saturating_add(fusion_nanos);
4678            trace.projection_nanos = trace
4679                .projection_nanos
4680                .saturating_add(projection_started.elapsed().as_nanos() as u64);
4681            trace.total_nanos = trace
4682                .total_nanos
4683                .saturating_add(total_started.elapsed().as_nanos() as u64);
4684            trace.projection_rows = trace.projection_rows.saturating_add(projection_rows);
4685            trace.projection_cells = trace.projection_cells.saturating_add(projection_cells);
4686            if let Some(context) = context {
4687                trace.work_consumed = trace.work_consumed.saturating_add(context.consumed_work());
4688            }
4689        });
4690        Ok(out)
4691    }
4692
4693    /// MinHash candidate generation followed by exact Jaccard verification.
4694    /// An empty query set returns no hits.
4695    pub fn set_similarity(
4696        &mut self,
4697        request: &crate::query::SetSimilarityRequest,
4698    ) -> Result<Vec<crate::query::SetSimilarityHit>> {
4699        self.set_similarity_with_allowed(request, None)
4700    }
4701
4702    pub fn set_similarity_at(
4703        &mut self,
4704        request: &crate::query::SetSimilarityRequest,
4705        snapshot: Snapshot,
4706        allowed: Option<&std::collections::HashSet<RowId>>,
4707    ) -> Result<Vec<crate::query::SetSimilarityHit>> {
4708        self.set_similarity_explained_at(request, snapshot, allowed)
4709            .map(|(hits, _)| hits)
4710    }
4711
4712    /// Binary ANN candidate generation followed by exact float-vector reranking.
4713    pub fn ann_rerank(
4714        &mut self,
4715        request: &crate::query::AnnRerankRequest,
4716    ) -> Result<Vec<crate::query::AnnRerankHit>> {
4717        self.ann_rerank_with_allowed(request, None)
4718    }
4719
4720    pub fn ann_rerank_with_allowed(
4721        &mut self,
4722        request: &crate::query::AnnRerankRequest,
4723        allowed: Option<&std::collections::HashSet<RowId>>,
4724    ) -> Result<Vec<crate::query::AnnRerankHit>> {
4725        self.ann_rerank_at(request, self.snapshot(), allowed)
4726    }
4727
4728    pub fn ann_rerank_at(
4729        &mut self,
4730        request: &crate::query::AnnRerankRequest,
4731        snapshot: Snapshot,
4732        allowed: Option<&std::collections::HashSet<RowId>>,
4733    ) -> Result<Vec<crate::query::AnnRerankHit>> {
4734        self.ann_rerank_at_with_context(request, snapshot, allowed, None)
4735    }
4736
4737    pub fn ann_rerank_at_with_context(
4738        &mut self,
4739        request: &crate::query::AnnRerankRequest,
4740        snapshot: Snapshot,
4741        allowed: Option<&std::collections::HashSet<RowId>>,
4742        context: Option<&crate::query::AiExecutionContext>,
4743    ) -> Result<Vec<crate::query::AnnRerankHit>> {
4744        self.ann_rerank_at_with_filters_and_context(request, snapshot, allowed, None, context)
4745    }
4746
4747    pub fn ann_rerank_at_with_candidate_authorization_and_context(
4748        &mut self,
4749        request: &crate::query::AnnRerankRequest,
4750        snapshot: Snapshot,
4751        authorization: Option<&crate::security::CandidateAuthorization<'_>>,
4752        context: Option<&crate::query::AiExecutionContext>,
4753    ) -> Result<Vec<crate::query::AnnRerankHit>> {
4754        self.ann_rerank_at_with_filters_and_context(request, snapshot, None, authorization, context)
4755    }
4756
4757    fn ann_rerank_at_with_filters_and_context(
4758        &mut self,
4759        request: &crate::query::AnnRerankRequest,
4760        snapshot: Snapshot,
4761        allowed: Option<&std::collections::HashSet<RowId>>,
4762        candidate_authorization: Option<&crate::security::CandidateAuthorization<'_>>,
4763        context: Option<&crate::query::AiExecutionContext>,
4764    ) -> Result<Vec<crate::query::AnnRerankHit>> {
4765        use crate::query::{
4766            AnnRerankHit, Retriever, RetrieverScore, VectorMetric, MAX_FINAL_LIMIT, MAX_RETRIEVER_K,
4767        };
4768        if request.candidate_k == 0 || request.limit == 0 {
4769            return Err(MongrelError::InvalidArgument(
4770                "candidate_k and limit must be > 0".into(),
4771            ));
4772        }
4773        if request.candidate_k > MAX_RETRIEVER_K || request.limit > MAX_FINAL_LIMIT {
4774            return Err(MongrelError::InvalidArgument(format!(
4775                "candidate_k must be <= {MAX_RETRIEVER_K} and limit <= {MAX_FINAL_LIMIT}"
4776            )));
4777        }
4778        let retriever = Retriever::Ann {
4779            column_id: request.column_id,
4780            query: request.query.clone(),
4781            k: request.candidate_k,
4782        };
4783        self.require_select()?;
4784        self.ensure_indexes_complete()?;
4785        self.validate_retriever(&retriever)?;
4786        let hits = self.retrieve_filtered(
4787            &retriever,
4788            snapshot,
4789            None,
4790            allowed,
4791            candidate_authorization,
4792            context,
4793        )?;
4794        let distances: std::collections::HashMap<_, _> = hits
4795            .iter()
4796            .filter_map(|hit| match hit.score {
4797                RetrieverScore::AnnHammingDistance(distance) => Some((hit.row_id, distance)),
4798                _ => None,
4799            })
4800            .collect();
4801        let row_ids: Vec<_> = hits.iter().map(|hit| hit.row_id.0).collect();
4802        if let Some(context) = context {
4803            context.consume(row_ids.len())?;
4804        }
4805        let gather_started = std::time::Instant::now();
4806        let query_now = context.map_or_else(unix_nanos_now, |context| context.query_time_nanos());
4807        let values = self.values_for_rids_batch_at_with_context(
4808            &row_ids,
4809            request.column_id,
4810            snapshot,
4811            query_now,
4812            context,
4813        )?;
4814        let gather_nanos = gather_started.elapsed().as_nanos() as u64;
4815        let score_started = std::time::Instant::now();
4816        let query_norm = request
4817            .query
4818            .iter()
4819            .map(|value| f64::from(*value).powi(2))
4820            .sum::<f64>()
4821            .sqrt();
4822        let mut reranked = Vec::with_capacity(values.len().min(request.limit));
4823        for (row_id, value) in values {
4824            if let Some(context) = context {
4825                context.consume(1)?;
4826            }
4827            let Value::Embedding(vector) = value else {
4828                continue;
4829            };
4830            let dot = request
4831                .query
4832                .iter()
4833                .zip(&vector)
4834                .map(|(left, right)| f64::from(*left) * f64::from(*right))
4835                .sum::<f64>();
4836            let exact_score = match request.metric {
4837                VectorMetric::DotProduct => dot,
4838                VectorMetric::Cosine => {
4839                    let norm = vector
4840                        .iter()
4841                        .map(|value| f64::from(*value).powi(2))
4842                        .sum::<f64>()
4843                        .sqrt();
4844                    if query_norm == 0.0 || norm == 0.0 {
4845                        0.0
4846                    } else {
4847                        dot / (query_norm * norm)
4848                    }
4849                }
4850                VectorMetric::Euclidean => request
4851                    .query
4852                    .iter()
4853                    .zip(&vector)
4854                    .map(|(left, right)| (f64::from(*left) - f64::from(*right)).powi(2))
4855                    .sum::<f64>()
4856                    .sqrt(),
4857            };
4858            let exact_score = exact_score as f32;
4859            if !exact_score.is_finite() {
4860                return Err(MongrelError::InvalidArgument(
4861                    "exact ANN score must be finite".into(),
4862                ));
4863            }
4864            reranked.push(AnnRerankHit {
4865                row_id,
4866                hamming_distance: distances.get(&row_id).copied().unwrap_or_default(),
4867                exact_score,
4868            });
4869        }
4870        reranked.sort_by(|left, right| {
4871            let score = match request.metric {
4872                VectorMetric::Euclidean => left.exact_score.total_cmp(&right.exact_score),
4873                VectorMetric::Cosine | VectorMetric::DotProduct => {
4874                    right.exact_score.total_cmp(&left.exact_score)
4875                }
4876            };
4877            score.then_with(|| left.row_id.cmp(&right.row_id))
4878        });
4879        reranked.truncate(request.limit);
4880        crate::trace::QueryTrace::record(|trace| {
4881            trace.exact_vector_gather_nanos =
4882                trace.exact_vector_gather_nanos.saturating_add(gather_nanos);
4883            trace.exact_vector_score_nanos = trace
4884                .exact_vector_score_nanos
4885                .saturating_add(score_started.elapsed().as_nanos() as u64);
4886        });
4887        Ok(reranked)
4888    }
4889
4890    pub fn set_similarity_with_allowed(
4891        &mut self,
4892        request: &crate::query::SetSimilarityRequest,
4893        allowed: Option<&std::collections::HashSet<RowId>>,
4894    ) -> Result<Vec<crate::query::SetSimilarityHit>> {
4895        self.set_similarity_explained_at(request, self.snapshot(), allowed)
4896            .map(|(hits, _)| hits)
4897    }
4898
4899    pub fn set_similarity_explained(
4900        &mut self,
4901        request: &crate::query::SetSimilarityRequest,
4902    ) -> Result<(
4903        Vec<crate::query::SetSimilarityHit>,
4904        crate::query::SetSimilarityTrace,
4905    )> {
4906        self.set_similarity_explained_at(request, self.snapshot(), None)
4907    }
4908
4909    fn set_similarity_explained_at(
4910        &mut self,
4911        request: &crate::query::SetSimilarityRequest,
4912        snapshot: Snapshot,
4913        allowed: Option<&std::collections::HashSet<RowId>>,
4914    ) -> Result<(
4915        Vec<crate::query::SetSimilarityHit>,
4916        crate::query::SetSimilarityTrace,
4917    )> {
4918        self.set_similarity_explained_at_with_context(request, snapshot, allowed, None, None)
4919    }
4920
4921    pub fn set_similarity_at_with_context(
4922        &mut self,
4923        request: &crate::query::SetSimilarityRequest,
4924        snapshot: Snapshot,
4925        allowed: Option<&std::collections::HashSet<RowId>>,
4926        context: Option<&crate::query::AiExecutionContext>,
4927    ) -> Result<Vec<crate::query::SetSimilarityHit>> {
4928        self.set_similarity_explained_at_with_context(request, snapshot, allowed, None, context)
4929            .map(|(hits, _)| hits)
4930    }
4931
4932    pub fn set_similarity_at_with_candidate_authorization_and_context(
4933        &mut self,
4934        request: &crate::query::SetSimilarityRequest,
4935        snapshot: Snapshot,
4936        authorization: Option<&crate::security::CandidateAuthorization<'_>>,
4937        context: Option<&crate::query::AiExecutionContext>,
4938    ) -> Result<Vec<crate::query::SetSimilarityHit>> {
4939        self.set_similarity_explained_at_with_context(
4940            request,
4941            snapshot,
4942            None,
4943            authorization,
4944            context,
4945        )
4946        .map(|(hits, _)| hits)
4947    }
4948
4949    fn set_similarity_explained_at_with_context(
4950        &mut self,
4951        request: &crate::query::SetSimilarityRequest,
4952        snapshot: Snapshot,
4953        allowed: Option<&std::collections::HashSet<RowId>>,
4954        candidate_authorization: Option<&crate::security::CandidateAuthorization<'_>>,
4955        context: Option<&crate::query::AiExecutionContext>,
4956    ) -> Result<(
4957        Vec<crate::query::SetSimilarityHit>,
4958        crate::query::SetSimilarityTrace,
4959    )> {
4960        use crate::query::{
4961            Retriever, RetrieverScore, SetSimilarityHit, MAX_FINAL_LIMIT, MAX_RETRIEVER_K,
4962            MAX_SET_MEMBERS,
4963        };
4964        let mut trace = crate::query::SetSimilarityTrace::default();
4965        if request.members.is_empty() {
4966            return Ok((Vec::new(), trace));
4967        }
4968        if request.candidate_k == 0 || request.limit == 0 {
4969            return Err(MongrelError::InvalidArgument(
4970                "candidate_k and limit must be > 0".into(),
4971            ));
4972        }
4973        if request.candidate_k > MAX_RETRIEVER_K
4974            || request.limit > MAX_FINAL_LIMIT
4975            || request.members.len() > MAX_SET_MEMBERS
4976        {
4977            return Err(MongrelError::InvalidArgument(format!(
4978                "candidate_k must be <= {MAX_RETRIEVER_K}, limit <= {MAX_FINAL_LIMIT}, and members <= {MAX_SET_MEMBERS}"
4979            )));
4980        }
4981        if !request.min_jaccard.is_finite() || !(0.0..=1.0).contains(&request.min_jaccard) {
4982            return Err(MongrelError::InvalidArgument(
4983                "min_jaccard must be finite and between 0 and 1".into(),
4984            ));
4985        }
4986        let started = std::time::Instant::now();
4987        let retriever = Retriever::MinHash {
4988            column_id: request.column_id,
4989            members: request.members.clone(),
4990            k: request.candidate_k,
4991        };
4992        self.require_select()?;
4993        self.ensure_indexes_complete()?;
4994        self.validate_retriever(&retriever)?;
4995        let hits = self.retrieve_filtered(
4996            &retriever,
4997            snapshot,
4998            None,
4999            allowed,
5000            candidate_authorization,
5001            context,
5002        )?;
5003        trace.candidate_generation_us = started.elapsed().as_micros() as u64;
5004        trace.candidate_count = hits.len();
5005        let row_ids: Vec<_> = hits.iter().map(|hit| hit.row_id.0).collect();
5006        if let Some(context) = context {
5007            context.consume(row_ids.len())?;
5008        }
5009        let started = std::time::Instant::now();
5010        let query_now = context.map_or_else(unix_nanos_now, |context| context.query_time_nanos());
5011        let values = self.values_for_rids_batch_at_with_context(
5012            &row_ids,
5013            request.column_id,
5014            snapshot,
5015            query_now,
5016            context,
5017        )?;
5018        trace.gather_us = started.elapsed().as_micros() as u64;
5019        let query: std::collections::HashSet<_> = request.members.iter().cloned().collect();
5020        let estimates: std::collections::HashMap<_, _> = hits
5021            .into_iter()
5022            .filter_map(|hit| match hit.score {
5023                RetrieverScore::MinHashEstimatedJaccard(score) => Some((hit.row_id, score)),
5024                _ => None,
5025            })
5026            .collect();
5027        let started = std::time::Instant::now();
5028        let parsed: Vec<_> = values
5029            .into_iter()
5030            .filter_map(|(row_id, value)| {
5031                let Value::Bytes(bytes) = value else {
5032                    return None;
5033                };
5034                let Ok(serde_json::Value::Array(members)) = serde_json::from_slice(&bytes) else {
5035                    return None;
5036                };
5037                let stored = members
5038                    .into_iter()
5039                    .filter_map(|member| match member {
5040                        serde_json::Value::String(value) => {
5041                            Some(crate::query::SetMember::String(value))
5042                        }
5043                        serde_json::Value::Number(value) => {
5044                            Some(crate::query::SetMember::Number(value))
5045                        }
5046                        serde_json::Value::Bool(value) => {
5047                            Some(crate::query::SetMember::Boolean(value))
5048                        }
5049                        _ => None,
5050                    })
5051                    .collect::<std::collections::HashSet<_>>();
5052                Some((row_id, stored))
5053            })
5054            .collect();
5055        trace.parse_us = started.elapsed().as_micros() as u64;
5056        trace.verified_count = parsed.len();
5057        let started = std::time::Instant::now();
5058        let mut exact = Vec::new();
5059        for (row_id, stored) in parsed {
5060            if let Some(context) = context {
5061                context.consume(1)?;
5062            }
5063            let union = query.union(&stored).count();
5064            let score = if union == 0 {
5065                1.0
5066            } else {
5067                query.intersection(&stored).count() as f32 / union as f32
5068            };
5069            if score >= request.min_jaccard {
5070                exact.push(SetSimilarityHit {
5071                    row_id,
5072                    estimated_jaccard: estimates.get(&row_id).copied().unwrap_or_default(),
5073                    exact_jaccard: score,
5074                });
5075            }
5076        }
5077        exact.sort_by(|a, b| {
5078            b.exact_jaccard
5079                .total_cmp(&a.exact_jaccard)
5080                .then_with(|| a.row_id.cmp(&b.row_id))
5081        });
5082        exact.truncate(request.limit);
5083        trace.score_us = started.elapsed().as_micros() as u64;
5084        crate::trace::QueryTrace::record(|query_trace| {
5085            query_trace.exact_set_gather_nanos = query_trace
5086                .exact_set_gather_nanos
5087                .saturating_add(trace.gather_us.saturating_mul(1_000));
5088            query_trace.exact_set_parse_nanos = query_trace
5089                .exact_set_parse_nanos
5090                .saturating_add(trace.parse_us.saturating_mul(1_000));
5091            query_trace.exact_set_score_nanos = query_trace
5092                .exact_set_score_nanos
5093                .saturating_add(trace.score_us.saturating_mul(1_000));
5094        });
5095        Ok((exact, trace))
5096    }
5097
5098    /// Fetch one column for visible row ids without decoding unrelated columns.
5099    fn values_for_rids_batch_at(
5100        &self,
5101        row_ids: &[u64],
5102        column_id: u16,
5103        snapshot: Snapshot,
5104        now: i64,
5105    ) -> Result<Vec<(RowId, Value)>> {
5106        if self.ttl.is_none()
5107            && self.memtable.is_empty()
5108            && self.mutable_run.is_empty()
5109            && self.run_refs.len() == 1
5110        {
5111            let mut reader = self.open_reader(self.run_refs[0].run_id)?;
5112            let (positions, visible_row_ids) =
5113                reader.visible_positions_with_rids(snapshot.epoch)?;
5114            let requested: Vec<(RowId, usize)> = row_ids
5115                .iter()
5116                .filter_map(|raw| {
5117                    visible_row_ids
5118                        .binary_search(&(*raw as i64))
5119                        .ok()
5120                        .map(|index| (RowId(*raw), positions[index]))
5121                })
5122                .collect();
5123            let values = reader.gather_column(
5124                column_id,
5125                &requested
5126                    .iter()
5127                    .map(|(_, position)| *position)
5128                    .collect::<Vec<_>>(),
5129            )?;
5130            return Ok(requested
5131                .into_iter()
5132                .zip(values)
5133                .map(|((row_id, _), value)| (row_id, value))
5134                .collect());
5135        }
5136        self.values_for_rids_at(row_ids, column_id, snapshot, now)
5137    }
5138
5139    fn values_for_rids_batch_at_with_context(
5140        &self,
5141        row_ids: &[u64],
5142        column_id: u16,
5143        snapshot: Snapshot,
5144        now: i64,
5145        context: Option<&crate::query::AiExecutionContext>,
5146    ) -> Result<Vec<(RowId, Value)>> {
5147        let Some(context) = context else {
5148            return self.values_for_rids_batch_at(row_ids, column_id, snapshot, now);
5149        };
5150        let mut values = Vec::with_capacity(row_ids.len());
5151        for chunk in row_ids.chunks(256) {
5152            context.checkpoint()?;
5153            values.extend(self.values_for_rids_batch_at(chunk, column_id, snapshot, now)?);
5154        }
5155        Ok(values)
5156    }
5157
5158    /// Fetch one column for visible row ids without decoding unrelated columns.
5159    fn values_for_rids_at(
5160        &self,
5161        row_ids: &[u64],
5162        column_id: u16,
5163        snapshot: Snapshot,
5164        now: i64,
5165    ) -> Result<Vec<(RowId, Value)>> {
5166        let mut readers: Vec<_> = self
5167            .run_refs
5168            .iter()
5169            .map(|run| self.open_reader(run.run_id))
5170            .collect::<Result<_>>()?;
5171        let mut out = Vec::with_capacity(row_ids.len());
5172        for &raw_row_id in row_ids {
5173            let row_id = RowId(raw_row_id);
5174            let mem = self.memtable.get_version(row_id, snapshot.epoch);
5175            let mutable = self.mutable_run.get_version(row_id, snapshot.epoch);
5176            let overlay = match (mem, mutable) {
5177                (Some((a_epoch, a)), Some((b_epoch, b))) => Some(if a_epoch >= b_epoch {
5178                    (a_epoch, a)
5179                } else {
5180                    (b_epoch, b)
5181                }),
5182                (Some(value), None) | (None, Some(value)) => Some(value),
5183                (None, None) => None,
5184            };
5185            if let Some((_, row)) = overlay {
5186                if !row.deleted && !self.row_expired_at(&row, now) {
5187                    if let Some(value) = row.columns.get(&column_id) {
5188                        out.push((row_id, value.clone()));
5189                    }
5190                }
5191                continue;
5192            }
5193
5194            let mut best: Option<(Epoch, bool, Option<Value>, usize)> = None;
5195            for (index, reader) in readers.iter_mut().enumerate() {
5196                if let Some((epoch, deleted, value)) =
5197                    reader.get_version_column(row_id, snapshot.epoch, column_id)?
5198                {
5199                    if best
5200                        .as_ref()
5201                        .map(|(best_epoch, ..)| epoch > *best_epoch)
5202                        .unwrap_or(true)
5203                    {
5204                        best = Some((epoch, deleted, value, index));
5205                    }
5206                }
5207            }
5208            let Some((_, false, Some(value), reader_index)) = best else {
5209                continue;
5210            };
5211            if let Some(ttl) = self.ttl {
5212                if ttl.column_id != column_id {
5213                    if let Some((_, _, Some(Value::Int64(timestamp)))) = readers[reader_index]
5214                        .get_version_column(row_id, snapshot.epoch, ttl.column_id)?
5215                    {
5216                        if timestamp.saturating_add(ttl.duration_nanos as i64) <= now {
5217                            continue;
5218                        }
5219                    }
5220                } else if let Value::Int64(timestamp) = value {
5221                    if timestamp.saturating_add(ttl.duration_nanos as i64) <= now {
5222                        continue;
5223                    }
5224                }
5225            }
5226            out.push((row_id, value));
5227        }
5228        Ok(out)
5229    }
5230
5231    /// Materialize the MVCC-visible, non-deleted rows for `rids` at `snapshot`,
5232    /// preserving the input order. Rows whose newest visible version is a
5233    /// tombstone, or that no longer exist, are omitted. Shared by index-served
5234    /// [`query`] and the Phase 8.1 FK-join path.
5235    pub fn rows_for_rids(&self, rids: &[u64], snapshot: Snapshot) -> Result<Vec<Row>> {
5236        self.rows_for_rids_at_time(rids, snapshot, unix_nanos_now())
5237    }
5238
5239    pub fn rows_for_rids_with_context(
5240        &self,
5241        rids: &[u64],
5242        snapshot: Snapshot,
5243        context: &crate::query::AiExecutionContext,
5244    ) -> Result<Vec<Row>> {
5245        context.consume(rids.len().saturating_mul(self.schema.columns.len()))?;
5246        self.rows_for_rids_at_time(rids, snapshot, context.query_time_nanos())
5247    }
5248
5249    fn rows_for_rids_at_time(
5250        &self,
5251        rids: &[u64],
5252        snapshot: Snapshot,
5253        ttl_now: i64,
5254    ) -> Result<Vec<Row>> {
5255        use std::collections::HashMap;
5256        let mut rows = Vec::with_capacity(rids.len());
5257        // Overlay (memtable + mutable-run) newest visible version per rid —
5258        // these shadow any stale version stored in a run. A rid may have an
5259        // older version in the mutable-run tier and a newer one in the memtable
5260        // (an update after a flush), so keep the **newest by epoch** across both
5261        // tiers, not whichever is inserted last.
5262        //
5263        // `rids` is already index-resolved (the caller's condition set), so it
5264        // is normally tiny relative to the memtable/mutable-run tiers — a
5265        // single-row PK/unique check feeding insert/update/delete resolves to
5266        // 0 or 1 rid. Materializing every version in both tiers (the old
5267        // behavior) cost O(tier size) regardless, which meant an unrelated
5268        // full-table-sized scan (plus the drop cost of the resulting map) on
5269        // every point lookup once the table grew large. Below the crossover,
5270        // a direct per-rid probe (`get_version`, O(log tier size) each) wins;
5271        // once `rids` approaches tier size, one linear materializing pass
5272        // beats `rids.len()` separate probes, so fall back to it.
5273        let tier_size = self.memtable.len() + self.mutable_run.len();
5274        let mut overlay: HashMap<u64, Row> = HashMap::with_capacity(rids.len());
5275        if rids.len().saturating_mul(24) < tier_size {
5276            for &rid in rids {
5277                let mem = self.memtable.get_version(RowId(rid), snapshot.epoch);
5278                let mrun = self.mutable_run.get_version(RowId(rid), snapshot.epoch);
5279                let newest = match (mem, mrun) {
5280                    (Some((me, mr)), Some((re, rr))) => Some(if me >= re { mr } else { rr }),
5281                    (Some((_, mr)), None) => Some(mr),
5282                    (None, Some((_, rr))) => Some(rr),
5283                    (None, None) => None,
5284                };
5285                if let Some(row) = newest {
5286                    overlay.insert(rid, row);
5287                }
5288            }
5289        } else {
5290            let fold_newest = |row: Row, overlay: &mut HashMap<u64, Row>| {
5291                overlay
5292                    .entry(row.row_id.0)
5293                    .and_modify(|e| {
5294                        if row.committed_epoch > e.committed_epoch {
5295                            *e = row.clone();
5296                        }
5297                    })
5298                    .or_insert(row);
5299            };
5300            for row in self.memtable.visible_versions(snapshot.epoch) {
5301                fold_newest(row, &mut overlay);
5302            }
5303            for row in self.mutable_run.visible_versions(snapshot.epoch) {
5304                fold_newest(row, &mut overlay);
5305            }
5306        }
5307        if self.run_refs.len() == 1 {
5308            let mut reader = self.open_reader(self.run_refs[0].run_id)?;
5309            // Same crossover as the overlay above: `visible_positions_with_rids`
5310            // decodes/scans the run's *entire* row-id column regardless of
5311            // `rids.len()`, so a point lookup (0 or 1 rid, the common
5312            // insert/update/delete case) paid an O(run size) tax for a single
5313            // row. Below the crossover, `get_version`'s page-pruned lookup
5314            // (`SYS_ROW_ID` pages carry exact row-id bounds) resolves each rid
5315            // by decoding only its page, no whole-column decode.
5316            if rids.len().saturating_mul(24) < reader.row_count() {
5317                for &rid in rids {
5318                    if let Some(r) = overlay.get(&rid) {
5319                        if !r.deleted {
5320                            rows.push(r.clone());
5321                        }
5322                        continue;
5323                    }
5324                    if let Some((_, row)) = reader.get_version(RowId(rid), snapshot.epoch)? {
5325                        if !row.deleted {
5326                            rows.push(row);
5327                        }
5328                    }
5329                }
5330                rows.retain(|row| !self.row_expired_at(row, ttl_now));
5331                return Ok(rows);
5332            }
5333            // Phase 16.3b: decode the system columns ONCE (via the clean-run-
5334            // shortcut visibility pass) and binary-search each requested rid,
5335            // instead of `get_version`-per-rid which re-decoded + cloned the
5336            // full system columns on every call (the ~350 ms native-query tax).
5337            // Phase 16.3b finish: batch the survivor positions into ONE
5338            // `materialize_batch` call so user columns are decoded once each via
5339            // the typed, page-cached path (not a per-rid `Vec<Value>` decode +
5340            // `.cloned()`).
5341            let (positions, vis_rids) = reader.visible_positions_with_rids(snapshot.epoch)?;
5342            // First pass: classify each input rid (overlay / run position /
5343            // not-found), recording the run positions to fetch in input order.
5344            enum Src {
5345                Overlay,
5346                Run,
5347            }
5348            let mut plan: Vec<Src> = Vec::with_capacity(rids.len());
5349            let mut fetch: Vec<usize> = Vec::with_capacity(rids.len());
5350            for rid in rids {
5351                if overlay.contains_key(rid) {
5352                    plan.push(Src::Overlay);
5353                    continue;
5354                }
5355                match vis_rids.binary_search(&(*rid as i64)) {
5356                    Ok(i) => {
5357                        plan.push(Src::Run);
5358                        fetch.push(positions[i]);
5359                    }
5360                    Err(_) => { /* not found — omitted from output */ }
5361                }
5362            }
5363            let fetched = reader.materialize_batch(&fetch)?;
5364            let mut fetched_iter = fetched.into_iter();
5365            for (rid, src) in rids.iter().zip(plan) {
5366                match src {
5367                    Src::Overlay => {
5368                        if let Some(r) = overlay.get(rid) {
5369                            if !r.deleted {
5370                                rows.push(r.clone());
5371                            }
5372                        }
5373                    }
5374                    Src::Run => {
5375                        if let Some(row) = fetched_iter.next() {
5376                            if !row.deleted {
5377                                rows.push(row);
5378                            }
5379                        }
5380                    }
5381                }
5382            }
5383            rows.retain(|row| !self.row_expired_at(row, ttl_now));
5384            return Ok(rows);
5385        }
5386        // Multi-run: one reader per run; newest visible version across all runs
5387        // + the overlay. (Per-rid `get_version` here is unavoidable without a
5388        // cross-run merge, but multi-run is the uncommon cold case.)
5389        let mut readers: Vec<_> = self
5390            .run_refs
5391            .iter()
5392            .map(|rr| self.open_reader(rr.run_id))
5393            .collect::<Result<Vec<_>>>()?;
5394        for rid in rids {
5395            if let Some(r) = overlay.get(rid) {
5396                if !r.deleted {
5397                    rows.push(r.clone());
5398                }
5399                continue;
5400            }
5401            let mut best: Option<(Epoch, Row)> = None;
5402            for reader in readers.iter_mut() {
5403                if let Ok(Some((epoch, row))) = reader.get_version(RowId(*rid), snapshot.epoch) {
5404                    if best.as_ref().map(|(be, _)| epoch > *be).unwrap_or(true) {
5405                        best = Some((epoch, row));
5406                    }
5407                }
5408            }
5409            if let Some((_, r)) = best {
5410                if !r.deleted {
5411                    rows.push(r);
5412                }
5413            }
5414        }
5415        rows.retain(|row| !self.row_expired_at(row, ttl_now));
5416        Ok(rows)
5417    }
5418
5419    /// Resolve the referencing (FK) side of a primary-key ↔ foreign-key join as
5420    /// a row-id set (Phase 8.1): union the roaring-bitmap entries of
5421    /// `fk_column_id` for every value in `pk_values` — the surviving
5422    /// primary-key values — then intersect with `fk_conditions`, i.e. any
5423    /// FK-side predicates (`ann_search ∩ fm_contains`, bitmap equality, range,
5424    /// …). Returns the survivor row-ids ascending. Requires a bitmap index on
5425    /// `fk_column_id`; returns an empty set when there is none.
5426    /// Whether live indexes are complete (Phase 14.7 + 17.2: the broadcast
5427    /// join path checks this before using the HOT index).
5428    pub fn indexes_complete(&self) -> bool {
5429        self.indexes_complete
5430    }
5431
5432    /// Where bulk loads put the index-build cost (see [`IndexBuildPolicy`]).
5433    pub fn index_build_policy(&self) -> IndexBuildPolicy {
5434        self.index_build_policy
5435    }
5436
5437    /// Set the bulk-load index-build policy. Takes effect on the next
5438    /// `bulk_load` / `bulk_load_columns` / `bulk_load_fast`; never changes
5439    /// already-built indexes.
5440    pub fn set_index_build_policy(&mut self, policy: IndexBuildPolicy) {
5441        self.index_build_policy = policy;
5442    }
5443
5444    /// Phase 17.2: broadcast join — return the distinct values in this table's
5445    /// bitmap index for `column_id` that also exist as a key in `pk_db`'s HOT
5446    /// index. Avoids loading the entire PK table when the FK column has low
5447    /// cardinality. Returns `None` if no bitmap index exists for the column.
5448    pub fn broadcast_join_values(&self, column_id: u16, pk_db: &Table) -> Option<Vec<Vec<u8>>> {
5449        // A deferred bulk load leaves the bitmap unbuilt — its (empty) key set
5450        // would silently produce an empty join. Decline; the caller falls back
5451        // to the PK-side query path, which completes indexes lazily.
5452        if !self.indexes_complete {
5453            return None;
5454        }
5455        let b = self.bitmap.get(&column_id)?;
5456        let result: Vec<Vec<u8>> = b
5457            .keys()
5458            .into_iter()
5459            .filter(|k| pk_db.hot.get(k.as_slice()).is_some())
5460            .cloned()
5461            .collect();
5462        Some(result)
5463    }
5464
5465    pub fn fk_join_row_ids(
5466        &self,
5467        fk_column_id: u16,
5468        pk_values: &[Vec<u8>],
5469        fk_conditions: &[crate::query::Condition],
5470        snapshot: Snapshot,
5471    ) -> Result<Vec<u64>> {
5472        let Some(b) = self.bitmap.get(&fk_column_id) else {
5473            return Ok(Vec::new());
5474        };
5475        let mut join_set = {
5476            let mut acc = roaring::RoaringBitmap::new();
5477            for v in pk_values {
5478                acc |= b.get(v);
5479            }
5480            RowIdSet::from_roaring(acc)
5481        };
5482        if !fk_conditions.is_empty() {
5483            let mut sets: Vec<RowIdSet> = Vec::with_capacity(fk_conditions.len() + 1);
5484            sets.push(join_set);
5485            for c in fk_conditions {
5486                sets.push(self.resolve_condition(c, snapshot)?);
5487            }
5488            join_set = RowIdSet::intersect_many(sets);
5489        }
5490        Ok(join_set.into_sorted_vec())
5491    }
5492
5493    /// Like [`fk_join_row_ids`] but returns only the **cardinality** of the FK
5494    /// survivor set — without materializing or sorting it. For a bare
5495    /// `COUNT(*)` join with no FK-side filter this is O(1) on the bitmap union
5496    /// (Phase 17.4): the prior path built a `HashSet<u64>` + `Vec<u64>` +
5497    /// `sort_unstable` over up to N rows only to read `.len()`.
5498    pub fn fk_join_count(
5499        &self,
5500        fk_column_id: u16,
5501        pk_values: &[Vec<u8>],
5502        fk_conditions: &[crate::query::Condition],
5503        snapshot: Snapshot,
5504    ) -> Result<u64> {
5505        let Some(b) = self.bitmap.get(&fk_column_id) else {
5506            return Ok(0);
5507        };
5508        let mut acc = roaring::RoaringBitmap::new();
5509        for v in pk_values {
5510            acc |= b.get(v);
5511        }
5512        if fk_conditions.is_empty() {
5513            return Ok(acc.len());
5514        }
5515        let mut sets: Vec<RowIdSet> = Vec::with_capacity(fk_conditions.len() + 1);
5516        sets.push(RowIdSet::from_roaring(acc));
5517        for c in fk_conditions {
5518            sets.push(self.resolve_condition(c, snapshot)?);
5519        }
5520        Ok(RowIdSet::intersect_many(sets).len() as u64)
5521    }
5522
5523    /// Resolve a single condition to its row-id set. Index-served conditions use
5524    /// the in-memory indexes; `Range`/`RangeF64` prefer the learned (PGM) index
5525    /// or the reader's page-index-skipping path on the single-run fast path, and
5526    /// only fall back to a `visible_rows` scan off the fast path (multi-run).
5527    fn resolve_condition(
5528        &self,
5529        c: &crate::query::Condition,
5530        snapshot: Snapshot,
5531    ) -> Result<RowIdSet> {
5532        self.resolve_condition_with_allowed(c, snapshot, None)
5533    }
5534
5535    fn resolve_condition_with_allowed(
5536        &self,
5537        c: &crate::query::Condition,
5538        snapshot: Snapshot,
5539        allowed: Option<&std::collections::HashSet<RowId>>,
5540    ) -> Result<RowIdSet> {
5541        use crate::query::Condition;
5542        self.validate_condition(c)?;
5543        Ok(match c {
5544            Condition::Pk(key) => {
5545                let lookup = self
5546                    .schema
5547                    .primary_key()
5548                    .map(|pk| self.index_lookup_key_bytes(pk.id, key))
5549                    .unwrap_or_else(|| key.clone());
5550                self.hot
5551                    .get(&lookup)
5552                    .map(|r| RowIdSet::one(r.0))
5553                    .unwrap_or_else(RowIdSet::empty)
5554            }
5555            Condition::BitmapEq { column_id, value } => {
5556                let lookup = self.index_lookup_key_bytes(*column_id, value);
5557                self.bitmap
5558                    .get(column_id)
5559                    .map(|b| RowIdSet::from_roaring(b.get(&lookup)))
5560                    .unwrap_or_else(RowIdSet::empty)
5561            }
5562            Condition::BitmapIn { column_id, values } => {
5563                let bm = self.bitmap.get(column_id);
5564                let mut acc = roaring::RoaringBitmap::new();
5565                if let Some(b) = bm {
5566                    for v in values {
5567                        let lookup = self.index_lookup_key_bytes(*column_id, v);
5568                        acc |= b.get(&lookup);
5569                    }
5570                }
5571                RowIdSet::from_roaring(acc)
5572            }
5573            Condition::BytesPrefix { column_id, prefix } => {
5574                // §5.6: enumerate bitmap keys sharing the prefix for an exact
5575                // prefix match (anchored `LIKE 'prefix%'`), tighter than the
5576                // FM substring superset. The caller only emits this when the
5577                // column has a bitmap index.
5578                if let Some(b) = self.bitmap.get(column_id) {
5579                    let lookup_prefix = self.index_lookup_key_bytes(*column_id, prefix);
5580                    let mut acc = roaring::RoaringBitmap::new();
5581                    for key in b.keys() {
5582                        if key.starts_with(&lookup_prefix) {
5583                            acc |= b.get(key);
5584                        }
5585                    }
5586                    RowIdSet::from_roaring(acc)
5587                } else {
5588                    RowIdSet::empty()
5589                }
5590            }
5591            Condition::FmContains { column_id, pattern } => self
5592                .fm
5593                .get(column_id)
5594                .map(|f| {
5595                    RowIdSet::from_unsorted(f.locate(pattern).into_iter().map(|r| r.0).collect())
5596                })
5597                .unwrap_or_else(RowIdSet::empty),
5598            Condition::FmContainsAll {
5599                column_id,
5600                patterns,
5601            } => {
5602                // Multi-segment intersection (Priority 12): resolve each segment
5603                // via FM and intersect — much tighter than the single longest.
5604                if let Some(f) = self.fm.get(column_id) {
5605                    let sets: Vec<RowIdSet> = patterns
5606                        .iter()
5607                        .map(|pat| {
5608                            RowIdSet::from_unsorted(
5609                                f.locate(pat).into_iter().map(|r| r.0).collect(),
5610                            )
5611                        })
5612                        .collect();
5613                    RowIdSet::intersect_many(sets)
5614                } else {
5615                    RowIdSet::empty()
5616                }
5617            }
5618            Condition::Ann {
5619                column_id,
5620                query,
5621                k,
5622            } => RowIdSet::from_unsorted(
5623                self.retrieve_filtered(
5624                    &crate::query::Retriever::Ann {
5625                        column_id: *column_id,
5626                        query: query.clone(),
5627                        k: *k,
5628                    },
5629                    snapshot,
5630                    None,
5631                    allowed,
5632                    None,
5633                    None,
5634                )?
5635                .into_iter()
5636                .map(|hit| hit.row_id.0)
5637                .collect(),
5638            ),
5639            Condition::SparseMatch {
5640                column_id,
5641                query,
5642                k,
5643            } => RowIdSet::from_unsorted(
5644                self.retrieve_filtered(
5645                    &crate::query::Retriever::Sparse {
5646                        column_id: *column_id,
5647                        query: query.clone(),
5648                        k: *k,
5649                    },
5650                    snapshot,
5651                    None,
5652                    allowed,
5653                    None,
5654                    None,
5655                )?
5656                .into_iter()
5657                .map(|hit| hit.row_id.0)
5658                .collect(),
5659            ),
5660            Condition::MinHashSimilar {
5661                column_id,
5662                query,
5663                k,
5664            } => match self.minhash.get(column_id) {
5665                Some(index) => {
5666                    let candidates = index.candidate_row_ids(query);
5667                    let eligible =
5668                        self.eligible_candidate_ids(&candidates, *column_id, snapshot, None)?;
5669                    RowIdSet::from_unsorted(
5670                        index
5671                            .search_filtered(query, *k, |row_id| {
5672                                eligible.contains(&row_id)
5673                                    && allowed.map_or(true, |allowed| allowed.contains(&row_id))
5674                            })
5675                            .into_iter()
5676                            .map(|(row_id, _)| row_id.0)
5677                            .collect(),
5678                    )
5679                }
5680                None => RowIdSet::empty(),
5681            },
5682            Condition::Range { column_id, lo, hi } => {
5683                // Build the candidate set from the durable tier — the learned
5684                // index (built from sorted runs) or a single page-pruned run —
5685                // then merge the memtable/mutable-run overlay. An overlay row
5686                // supersedes its run version (it may have been updated out of
5687                // range or deleted), so overlay rids are dropped from the run
5688                // set and re-evaluated from the overlay directly. Without this
5689                // merge, rows still in the memtable are invisible to a ranged
5690                // read whenever a LearnedRange index is present.
5691                let mut set = if let Some(li) = self.learned_range.get(column_id) {
5692                    RowIdSet::from_unsorted(li.range(*lo, *hi).into_iter().collect())
5693                } else if self.run_refs.len() == 1 {
5694                    let mut r = self.open_reader(self.run_refs[0].run_id)?;
5695                    r.range_row_id_set_i64(*column_id, *lo, *hi)?
5696                } else {
5697                    return self.range_scan_i64(*column_id, *lo, *hi, snapshot);
5698                };
5699                set.remove_many(self.overlay_rid_set(snapshot));
5700                self.range_scan_overlay_i64(&mut set, *column_id, *lo, *hi, snapshot);
5701                set
5702            }
5703            Condition::RangeF64 {
5704                column_id,
5705                lo,
5706                lo_inclusive,
5707                hi,
5708                hi_inclusive,
5709            } => {
5710                // See the `Range` arm: merge the overlay over the durable
5711                // candidate set so memtable/mutable-run rows are visible.
5712                let mut set = if let Some(li) = self.learned_range.get(column_id) {
5713                    RowIdSet::from_unsorted(
5714                        li.range_f64(*lo, *lo_inclusive, *hi, *hi_inclusive)
5715                            .into_iter()
5716                            .collect(),
5717                    )
5718                } else if self.run_refs.len() == 1 {
5719                    let mut r = self.open_reader(self.run_refs[0].run_id)?;
5720                    r.range_row_id_set_f64(*column_id, *lo, *lo_inclusive, *hi, *hi_inclusive)?
5721                } else {
5722                    return self.range_scan_f64(
5723                        *column_id,
5724                        *lo,
5725                        *lo_inclusive,
5726                        *hi,
5727                        *hi_inclusive,
5728                        snapshot,
5729                    );
5730                };
5731                set.remove_many(self.overlay_rid_set(snapshot));
5732                self.range_scan_overlay_f64(
5733                    &mut set,
5734                    *column_id,
5735                    *lo,
5736                    *lo_inclusive,
5737                    *hi,
5738                    *hi_inclusive,
5739                    snapshot,
5740                );
5741                set
5742            }
5743            Condition::IsNull { column_id } => {
5744                let mut set = if self.run_refs.len() == 1 {
5745                    let mut r = self.open_reader(self.run_refs[0].run_id)?;
5746                    r.null_row_id_set(*column_id, true)?
5747                } else {
5748                    return self.null_scan(*column_id, true, snapshot);
5749                };
5750                set.remove_many(self.overlay_rid_set(snapshot));
5751                self.null_scan_overlay(&mut set, *column_id, true, snapshot);
5752                set
5753            }
5754            Condition::IsNotNull { column_id } => {
5755                let mut set = if self.run_refs.len() == 1 {
5756                    let mut r = self.open_reader(self.run_refs[0].run_id)?;
5757                    r.null_row_id_set(*column_id, false)?
5758                } else {
5759                    return self.null_scan(*column_id, false, snapshot);
5760                };
5761                set.remove_many(self.overlay_rid_set(snapshot));
5762                self.null_scan_overlay(&mut set, *column_id, false, snapshot);
5763                set
5764            }
5765        })
5766    }
5767
5768    /// Vectorized range scan for Int64 columns (Phase 13.2 / 16.3). Resolves the
5769    /// survivor set via the reader's **page-pruned** path — pages whose `[min,max]`
5770    /// excludes `[lo,hi]` are never decoded — restricted to MVCC-visible rows.
5771    /// This is layout-independent: correct under any memtable / multi-run state,
5772    /// so it is always safe to call (no "single clean run" gate). Overlay rows
5773    /// (memtable / mutable-run) are excluded from the run portion and checked
5774    /// directly via [`Self::range_scan_overlay_i64`].
5775    fn range_scan_i64(
5776        &self,
5777        column_id: u16,
5778        lo: i64,
5779        hi: i64,
5780        snapshot: Snapshot,
5781    ) -> Result<RowIdSet> {
5782        let mut row_ids = Vec::new();
5783        let overlay_rids = self.overlay_rid_set(snapshot);
5784        for rr in &self.run_refs {
5785            let mut reader = self.open_reader(rr.run_id)?;
5786            let matched = reader.range_row_ids_visible_i64(column_id, lo, hi, snapshot.epoch)?;
5787            for rid in matched {
5788                if !overlay_rids.contains(&rid) {
5789                    row_ids.push(rid);
5790                }
5791            }
5792        }
5793        let mut s = RowIdSet::from_unsorted(row_ids);
5794        self.range_scan_overlay_i64(&mut s, column_id, lo, hi, snapshot);
5795        Ok(s)
5796    }
5797
5798    /// Float64 analogue of [`Self::range_scan_i64`] with per-bound inclusivity
5799    /// (Phase 13.2 / 16.3).
5800    fn range_scan_f64(
5801        &self,
5802        column_id: u16,
5803        lo: f64,
5804        lo_inclusive: bool,
5805        hi: f64,
5806        hi_inclusive: bool,
5807        snapshot: Snapshot,
5808    ) -> Result<RowIdSet> {
5809        let mut row_ids = Vec::new();
5810        let overlay_rids = self.overlay_rid_set(snapshot);
5811        for rr in &self.run_refs {
5812            let mut reader = self.open_reader(rr.run_id)?;
5813            let matched = reader.range_row_ids_visible_f64(
5814                column_id,
5815                lo,
5816                lo_inclusive,
5817                hi,
5818                hi_inclusive,
5819                snapshot.epoch,
5820            )?;
5821            for rid in matched {
5822                if !overlay_rids.contains(&rid) {
5823                    row_ids.push(rid);
5824                }
5825            }
5826        }
5827        let mut s = RowIdSet::from_unsorted(row_ids);
5828        self.range_scan_overlay_f64(
5829            &mut s,
5830            column_id,
5831            lo,
5832            lo_inclusive,
5833            hi,
5834            hi_inclusive,
5835            snapshot,
5836        );
5837        Ok(s)
5838    }
5839
5840    /// Collect the set of row-ids visible in the memtable / mutable-run overlay.
5841    fn overlay_rid_set(&self, snapshot: Snapshot) -> HashSet<u64> {
5842        let mut s = HashSet::new();
5843        for row in self.memtable.visible_versions(snapshot.epoch) {
5844            s.insert(row.row_id.0);
5845        }
5846        for row in self.mutable_run.visible_versions(snapshot.epoch) {
5847            s.insert(row.row_id.0);
5848        }
5849        s
5850    }
5851
5852    fn range_scan_overlay_i64(
5853        &self,
5854        s: &mut RowIdSet,
5855        column_id: u16,
5856        lo: i64,
5857        hi: i64,
5858        snapshot: Snapshot,
5859    ) {
5860        // Collapse both overlay tiers to the newest visible version per row id
5861        // (the memtable supersedes the mutable run) before range-checking, so a
5862        // stale in-range mutable-run version cannot shadow a newer out-of-range
5863        // memtable version of the same row.
5864        let mut newest: HashMap<u64, &Row> = HashMap::new();
5865        let mutable = self.mutable_run.visible_versions(snapshot.epoch);
5866        let memtable = self.memtable.visible_versions(snapshot.epoch);
5867        for r in &mutable {
5868            newest.entry(r.row_id.0).or_insert(r);
5869        }
5870        for r in &memtable {
5871            newest.insert(r.row_id.0, r);
5872        }
5873        for row in newest.values() {
5874            if !row.deleted {
5875                if let Some(Value::Int64(v)) = row.columns.get(&column_id) {
5876                    if *v >= lo && *v <= hi {
5877                        s.insert(row.row_id.0);
5878                    }
5879                }
5880            }
5881        }
5882    }
5883
5884    #[allow(clippy::too_many_arguments)]
5885    fn range_scan_overlay_f64(
5886        &self,
5887        s: &mut RowIdSet,
5888        column_id: u16,
5889        lo: f64,
5890        lo_inclusive: bool,
5891        hi: f64,
5892        hi_inclusive: bool,
5893        snapshot: Snapshot,
5894    ) {
5895        // See `range_scan_overlay_i64`: dedup to the newest version per row id
5896        // across the memtable + mutable run before range-checking.
5897        let mut newest: HashMap<u64, &Row> = HashMap::new();
5898        let mutable = self.mutable_run.visible_versions(snapshot.epoch);
5899        let memtable = self.memtable.visible_versions(snapshot.epoch);
5900        for r in &mutable {
5901            newest.entry(r.row_id.0).or_insert(r);
5902        }
5903        for r in &memtable {
5904            newest.insert(r.row_id.0, r);
5905        }
5906        for row in newest.values() {
5907            if !row.deleted {
5908                if let Some(Value::Float64(v)) = row.columns.get(&column_id) {
5909                    let ok_lo = if lo_inclusive { *v >= lo } else { *v > lo };
5910                    let ok_hi = if hi_inclusive { *v <= hi } else { *v < hi };
5911                    if ok_lo && ok_hi {
5912                        s.insert(row.row_id.0);
5913                    }
5914                }
5915            }
5916        }
5917    }
5918
5919    /// Multi-run fallback for `IS NULL` / `IS NOT NULL`. Calls each run's
5920    /// MVCC-aware null scan and merges with the overlay.
5921    fn null_scan(&self, column_id: u16, want_nulls: bool, snapshot: Snapshot) -> Result<RowIdSet> {
5922        let mut row_ids = Vec::new();
5923        let overlay_rids = self.overlay_rid_set(snapshot);
5924        for rr in &self.run_refs {
5925            let mut reader = self.open_reader(rr.run_id)?;
5926            let matched = reader.null_row_ids_visible(column_id, want_nulls, snapshot.epoch)?;
5927            for rid in matched {
5928                if !overlay_rids.contains(&rid) {
5929                    row_ids.push(rid);
5930                }
5931            }
5932        }
5933        let mut s = RowIdSet::from_unsorted(row_ids);
5934        self.null_scan_overlay(&mut s, column_id, want_nulls, snapshot);
5935        Ok(s)
5936    }
5937
5938    /// Merge overlay rows for `IS NULL` / `IS NOT NULL`. An overlay row
5939    /// supersedes its run version, so overlay rids are removed from the run
5940    /// set and re-evaluated from the overlay values directly.
5941    fn null_scan_overlay(
5942        &self,
5943        s: &mut RowIdSet,
5944        column_id: u16,
5945        want_nulls: bool,
5946        snapshot: Snapshot,
5947    ) {
5948        let mut newest: HashMap<u64, &Row> = HashMap::new();
5949        let mutable = self.mutable_run.visible_versions(snapshot.epoch);
5950        let memtable = self.memtable.visible_versions(snapshot.epoch);
5951        for r in &mutable {
5952            newest.entry(r.row_id.0).or_insert(r);
5953        }
5954        for r in &memtable {
5955            newest.insert(r.row_id.0, r);
5956        }
5957        for row in newest.values() {
5958            if row.deleted {
5959                continue;
5960            }
5961            let is_null = !row.columns.contains_key(&column_id)
5962                || matches!(row.columns.get(&column_id), Some(Value::Null) | None);
5963            if is_null == want_nulls {
5964                s.insert(row.row_id.0);
5965            }
5966        }
5967    }
5968
5969    pub fn snapshot(&self) -> Snapshot {
5970        Snapshot::at(self.epoch.visible())
5971    }
5972
5973    /// Generation of this table's row contents for table-local caches.
5974    pub fn data_generation(&self) -> u64 {
5975        self.data_generation
5976    }
5977
5978    /// Pin the current epoch as a read snapshot; compaction will preserve the
5979    /// versions it needs until [`Table::unpin_snapshot`] is called.
5980    pub fn pin_snapshot(&mut self) -> Snapshot {
5981        let e = self.epoch.visible();
5982        *self.pinned.entry(e).or_insert(0) += 1;
5983        Snapshot::at(e)
5984    }
5985
5986    /// Release a pinned snapshot.
5987    pub fn unpin_snapshot(&mut self, snap: Snapshot) {
5988        if let Some(count) = self.pinned.get_mut(&snap.epoch) {
5989            *count -= 1;
5990            if *count == 0 {
5991                self.pinned.remove(&snap.epoch);
5992            }
5993        }
5994    }
5995
5996    /// Oldest pinned snapshot epoch, or `None` if no snapshot is active.
5997    /// Lowest snapshot epoch that compaction must preserve a version for, or
5998    /// `None` when no reader is pinned anywhere. Considers BOTH the single-table
5999    /// local pin set (`self.pinned`, used by the standalone `pin_snapshot` API)
6000    /// AND the shared `Database` snapshot registry (`db.snapshot()` readers) —
6001    /// otherwise a multi-table reader's version could be dropped by a compaction
6002    /// triggered on its table (the registry-gated reaper would then keep the
6003    /// old run *files*, but readers only scan the merged run, so the version
6004    /// would still be lost).
6005    pub(crate) fn min_active_snapshot(&self) -> Option<Epoch> {
6006        let local = self.pinned.keys().next().copied();
6007        let global = self.snapshots.min_pinned();
6008        let history = self.snapshots.history_floor(self.current_epoch());
6009        [local, global, history].into_iter().flatten().min()
6010    }
6011
6012    /// Configure timestamp-column retention on a standalone table. Mounted
6013    /// databases should use [`crate::Database::set_table_ttl`] so the DDL is
6014    /// WAL-replicated.
6015    pub fn set_ttl(&mut self, column_name: &str, duration_nanos: u64) -> Result<()> {
6016        self.ensure_writable()?;
6017        let policy = self.prepare_ttl_policy(column_name, duration_nanos)?;
6018        self.apply_ttl_policy_at(Some(policy), self.current_epoch())
6019    }
6020
6021    pub fn clear_ttl(&mut self) -> Result<()> {
6022        self.ensure_writable()?;
6023        self.apply_ttl_policy_at(None, self.current_epoch())
6024    }
6025
6026    pub fn ttl(&self) -> Option<TtlPolicy> {
6027        self.ttl
6028    }
6029
6030    pub(crate) fn prepare_ttl_policy(
6031        &self,
6032        column_name: &str,
6033        duration_nanos: u64,
6034    ) -> Result<TtlPolicy> {
6035        if duration_nanos == 0 || duration_nanos > i64::MAX as u64 {
6036            return Err(MongrelError::InvalidArgument(
6037                "TTL duration must be between 1 and i64::MAX nanoseconds".into(),
6038            ));
6039        }
6040        let column = self
6041            .schema
6042            .columns
6043            .iter()
6044            .find(|column| column.name == column_name)
6045            .ok_or_else(|| MongrelError::Schema(format!("unknown TTL column {column_name}")))?;
6046        if column.ty != TypeId::TimestampNanos {
6047            return Err(MongrelError::Schema(format!(
6048                "TTL column {column_name} must be TimestampNanos, is {:?}",
6049                column.ty
6050            )));
6051        }
6052        Ok(TtlPolicy {
6053            column_id: column.id,
6054            duration_nanos,
6055        })
6056    }
6057
6058    pub(crate) fn apply_ttl_policy_at(
6059        &mut self,
6060        policy: Option<TtlPolicy>,
6061        epoch: Epoch,
6062    ) -> Result<()> {
6063        if let Some(policy) = policy {
6064            let column = self
6065                .schema
6066                .columns
6067                .iter()
6068                .find(|column| column.id == policy.column_id)
6069                .ok_or_else(|| {
6070                    MongrelError::Schema(format!("unknown TTL column id {}", policy.column_id))
6071                })?;
6072            if column.ty != TypeId::TimestampNanos
6073                || policy.duration_nanos == 0
6074                || policy.duration_nanos > i64::MAX as u64
6075            {
6076                return Err(MongrelError::Schema("invalid TTL policy".into()));
6077            }
6078        }
6079        self.ttl = policy;
6080        self.agg_cache.clear();
6081        self.clear_result_cache();
6082        let _ = std::fs::remove_dir_all(self.dir.join("_shadow"));
6083        self.persist_manifest(epoch)
6084    }
6085
6086    pub(crate) fn row_expired_at(&self, row: &Row, now_nanos: i64) -> bool {
6087        let Some(policy) = self.ttl else {
6088            return false;
6089        };
6090        let Some(Value::Int64(timestamp)) = row.columns.get(&policy.column_id) else {
6091            return false;
6092        };
6093        timestamp.saturating_add(policy.duration_nanos as i64) <= now_nanos
6094    }
6095
6096    pub fn current_epoch(&self) -> Epoch {
6097        self.epoch.visible()
6098    }
6099
6100    pub fn memtable_len(&self) -> usize {
6101        self.memtable.len()
6102    }
6103
6104    /// Live row count. O(1) without TTL; TTL tables scan because wall-clock
6105    /// expiry can change without a commit epoch.
6106    pub fn count(&self) -> u64 {
6107        if self.ttl.is_none()
6108            && self.pending_put_cols.is_empty()
6109            && self.pending_delete_rids.is_empty()
6110            && self.pending_rows.is_empty()
6111            && self.pending_dels.is_empty()
6112            && self.pending_truncate.is_none()
6113        {
6114            self.live_count
6115        } else {
6116            self.visible_rows(self.snapshot())
6117                .map(|rows| rows.len() as u64)
6118                .unwrap_or(self.live_count)
6119        }
6120    }
6121
6122    /// Count rows matching an index-backed conjunctive predicate without
6123    /// materializing projected columns. Returns `None` when a condition cannot
6124    /// be served by the native predicate resolver.
6125    pub fn count_conditions(
6126        &mut self,
6127        conditions: &[crate::query::Condition],
6128        snapshot: Snapshot,
6129    ) -> Result<Option<u64>> {
6130        use crate::query::Condition;
6131        if self.ttl.is_some() {
6132            if conditions.is_empty() {
6133                return Ok(Some(self.visible_rows(snapshot)?.len() as u64));
6134            }
6135            let mut sets = Vec::with_capacity(conditions.len());
6136            for condition in conditions {
6137                sets.push(self.resolve_condition(condition, snapshot)?);
6138            }
6139            let survivors = RowIdSet::intersect_many(sets);
6140            let rows = self.visible_rows(snapshot)?;
6141            return Ok(Some(
6142                rows.into_iter()
6143                    .filter(|row| survivors.contains(row.row_id.0))
6144                    .count() as u64,
6145            ));
6146        }
6147        if conditions.is_empty() {
6148            return Ok(Some(self.count()));
6149        }
6150        let served = |c: &Condition| {
6151            matches!(
6152                c,
6153                Condition::Pk(_)
6154                    | Condition::BitmapEq { .. }
6155                    | Condition::BitmapIn { .. }
6156                    | Condition::BytesPrefix { .. }
6157                    | Condition::FmContains { .. }
6158                    | Condition::FmContainsAll { .. }
6159                    | Condition::Ann { .. }
6160                    | Condition::Range { .. }
6161                    | Condition::RangeF64 { .. }
6162                    | Condition::SparseMatch { .. }
6163                    | Condition::MinHashSimilar { .. }
6164                    | Condition::IsNull { .. }
6165                    | Condition::IsNotNull { .. }
6166            )
6167        };
6168        if !conditions.iter().all(served) {
6169            return Ok(None);
6170        }
6171        self.ensure_indexes_complete()?;
6172        if !self.pending_put_cols.is_empty()
6173            || !self.pending_delete_rids.is_empty()
6174            || !self.pending_rows.is_empty()
6175            || !self.pending_dels.is_empty()
6176            || self.pending_truncate.is_some()
6177        {
6178            let mut sets = Vec::with_capacity(conditions.len());
6179            for condition in conditions {
6180                sets.push(self.resolve_condition(condition, snapshot)?);
6181            }
6182            let rids = RowIdSet::intersect_many(sets).into_sorted_vec();
6183            return Ok(Some(self.rows_for_rids(&rids, snapshot)?.len() as u64));
6184        }
6185        let mut sets = Vec::with_capacity(conditions.len());
6186        for condition in conditions {
6187            sets.push(self.resolve_condition(condition, snapshot)?);
6188        }
6189        let mut rids = RowIdSet::intersect_many(sets);
6190        // §5.1: the in-memory indexes (bitmap/FM/ANN/sparse/minhash) are
6191        // append-only across puts (`index_row` adds entries but
6192        // `tombstone_row` never removes them), so deletes and PK-displacing
6193        // updates leave behind entries for now-tombstoned row-ids. The
6194        // materialize paths (`query`, `query_columns_native`) already drop
6195        // these via MVCC visibility during row fetch; only the count fast
6196        // path trusts raw index cardinality, so prune tombstoned overlay
6197        // row-ids here. On a clean table (empty overlay) the bitmap was
6198        // rebuilt at flush and is authoritative — the prune is skipped.
6199        if !self.memtable.is_empty() || !self.mutable_run.is_empty() {
6200            rids.remove_many(self.overlay_tombstoned_rids(snapshot));
6201        }
6202        let count = rids.len() as u64;
6203        crate::trace::QueryTrace::record(|t| {
6204            t.scan_mode = crate::trace::ScanMode::CountSurvivors;
6205            t.survivor_count = Some(count as usize);
6206            t.conditions_pushed = conditions.len();
6207        });
6208        Ok(Some(count))
6209    }
6210
6211    /// Row-ids whose newest visible overlay version is a tombstone. Used to
6212    /// prune stale entries left behind by the append-only in-memory indexes
6213    /// (see `count_conditions`). Only unflushed tombstones matter — a flush
6214    /// rebuilds indexes from runs and excludes tombstoned rows. (§5.1)
6215    fn overlay_tombstoned_rids(&self, snapshot: Snapshot) -> Vec<u64> {
6216        let mut out = Vec::new();
6217        for row in self.memtable.visible_versions(snapshot.epoch) {
6218            if row.deleted {
6219                out.push(row.row_id.0);
6220            }
6221        }
6222        for row in self.mutable_run.visible_versions(snapshot.epoch) {
6223            if row.deleted {
6224                out.push(row.row_id.0);
6225            }
6226        }
6227        out
6228    }
6229
6230    /// Bulk-load typed columns straight to a new run — the fast ingest path.
6231    /// Bypasses the WAL, the memtable, and the `Value` enum entirely; writes one
6232    /// compressed run (delta for sorted Int64, dictionary for low-card Bytes)
6233    /// with **LZ4** (Phase 15.3 — fast decode for scan-heavy analytical runs),
6234    /// rotates the WAL, and persists the manifest in a single fsync group.
6235    /// Index building follows [`Table::index_build_policy`]: deferred to the
6236    /// first query/flush by default, or bulk-built inline from the typed
6237    /// columns (Phase 14.2) under [`IndexBuildPolicy::Eager`].
6238    pub fn bulk_load_columns(
6239        &mut self,
6240        user_columns: Vec<(u16, columnar::NativeColumn)>,
6241    ) -> Result<Epoch> {
6242        self.bulk_load_columns_with(user_columns, 3, false, true)
6243    }
6244
6245    /// Maximal-throughput bulk ingest (Phase 14.4): skip zstd entirely and write
6246    /// raw `ALGO_PLAIN` pages. ~3–4× the encode throughput of
6247    /// [`Self::bulk_load_columns`] at ~3–4× the on-disk size — the right choice
6248    /// when ingest latency dominates and a background compaction will re-compress
6249    /// later. Indexing, WAL rotation, and the manifest are identical to
6250    /// [`Self::bulk_load_columns`].
6251    pub fn bulk_load_fast(
6252        &mut self,
6253        user_columns: Vec<(u16, columnar::NativeColumn)>,
6254    ) -> Result<Epoch> {
6255        self.bulk_load_columns_with(user_columns, -1, true, false)
6256    }
6257
6258    fn bulk_load_columns_with(
6259        &mut self,
6260        mut user_columns: Vec<(u16, columnar::NativeColumn)>,
6261        zstd_level: i32,
6262        force_plain: bool,
6263        lz4: bool,
6264    ) -> Result<Epoch> {
6265        let epoch = self.commit()?;
6266        let n = user_columns.first().map(|(_, c)| c.len()).unwrap_or(0);
6267        if n == 0 {
6268            return Ok(epoch);
6269        }
6270        let live_before = self.live_count;
6271        // Spill pending mutable-run data before the Flush marker + WAL rotation.
6272        self.spill_mutable_run(epoch)?;
6273        let eager_index_build = self.index_build_policy == IndexBuildPolicy::Eager
6274            && self.indexes_complete
6275            && self.run_refs.is_empty()
6276            && self.memtable.is_empty()
6277            && self.mutable_run.is_empty();
6278        // Enforce NOT NULL constraints and primary-key upsert semantics before
6279        // any row id is allocated or bytes hit the run file.
6280        self.fill_auto_inc_native_columns(&mut user_columns, n)?;
6281        self.validate_columns_not_null(&user_columns, n)?;
6282        let winner_idx = self
6283            .bulk_pk_winner_indices(&user_columns, n)
6284            .filter(|idx| idx.len() != n);
6285        let (write_columns, write_n): (Vec<(u16, columnar::NativeColumn)>, usize) =
6286            match winner_idx.as_deref() {
6287                Some(idx) => {
6288                    let compacted = user_columns
6289                        .iter()
6290                        .map(|(id, c)| (*id, c.gather(idx)))
6291                        .collect();
6292                    (compacted, idx.len())
6293                }
6294                None => (user_columns, n),
6295            };
6296        self.advance_auto_inc_from_native_columns(&write_columns, write_n, live_before)?;
6297        let first = self.allocator.alloc_range(write_n as u64).0;
6298        for rid in first..first + write_n as u64 {
6299            self.reservoir.offer(rid);
6300        }
6301        let run_id = self.next_run_id;
6302        self.next_run_id += 1;
6303        let path = self.run_path(run_id);
6304        let mut writer =
6305            RunWriter::new(&self.schema, run_id as u128, epoch, 0).with_native_endian();
6306        if force_plain {
6307            writer = writer.with_plain();
6308        } else if lz4 {
6309            // Phase 15.3: bulk-loaded analytical runs are scan-heavy, so encode
6310            // them with LZ4 (3–5× faster decode, ~10% worse ratio than zstd).
6311            writer = writer.with_lz4();
6312        } else {
6313            writer = writer.with_zstd_level(zstd_level);
6314        }
6315        if let Some(kek) = &self.kek {
6316            writer = writer.with_encryption(kek.as_ref(), self.indexable_column_specs());
6317        }
6318        let header = writer.write_native(&path, &write_columns, write_n, first)?;
6319        self.run_refs.push(RunRef {
6320            run_id: run_id as u128,
6321            level: 0,
6322            epoch_created: epoch.0,
6323            row_count: header.row_count,
6324        });
6325        self.live_count = self.live_count.saturating_add(write_n as u64);
6326        if eager_index_build {
6327            let row_ids: Vec<u64> = (first..first + write_n as u64).collect();
6328            self.index_columns_bulk(&write_columns, &row_ids);
6329            self.indexes_complete = true;
6330            self.build_learned_ranges()?;
6331        } else {
6332            // Phase 14.7: defer index building off the ingest critical path for
6333            // non-empty tables where cross-run PK/update semantics must be
6334            // reconstructed from durable state.
6335            self.indexes_complete = false;
6336        }
6337        self.mark_flushed(epoch)?;
6338        self.persist_manifest(epoch)?;
6339        if eager_index_build {
6340            self.checkpoint_indexes(epoch);
6341        }
6342        self.clear_result_cache();
6343        self.data_generation = self.data_generation.wrapping_add(1);
6344        Ok(epoch)
6345    }
6346
6347    /// Bulk-build the live in-memory indexes (HOT/bitmap/FM/sparse) straight
6348    /// from typed columns — the deferred batch-indexing path (Phase 14.2).
6349    ///
6350    /// Replaces the per-row `index_into` loop: no `Row`, no per-row
6351    /// `HashMap<u16, Value>`, no `Value` enum. Index keys are computed directly
6352    /// from the typed buffers via [`columnar::encode_key_native`], tokenized for
6353    /// `ENCRYPTED_INDEXABLE` columns the same way `index_into` on a tokenized
6354    /// row would. FM is appended dirty and rebuilt once on the next query; the
6355    /// others are populated in a single typed pass. Entries are merged into the
6356    /// existing indexes so this is correct under multi-run loads and partial
6357    /// reindexes.
6358    ///
6359    /// `row_ids[i]` is the `RowId` of element `i` of every column. ANN
6360    /// (`IndexKind::Ann`) is intentionally skipped: the native codec carries no
6361    /// embeddings, so an `Embedding` column can never reach this path (a native
6362    /// bulk load of an embedding schema fails at encode). LearnedRange is built
6363    /// separately from the runs by [`Self::build_learned_ranges`].
6364    fn index_columns_bulk(&mut self, columns: &[(u16, columnar::NativeColumn)], row_ids: &[u64]) {
6365        let n = row_ids.len();
6366        if n == 0 {
6367            return;
6368        }
6369        let by_id: std::collections::HashMap<u16, &columnar::NativeColumn> =
6370            columns.iter().map(|(id, c)| (*id, c)).collect();
6371        let ty_of: std::collections::HashMap<u16, TypeId> = self
6372            .schema
6373            .columns
6374            .iter()
6375            .map(|c| (c.id, c.ty.clone()))
6376            .collect();
6377        let pk_id = self.schema.primary_key().map(|c| c.id);
6378
6379        for (i, &rid) in row_ids.iter().enumerate() {
6380            let row_id = RowId(rid);
6381            if let Some(pid) = pk_id {
6382                if let Some(col) = by_id.get(&pid) {
6383                    let ty = ty_of.get(&pid).cloned().unwrap_or(TypeId::Int64);
6384                    if let Some(key) = bulk_index_key(&self.column_keys, pid, ty, col, i) {
6385                        self.insert_hot_pk(key, row_id);
6386                    }
6387                }
6388            }
6389            for idef in &self.schema.indexes {
6390                let Some(col) = by_id.get(&idef.column_id) else {
6391                    continue;
6392                };
6393                let ty = ty_of.get(&idef.column_id).cloned().unwrap_or(TypeId::Int64);
6394                match idef.kind {
6395                    IndexKind::Bitmap => {
6396                        if let Some(b) = self.bitmap.get_mut(&idef.column_id) {
6397                            if let Some(key) =
6398                                bulk_index_key(&self.column_keys, idef.column_id, ty, col, i)
6399                            {
6400                                b.insert(key, row_id);
6401                            }
6402                        }
6403                    }
6404                    IndexKind::FmIndex => {
6405                        if let Some(f) = self.fm.get_mut(&idef.column_id) {
6406                            if let Some(bytes) = columnar::native_bytes_at(col, i) {
6407                                f.insert(bytes.to_vec(), row_id);
6408                            }
6409                        }
6410                    }
6411                    IndexKind::Sparse => {
6412                        if let Some(s) = self.sparse.get_mut(&idef.column_id) {
6413                            if let Some(bytes) = columnar::native_bytes_at(col, i) {
6414                                if let Ok(terms) = bincode::deserialize::<Vec<(u32, f32)>>(bytes) {
6415                                    s.insert(&terms, row_id);
6416                                }
6417                            }
6418                        }
6419                    }
6420                    IndexKind::MinHash => {
6421                        if let Some(mh) = self.minhash.get_mut(&idef.column_id) {
6422                            if let Some(bytes) = columnar::native_bytes_at(col, i) {
6423                                let tokens = crate::index::token_hashes_from_bytes(bytes);
6424                                mh.insert(&tokens, row_id);
6425                            }
6426                        }
6427                    }
6428                    _ => {}
6429                }
6430            }
6431        }
6432    }
6433
6434    /// no `Value`). Fast path: empty memtable + single run decodes columns
6435    /// directly and gathers visible indices; falls back to the `Value` path
6436    /// pivoted to native columns otherwise. `projection` (a set of column ids)
6437    /// limits decoding to the requested columns — `None` ⇒ all user columns.
6438    pub fn visible_columns_native(
6439        &self,
6440        snapshot: Snapshot,
6441        projection: Option<&[u16]>,
6442    ) -> Result<Vec<(u16, columnar::NativeColumn)>> {
6443        let wanted: Vec<u16> = match projection {
6444            Some(p) => p.to_vec(),
6445            None => self.schema.columns.iter().map(|c| c.id).collect(),
6446        };
6447        if self.ttl.is_none()
6448            && self.memtable.is_empty()
6449            && self.mutable_run.is_empty()
6450            && self.run_refs.len() == 1
6451        {
6452            let rr = self.run_refs[0].clone();
6453            let mut reader = self.open_reader(rr.run_id)?;
6454            let idxs = reader.visible_indices_native(snapshot.epoch)?;
6455            let all_visible = idxs.len() == reader.row_count();
6456            // Phase 15.1: decode every requested column in parallel when the
6457            // reader is mmap-backed. Each column already parallel-decodes its
6458            // own pages, so a wide table saturates the pool via nested rayon
6459            // without oversubscribing (work-stealing handles it). Falls back to
6460            // the sequential `&mut` path when mmap is unavailable.
6461            if reader.has_mmap() {
6462                use rayon::prelude::*;
6463                // Pre-resolve the requested ids that exist in the schema (don't
6464                // capture `self` inside the rayon closure).
6465                let valid: Vec<u16> = wanted
6466                    .iter()
6467                    .filter(|cid| self.schema.columns.iter().any(|c| c.id == **cid))
6468                    .copied()
6469                    .collect();
6470                // Decode concurrently; `collect` preserves `valid` order.
6471                let decoded: Vec<(u16, columnar::NativeColumn)> = valid
6472                    .par_iter()
6473                    .filter_map(|cid| {
6474                        reader
6475                            .column_native_shared(*cid)
6476                            .ok()
6477                            .map(|col| (*cid, col))
6478                    })
6479                    .collect();
6480                let cols = decoded
6481                    .into_iter()
6482                    .map(|(id, col)| (id, if all_visible { col } else { col.gather(&idxs) }))
6483                    .collect();
6484                return Ok(cols);
6485            }
6486            let mut cols = Vec::with_capacity(wanted.len());
6487            for cid in &wanted {
6488                let cdef = match self.schema.columns.iter().find(|c| c.id == *cid) {
6489                    Some(c) => c,
6490                    None => continue,
6491                };
6492                let col = reader.column_native(cdef.id)?;
6493                cols.push((cdef.id, if all_visible { col } else { col.gather(&idxs) }));
6494            }
6495            return Ok(cols);
6496        }
6497        let vcols = self.visible_columns(snapshot)?;
6498        let want_set: std::collections::HashSet<u16> = wanted.iter().copied().collect();
6499        let out: Vec<(u16, columnar::NativeColumn)> = vcols
6500            .into_iter()
6501            .filter(|(id, _)| want_set.contains(id))
6502            .map(|(id, vals)| {
6503                let ty = self
6504                    .schema
6505                    .columns
6506                    .iter()
6507                    .find(|c| c.id == id)
6508                    .map(|c| c.ty.clone())
6509                    .unwrap_or(TypeId::Bytes);
6510                (id, columnar::values_to_native(ty, &vals))
6511            })
6512            .collect();
6513        Ok(out)
6514    }
6515
6516    pub fn run_count(&self) -> usize {
6517        self.run_refs.len()
6518    }
6519
6520    /// Whether the memtable is empty (no unflushed puts).
6521    pub fn memtable_is_empty(&self) -> bool {
6522        self.memtable.is_empty()
6523    }
6524
6525    /// Cumulative raw-page-cache hit/miss counts (Priority 14: hit visibility).
6526    /// Useful for confirming a repeat scan is served from cache or measuring a
6527    /// query's locality after [`reset_page_cache_stats`](Self::reset_page_cache_stats).
6528    pub fn page_cache_stats(&self) -> crate::cache::CacheStats {
6529        self.page_cache.stats()
6530    }
6531
6532    /// Zero the raw-page-cache hit/miss counters.
6533    pub fn reset_page_cache_stats(&self) {
6534        self.page_cache.reset_stats();
6535    }
6536
6537    /// The run IDs in level order (Phase 15.5: used by the Arrow IPC shadow to
6538    /// key shadow files and detect stale shadows).
6539    pub fn run_ids(&self) -> Vec<u128> {
6540        self.run_refs.iter().map(|r| r.run_id).collect()
6541    }
6542
6543    /// Whether the single run (if exactly one) is clean — i.e. has
6544    /// `RUN_FLAG_CLEAN` set (Phase 15.5: the shadow is zero-copy only for clean
6545    /// runs).
6546    pub fn single_run_is_clean(&self) -> bool {
6547        if self.ttl.is_some() || self.run_refs.len() != 1 {
6548            return false;
6549        }
6550        self.open_reader(self.run_refs[0].run_id)
6551            .map(|r| r.is_clean())
6552            .unwrap_or(false)
6553    }
6554
6555    /// Best-effort resolve of the survivor RowId set for fine-grained cache
6556    /// invalidation (hardening (c)). On the single-run fast path, opens a reader
6557    /// and calls `resolve_survivor_rids`. On the multi-run/memtable path,
6558    /// returns an empty bitmap — conservative (condition_cols still catches
6559    /// column mutations, and deletes are caught by the epoch-free design falling
6560    /// through to the multi-run path which re-resolves).
6561    fn resolve_footprint(
6562        &self,
6563        conditions: &[crate::query::Condition],
6564        snapshot: Snapshot,
6565    ) -> roaring::RoaringBitmap {
6566        if !self.memtable.is_empty() || !self.mutable_run.is_empty() {
6567            return roaring::RoaringBitmap::new();
6568        }
6569        if self.run_refs.is_empty() {
6570            return roaring::RoaringBitmap::new();
6571        }
6572        // Try the single-run fast path.
6573        if self.run_refs.len() == 1 {
6574            if let Ok(mut reader) = self.open_reader(self.run_refs[0].run_id) {
6575                if let Ok(rids) = self.resolve_survivor_rids(conditions, &mut reader, snapshot) {
6576                    return rids.to_roaring_lossy();
6577                }
6578            }
6579        }
6580        roaring::RoaringBitmap::new()
6581    }
6582
6583    /// Phase 19.1 + hardening (c): a cached form of
6584    /// [`Table::query_columns_native`]. The cache key embeds the snapshot epoch
6585    /// so two queries at different pinned snapshots never share an entry;
6586    /// invalidation is fine-grained — a `commit()` drops only entries whose
6587    /// footprint intersects a deleted RowId or whose condition-columns intersect
6588    /// a mutated column. On a miss the underlying `query_columns_native` runs and
6589    /// the result is cached as typed `NativeColumn`s. Returns `None` exactly when
6590    /// the non-cached path would (conditions not pushdown-served). Strictly
6591    /// additive — callers wanting fresh results keep using
6592    /// `query_columns_native`.
6593    pub fn query_columns_native_cached(
6594        &mut self,
6595        conditions: &[crate::query::Condition],
6596        projection: Option<&[u16]>,
6597        snapshot: Snapshot,
6598    ) -> Result<Option<Vec<(u16, columnar::NativeColumn)>>> {
6599        // Wall-clock expiry changes without an MVCC epoch, so an epoch-keyed
6600        // result can become stale while sitting in the cache.
6601        if self.ttl.is_some() {
6602            return self.query_columns_native(conditions, projection, snapshot);
6603        }
6604        if conditions.is_empty() {
6605            return self.query_columns_native(conditions, projection, snapshot);
6606        }
6607        // The snapshot epoch is part of the key so two queries with identical
6608        // conditions/projection but pinned at different snapshots never share a
6609        // cached result (MVCC isolation for the explicit-snapshot API).
6610        let key = crate::query::canonical_query_key(conditions, projection, snapshot.epoch.0);
6611        if let Some(hit) = self.result_cache.lock().get_columns(key) {
6612            crate::trace::QueryTrace::record(|t| {
6613                t.result_cache_hit = true;
6614                t.scan_mode = crate::trace::ScanMode::NativePushdown;
6615            });
6616            return Ok(Some((*hit).clone()));
6617        }
6618        let res = self.query_columns_native(conditions, projection, snapshot)?;
6619        if let Some(cols) = &res {
6620            let footprint = self.resolve_footprint(conditions, snapshot);
6621            let condition_cols = crate::query::condition_columns(conditions);
6622            self.result_cache.lock().insert(
6623                key,
6624                CachedEntry {
6625                    data: CachedData::Columns(Arc::new(cols.clone())),
6626                    footprint,
6627                    condition_cols,
6628                },
6629            );
6630        }
6631        Ok(res)
6632    }
6633
6634    /// Phase 19.1 + hardening (c): a cached form of [`Table::query`]. The cache key
6635    /// is epoch-independent; invalidation is fine-grained (see
6636    /// [`Table::query_columns_native_cached`]). On a hit returns the cached rows (no
6637    /// re-resolve, no re-decode).
6638    pub fn query_cached(&mut self, q: &crate::query::Query) -> Result<Vec<Row>> {
6639        if self.ttl.is_some() {
6640            return self.query(q);
6641        }
6642        if q.conditions.is_empty() {
6643            return self.query(q);
6644        }
6645        let key = crate::query::canonical_query_key(&q.conditions, None, 0)
6646            ^ (q.limit.unwrap_or(usize::MAX) as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)
6647            ^ (q.offset as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F);
6648        if let Some(hit) = self.result_cache.lock().get_rows(key) {
6649            crate::trace::QueryTrace::record(|t| {
6650                t.result_cache_hit = true;
6651                t.scan_mode = crate::trace::ScanMode::Materialized;
6652            });
6653            return Ok((*hit).clone());
6654        }
6655        let rows = self.query(q)?;
6656        let footprint = rows.iter().map(|r| r.row_id.0 as u32).collect();
6657        let condition_cols = crate::query::condition_columns(&q.conditions);
6658        self.result_cache.lock().insert(
6659            key,
6660            CachedEntry {
6661                data: CachedData::Rows(Arc::new(rows.clone())),
6662                footprint,
6663                condition_cols,
6664            },
6665        );
6666        Ok(rows)
6667    }
6668
6669    // -----------------------------------------------------------------------
6670    // Traced query wrappers (OPTIMIZATIONS.md Priority 0 / 16).
6671    //
6672    // Each `_traced` method runs its underlying query inside a
6673    // [`crate::trace::QueryTrace::capture`] scope and returns the result
6674    // alongside the captured path trace. The trace records which physical path
6675    // served the query (cursor / pushdown / materialized / count-shortcut),
6676    // whether indexes were rebuilt, whether the result cache hit, overlay size,
6677    // survivor count, and the fast row-id map usage. Recording is zero-cost
6678    // when no `_traced` method is on the call stack (the plain methods are
6679    // unchanged).
6680    // -----------------------------------------------------------------------
6681
6682    /// [`Self::query_columns_native`] with a captured [`crate::trace::QueryTrace`].
6683    #[allow(clippy::type_complexity)]
6684    pub fn query_columns_native_traced(
6685        &mut self,
6686        conditions: &[crate::query::Condition],
6687        projection: Option<&[u16]>,
6688        snapshot: Snapshot,
6689    ) -> Result<(
6690        Option<Vec<(u16, columnar::NativeColumn)>>,
6691        crate::trace::QueryTrace,
6692    )> {
6693        let (result, trace) = crate::trace::QueryTrace::capture(|| {
6694            self.query_columns_native(conditions, projection, snapshot)
6695        });
6696        Ok((result?, trace))
6697    }
6698
6699    /// [`Self::query_columns_native_cached`] with a captured
6700    /// [`crate::trace::QueryTrace`] (records result-cache hits too).
6701    #[allow(clippy::type_complexity)]
6702    pub fn query_columns_native_cached_traced(
6703        &mut self,
6704        conditions: &[crate::query::Condition],
6705        projection: Option<&[u16]>,
6706        snapshot: Snapshot,
6707    ) -> Result<(
6708        Option<Vec<(u16, columnar::NativeColumn)>>,
6709        crate::trace::QueryTrace,
6710    )> {
6711        let (result, trace) = crate::trace::QueryTrace::capture(|| {
6712            self.query_columns_native_cached(conditions, projection, snapshot)
6713        });
6714        Ok((result?, trace))
6715    }
6716
6717    /// [`Self::native_page_cursor`] with a captured [`crate::trace::QueryTrace`].
6718    pub fn native_page_cursor_traced(
6719        &self,
6720        snapshot: Snapshot,
6721        projection: Vec<(u16, TypeId)>,
6722        conditions: &[crate::query::Condition],
6723    ) -> Result<(Option<NativePageCursor>, crate::trace::QueryTrace)> {
6724        let (result, trace) = crate::trace::QueryTrace::capture(|| {
6725            self.native_page_cursor(snapshot, projection, conditions)
6726        });
6727        Ok((result?, trace))
6728    }
6729
6730    /// [`Self::native_multi_run_cursor`] with a captured [`crate::trace::QueryTrace`].
6731    pub fn native_multi_run_cursor_traced(
6732        &self,
6733        snapshot: Snapshot,
6734        projection: Vec<(u16, TypeId)>,
6735        conditions: &[crate::query::Condition],
6736    ) -> Result<(
6737        Option<crate::cursor::MultiRunCursor>,
6738        crate::trace::QueryTrace,
6739    )> {
6740        let (result, trace) = crate::trace::QueryTrace::capture(|| {
6741            self.native_multi_run_cursor(snapshot, projection, conditions)
6742        });
6743        Ok((result?, trace))
6744    }
6745
6746    /// [`Self::count_conditions`] with a captured [`crate::trace::QueryTrace`].
6747    pub fn count_conditions_traced(
6748        &mut self,
6749        conditions: &[crate::query::Condition],
6750        snapshot: Snapshot,
6751    ) -> Result<(Option<u64>, crate::trace::QueryTrace)> {
6752        let (result, trace) =
6753            crate::trace::QueryTrace::capture(|| self.count_conditions(conditions, snapshot));
6754        Ok((result?, trace))
6755    }
6756
6757    /// [`Self::query`] with a captured [`crate::trace::QueryTrace`].
6758    pub fn query_traced(
6759        &mut self,
6760        q: &crate::query::Query,
6761    ) -> Result<(Vec<Row>, crate::trace::QueryTrace)> {
6762        let (result, trace) = crate::trace::QueryTrace::capture(|| self.query(q));
6763        Ok((result?, trace))
6764    }
6765
6766    /// Predicate pushdown: resolve `conditions` via indexes to find the matching
6767    /// row-id set, then decode only those rows' columns — not the whole table.
6768    /// Returns `None` if the conditions can't be served by indexes (caller falls
6769    /// back to a full scan). This is the fast path for `WHERE col = 'value'`.
6770    pub fn query_columns_native(
6771        &mut self,
6772        conditions: &[crate::query::Condition],
6773        projection: Option<&[u16]>,
6774        snapshot: Snapshot,
6775    ) -> Result<Option<Vec<(u16, columnar::NativeColumn)>>> {
6776        use crate::query::Condition;
6777        // TTL reads use the materialized visibility path so the wall-clock
6778        // cutoff is captured once and applied to every storage tier.
6779        if self.ttl.is_some() {
6780            return Ok(None);
6781        }
6782        if conditions.is_empty() {
6783            return Ok(None);
6784        }
6785        self.ensure_indexes_complete()?;
6786
6787        // Only these conditions are pushdown-served. Range/RangeF64 need a
6788        // column read on the single-run fast path; off it they fall back to a
6789        // visible-rows scan via `resolve_condition` (still correct for any
6790        // layout, just not page-pruned).
6791        let served = |c: &Condition| {
6792            matches!(
6793                c,
6794                Condition::Pk(_)
6795                    | Condition::BitmapEq { .. }
6796                    | Condition::BitmapIn { .. }
6797                    | Condition::BytesPrefix { .. }
6798                    | Condition::FmContains { .. }
6799                    | Condition::FmContainsAll { .. }
6800                    | Condition::Ann { .. }
6801                    | Condition::Range { .. }
6802                    | Condition::RangeF64 { .. }
6803                    | Condition::SparseMatch { .. }
6804                    | Condition::MinHashSimilar { .. }
6805                    | Condition::IsNull { .. }
6806                    | Condition::IsNotNull { .. }
6807            )
6808        };
6809        if !conditions.iter().all(served) {
6810            return Ok(None);
6811        }
6812        let fast_path =
6813            self.memtable.is_empty() && self.mutable_run.is_empty() && self.run_refs.len() == 1;
6814        crate::trace::QueryTrace::record(|t| {
6815            t.run_count = self.run_refs.len();
6816            t.memtable_rows = self.memtable.len();
6817            t.mutable_run_rows = self.mutable_run.len();
6818            t.conditions_pushed = conditions.len();
6819            t.learned_range_used = conditions.iter().any(|c| match c {
6820                Condition::Range { column_id, .. } | Condition::RangeF64 { column_id, .. } => {
6821                    self.learned_range.contains_key(column_id)
6822                }
6823                _ => false,
6824            });
6825        });
6826        // Build column list (projected or all user columns) + projection pairs.
6827        let col_ids: Vec<u16> = projection
6828            .map(|p| p.to_vec())
6829            .unwrap_or_else(|| self.schema.columns.iter().map(|c| c.id).collect());
6830        let proj_pairs: Vec<(u16, TypeId)> = col_ids
6831            .iter()
6832            .map(|&cid| {
6833                let ty = self
6834                    .schema
6835                    .columns
6836                    .iter()
6837                    .find(|c| c.id == cid)
6838                    .map(|c| c.ty.clone())
6839                    .unwrap_or(TypeId::Bytes);
6840                (cid, ty)
6841            })
6842            .collect();
6843
6844        // -----------------------------------------------------------------------
6845        // Fast path: single run, empty memtable/mutable-run → resolve survivors,
6846        // binary-search positions, gather only the projected columns from one
6847        // reader. This is the fastest pushdown path (no cursor overhead).
6848        // -----------------------------------------------------------------------
6849        if fast_path {
6850            // A Range/RangeF64 needs a column read *unless* its column has a
6851            // learned (PGM) range index, in which case it's served in-memory.
6852            let needs_column = conditions.iter().any(|c| match c {
6853                Condition::Range { column_id, .. } => !self.learned_range.contains_key(column_id),
6854                Condition::RangeF64 { column_id, .. } => {
6855                    !self.learned_range.contains_key(column_id)
6856                }
6857                _ => false,
6858            });
6859            let mut reader_opt: Option<RunReader> = if needs_column {
6860                Some(self.open_reader(self.run_refs[0].run_id)?)
6861            } else {
6862                None
6863            };
6864            let mut sets: Vec<RowIdSet> = Vec::new();
6865            for c in conditions {
6866                let s = match c {
6867                    Condition::Range { column_id, lo, hi }
6868                        if !self.learned_range.contains_key(column_id) =>
6869                    {
6870                        if reader_opt.is_none() {
6871                            reader_opt = Some(self.open_reader(self.run_refs[0].run_id)?);
6872                        }
6873                        reader_opt
6874                            .as_mut()
6875                            .expect("reader opened for range")
6876                            .range_row_id_set_i64(*column_id, *lo, *hi)?
6877                    }
6878                    Condition::RangeF64 {
6879                        column_id,
6880                        lo,
6881                        lo_inclusive,
6882                        hi,
6883                        hi_inclusive,
6884                    } if !self.learned_range.contains_key(column_id) => {
6885                        if reader_opt.is_none() {
6886                            reader_opt = Some(self.open_reader(self.run_refs[0].run_id)?);
6887                        }
6888                        reader_opt
6889                            .as_mut()
6890                            .expect("reader opened for range")
6891                            .range_row_id_set_f64(
6892                                *column_id,
6893                                *lo,
6894                                *lo_inclusive,
6895                                *hi,
6896                                *hi_inclusive,
6897                            )?
6898                    }
6899                    _ => self.resolve_condition(c, snapshot)?,
6900                };
6901                sets.push(s);
6902            }
6903            let candidates = RowIdSet::intersect_many(sets);
6904            crate::trace::QueryTrace::record(|t| {
6905                t.survivor_count = Some(candidates.len());
6906            });
6907            if candidates.is_empty() {
6908                let cols: Vec<(u16, columnar::NativeColumn)> = col_ids
6909                    .iter()
6910                    .map(|&id| {
6911                        (
6912                            id,
6913                            columnar::null_native(
6914                                proj_pairs
6915                                    .iter()
6916                                    .find(|(c, _)| c == &id)
6917                                    .map(|(_, t)| t.clone())
6918                                    .unwrap_or(TypeId::Bytes),
6919                                0,
6920                            ),
6921                        )
6922                    })
6923                    .collect();
6924                return Ok(Some(cols));
6925            }
6926            let mut reader = match reader_opt.take() {
6927                Some(r) => r,
6928                None => self.open_reader(self.run_refs[0].run_id)?,
6929            };
6930            let candidate_ids = candidates.into_sorted_vec();
6931            let (positions, fast_rid) = if let Some(positions) =
6932                reader.positions_for_row_ids_fast(&candidate_ids)
6933            {
6934                (positions, true)
6935            } else {
6936                let col = reader.column_native(crate::sorted_run::SYS_ROW_ID)?;
6937                match col {
6938                    columnar::NativeColumn::Int64 { data, .. } => {
6939                        let mut p: Vec<usize> = candidate_ids
6940                            .iter()
6941                            .filter_map(|rid| data.binary_search(&(*rid as i64)).ok())
6942                            .collect();
6943                        p.sort_unstable();
6944                        (p, false)
6945                    }
6946                    _ => return Err(MongrelError::InvalidArgument("sys row_id not int64".into())),
6947                }
6948            };
6949            crate::trace::QueryTrace::record(|t| {
6950                t.scan_mode = crate::trace::ScanMode::NativePushdown;
6951                t.fast_row_id_map = fast_rid;
6952            });
6953            let mut cols = Vec::with_capacity(col_ids.len());
6954            for cid in &col_ids {
6955                let col = reader.column_native(*cid)?;
6956                cols.push((*cid, col.gather(&positions)));
6957            }
6958            return Ok(Some(cols));
6959        }
6960
6961        // -----------------------------------------------------------------------
6962        // Non-fast path (multi-run / non-empty overlay). Route through the
6963        // columnar cursor (OPTIMIZATIONS.md Priority 1 + 4): the cursor builder
6964        // resolves MVCC, predicates, and overlay internally in batch, then
6965        // streams projected columns page-by-page. This avoids the per-rid
6966        // `rows_for_rids` `get_version`-across-all-runs cost that made multi-run
6967        // pushdown ~1000× slower than the single-run fast path.
6968        //
6969        // The cursor handles both single-run-with-overlay (`native_page_cursor`)
6970        // and multi-run (`native_multi_run_cursor`) layouts. The empty-table
6971        // (no runs, memtable-only) edge case falls through to `rows_for_rids`.
6972        // -----------------------------------------------------------------------
6973        if !self.run_refs.is_empty() {
6974            use crate::cursor::{drain_cursor_to_columns, Cursor};
6975            let remaining: usize;
6976            let mut cursor: Box<dyn crate::cursor::Cursor> = if self.run_refs.len() == 1 {
6977                let c = self
6978                    .native_page_cursor(snapshot, proj_pairs.clone(), conditions)?
6979                    .expect("single-run cursor should build when run_refs.len() == 1");
6980                remaining = c.remaining_rows();
6981                Box::new(c)
6982            } else {
6983                let c = self
6984                    .native_multi_run_cursor(snapshot, proj_pairs.clone(), conditions)?
6985                    .expect("multi-run cursor should build when run_refs.len() >= 1");
6986                remaining = c.remaining_rows();
6987                Box::new(c)
6988            };
6989            crate::trace::QueryTrace::record(|t| {
6990                if t.survivor_count.is_none() {
6991                    t.survivor_count = Some(remaining);
6992                }
6993            });
6994            let cols = drain_cursor_to_columns(cursor.as_mut(), &proj_pairs)?;
6995            return Ok(Some(cols));
6996        }
6997
6998        // Empty-table fallback (no sorted runs, memtable/mutable-run only): the
6999        // cursor builders return `None` for `run_refs.is_empty()`, so resolve
7000        // from overlay indexes and materialize via `rows_for_rids`. This is the
7001        // rare edge case (fresh table with only `put`s, no `flush`/`bulk_load`).
7002        crate::trace::QueryTrace::record(|t| {
7003            t.scan_mode = crate::trace::ScanMode::Materialized;
7004            t.row_materialized = true;
7005        });
7006        let mut sets: Vec<RowIdSet> = Vec::with_capacity(conditions.len());
7007        for c in conditions {
7008            sets.push(self.resolve_condition(c, snapshot)?);
7009        }
7010        let rids = RowIdSet::intersect_many(sets).into_sorted_vec();
7011        let rows = self.rows_for_rids(&rids, snapshot)?;
7012        let mut cols: Vec<(u16, columnar::NativeColumn)> = Vec::with_capacity(col_ids.len());
7013        for (cid, ty) in &proj_pairs {
7014            let vals: Vec<Value> = rows
7015                .iter()
7016                .map(|r| r.columns.get(cid).cloned().unwrap_or(Value::Null))
7017                .collect();
7018            cols.push((*cid, columnar::values_to_native(ty.clone(), &vals)));
7019        }
7020        Ok(Some(cols))
7021    }
7022
7023    /// Build a lazy, page-aware [`NativePageCursor`] for the single-run fast
7024    /// path. MVCC visibility and predicate survivor resolution are settled up
7025    /// front (so they see the live indexes under the DB lock); the cursor then
7026    /// owns the reader and decodes only the projected columns of pages that
7027    /// contain survivors, lazily. This is the fused-predicate + page-skip +
7028    /// late-materialization scan.
7029    ///
7030    /// Phase 13.1: the memtable / mutable-run overlay is now handled. Rows with
7031    /// a newer version in the overlay are excluded from the run's page plans
7032    /// (their run version is stale); the overlay rows are pre-materialized and
7033    /// appended as a final batch via [`NativePageCursor::new_with_overlay`].
7034    ///
7035    /// Returns `None` only for multiple sorted runs; the caller falls back to
7036    /// the materialize-then-stream scan for that layout.
7037    pub fn native_page_cursor(
7038        &self,
7039        snapshot: Snapshot,
7040        projection: Vec<(u16, TypeId)>,
7041        conditions: &[crate::query::Condition],
7042    ) -> Result<Option<NativePageCursor>> {
7043        use crate::cursor::build_page_plans;
7044        if self.ttl.is_some() {
7045            return Ok(None);
7046        }
7047        // See `scan_cursor`: incomplete (deferred) indexes cannot resolve
7048        // conditions — signal "can't serve" instead of empty survivor sets.
7049        if !conditions.is_empty() && !self.indexes_complete {
7050            return Ok(None);
7051        }
7052        if self.run_refs.len() != 1 {
7053            return Ok(None);
7054        }
7055        let mut reader = self.open_reader(self.run_refs[0].run_id)?;
7056        let (positions, rids) = reader.visible_positions_with_rids(snapshot.epoch)?;
7057
7058        // Collect overlay rows from memtable + mutable_run (visible, newest
7059        // version per row). These shadow any stale version in the run.
7060        let overlay_rids: HashSet<u64> = {
7061            let mut s = HashSet::new();
7062            for row in self.memtable.visible_versions(snapshot.epoch) {
7063                s.insert(row.row_id.0);
7064            }
7065            for row in self.mutable_run.visible_versions(snapshot.epoch) {
7066                s.insert(row.row_id.0);
7067            }
7068            s
7069        };
7070
7071        // Resolve survivor rids via indexes (covers overlay rows for index-
7072        // served conditions: PK, bitmap, FM, ANN, sparse — all maintained on
7073        // every put).
7074        let survivors = if conditions.is_empty() {
7075            None
7076        } else {
7077            Some(self.resolve_survivor_rids(conditions, &mut reader, snapshot)?)
7078        };
7079
7080        // Exclude overlay rids from the run portion: their version in the run
7081        // is stale (updated/deleted in the overlay) or they don't exist in the
7082        // run (new inserts). When there are conditions, we remove overlay rids
7083        // from the survivor set. When there are no conditions, we synthesize a
7084        // survivor set = (all visible run rids) − (overlay rids) so the stale
7085        // run rows are pruned.
7086        let run_survivors: Option<RowIdSet> = if overlay_rids.is_empty() {
7087            survivors.clone()
7088        } else if let Some(s) = &survivors {
7089            let mut run_set = s.clone();
7090            run_set.remove_many(overlay_rids.iter().copied());
7091            Some(run_set)
7092        } else {
7093            Some(RowIdSet::from_unsorted(
7094                rids.iter()
7095                    .map(|&r| r as u64)
7096                    .filter(|r| !overlay_rids.contains(r))
7097                    .collect(),
7098            ))
7099        };
7100
7101        let overlay_rows = if overlay_rids.is_empty() {
7102            Vec::new()
7103        } else {
7104            let bound = Self::overlay_materialization_bound(conditions, &survivors);
7105            self.overlay_visible_rows(snapshot, bound)
7106        };
7107
7108        // Build page plans for the run portion.
7109        let plans = if positions.is_empty() {
7110            Vec::new()
7111        } else {
7112            let page_rows = reader.page_row_counts(crate::sorted_run::SYS_ROW_ID)?;
7113            build_page_plans(&positions, &rids, &page_rows, run_survivors.as_ref())
7114        };
7115
7116        // Filter and materialize the overlay.
7117        let overlay = if overlay_rows.is_empty() {
7118            None
7119        } else {
7120            let filtered =
7121                self.filter_overlay_rows(overlay_rows, conditions, survivors.as_ref(), snapshot)?;
7122            if filtered.is_empty() {
7123                None
7124            } else {
7125                Some(self.materialize_overlay(&filtered, &projection))
7126            }
7127        };
7128
7129        let overlay_row_count = overlay
7130            .as_ref()
7131            .map(|c| c.first().map(|c| c.len()).unwrap_or(0))
7132            .unwrap_or(0);
7133        crate::trace::QueryTrace::record(|t| {
7134            t.scan_mode = crate::trace::ScanMode::NativePageCursor;
7135            t.run_count = self.run_refs.len();
7136            t.memtable_rows = self.memtable.len();
7137            t.mutable_run_rows = self.mutable_run.len();
7138            t.overlay_rows = overlay_row_count;
7139            t.conditions_pushed = conditions.len();
7140            t.pages_decoded = plans
7141                .iter()
7142                .map(|p| p.positions.len())
7143                .sum::<usize>()
7144                .min(1);
7145        });
7146
7147        Ok(Some(NativePageCursor::new_with_overlay(
7148            reader, projection, plans, overlay,
7149        )))
7150    }
7151    /// Generalizes [`Self::native_page_cursor`] (single-run) to arbitrary run
7152    /// counts via a k-way merge by `RowId`. Cross-run MVCC resolution (newest
7153    /// visible version per `RowId`) and predicate survivor resolution are settled
7154    /// up front from the cheap system columns + global indexes; the cursor then
7155    /// lazily decodes the projected data columns of just the pages that own
7156    /// survivors, each page at most once. The memtable / mutable-run overlay is
7157    /// materialized and yielded as a final batch (mirroring the single-run path).
7158    ///
7159    /// Returns `None` only when there are no runs at all (caller falls back).
7160    #[allow(clippy::type_complexity)]
7161    pub fn native_multi_run_cursor(
7162        &self,
7163        snapshot: Snapshot,
7164        projection: Vec<(u16, TypeId)>,
7165        conditions: &[crate::query::Condition],
7166    ) -> Result<Option<crate::cursor::MultiRunCursor>> {
7167        use crate::cursor::{MultiRunCursor, RunStream};
7168        use crate::sorted_run::SYS_ROW_ID;
7169        use std::collections::{BinaryHeap, HashMap, HashSet};
7170        if self.ttl.is_some() {
7171            return Ok(None);
7172        }
7173        // See `scan_cursor`: incomplete (deferred) indexes cannot resolve
7174        // conditions — signal "can't serve" instead of empty survivor sets.
7175        if !conditions.is_empty() && !self.indexes_complete {
7176            return Ok(None);
7177        }
7178        if self.run_refs.is_empty() {
7179            return Ok(None);
7180        }
7181
7182        // Open each run once; read its system columns + page layout.
7183        let mut run_meta: Vec<(RunReader, Vec<i64>, Vec<i64>, Vec<u8>, Vec<usize>)> =
7184            Vec::with_capacity(self.run_refs.len());
7185        for rr in &self.run_refs {
7186            let mut reader = self.open_reader(rr.run_id)?;
7187            let (rids, eps, del) = reader.system_columns_native()?;
7188            let page_rows = reader.page_row_counts(SYS_ROW_ID)?;
7189            run_meta.push((reader, rids, eps, del, page_rows));
7190        }
7191
7192        // Global cross-run newest-version resolution: rid -> (epoch, run_idx,
7193        // position, deleted). Mirrors `visible_rows`, tracking which run owns
7194        // the newest MVCC-visible version.
7195        let mut best: HashMap<u64, (u64, usize, usize, bool)> = HashMap::new();
7196        for (run_idx, (_, rids, eps, del, _)) in run_meta.iter().enumerate() {
7197            for i in 0..rids.len() {
7198                let rid = rids[i] as u64;
7199                let e = eps[i] as u64;
7200                if e > snapshot.epoch.0 {
7201                    continue;
7202                }
7203                let is_del = del[i] != 0;
7204                best.entry(rid)
7205                    .and_modify(|cur| {
7206                        if e > cur.0 {
7207                            *cur = (e, run_idx, i, is_del);
7208                        }
7209                    })
7210                    .or_insert((e, run_idx, i, is_del));
7211            }
7212        }
7213
7214        // Overlay rids (memtable + mutable-run) shadow every run version.
7215        let overlay_rids: HashSet<u64> = {
7216            let mut s = HashSet::new();
7217            for row in self.memtable.visible_versions(snapshot.epoch) {
7218                s.insert(row.row_id.0);
7219            }
7220            for row in self.mutable_run.visible_versions(snapshot.epoch) {
7221                s.insert(row.row_id.0);
7222            }
7223            s
7224        };
7225
7226        // Predicate survivors (global, layout-independent).
7227        let survivors: Option<RowIdSet> = if conditions.is_empty() {
7228            None
7229        } else {
7230            let mut sets: Vec<RowIdSet> = Vec::with_capacity(conditions.len());
7231            for c in conditions {
7232                sets.push(self.resolve_condition(c, snapshot)?);
7233            }
7234            Some(RowIdSet::intersect_many(sets))
7235        };
7236
7237        // Per-run owned survivors: (rid, position), ascending by rid. A row is
7238        // owned by the run holding its newest visible version, is not deleted,
7239        // is not shadowed by the overlay, and satisfies the predicate.
7240        let mut per_run: Vec<Vec<(u64, usize)>> = vec![Vec::new(); run_meta.len()];
7241        for (rid, (_, run_idx, pos, deleted)) in &best {
7242            if *deleted {
7243                continue;
7244            }
7245            if overlay_rids.contains(rid) {
7246                continue;
7247            }
7248            if let Some(s) = &survivors {
7249                if !s.contains(*rid) {
7250                    continue;
7251                }
7252            }
7253            per_run[*run_idx].push((*rid, *pos));
7254        }
7255        for v in per_run.iter_mut() {
7256            v.sort_unstable_by_key(|&(rid, _)| rid);
7257        }
7258
7259        // Build the merge streams: map each owned position to (page_seq, within).
7260        let mut streams = Vec::with_capacity(run_meta.len());
7261        let mut heap: BinaryHeap<std::cmp::Reverse<(u64, usize)>> = BinaryHeap::new();
7262        let mut total = 0usize;
7263        for (run_idx, (reader, _, _, _, page_rows)) in run_meta.into_iter().enumerate() {
7264            let mut starts = Vec::with_capacity(page_rows.len());
7265            let mut acc = 0usize;
7266            for &r in &page_rows {
7267                starts.push(acc);
7268                acc += r;
7269            }
7270            let mut survivors_vec: Vec<(u64, usize, usize)> =
7271                Vec::with_capacity(per_run[run_idx].len());
7272            for &(rid, pos) in &per_run[run_idx] {
7273                let page_seq = match starts.partition_point(|&s| s <= pos) {
7274                    0 => continue,
7275                    p => p - 1,
7276                };
7277                let within = pos - starts[page_seq];
7278                survivors_vec.push((rid, page_seq, within));
7279            }
7280            total += survivors_vec.len();
7281            if let Some(&(rid, _, _)) = survivors_vec.first() {
7282                heap.push(std::cmp::Reverse((rid, run_idx)));
7283            }
7284            streams.push(RunStream::new(reader, survivors_vec, page_rows));
7285        }
7286
7287        // Materialize the overlay (filtered + projected), yielded as the final batch.
7288        let overlay_rows = if overlay_rids.is_empty() {
7289            Vec::new()
7290        } else {
7291            let bound = Self::overlay_materialization_bound(conditions, &survivors);
7292            self.overlay_visible_rows(snapshot, bound)
7293        };
7294        let overlay = if overlay_rows.is_empty() {
7295            None
7296        } else {
7297            let filtered =
7298                self.filter_overlay_rows(overlay_rows, conditions, survivors.as_ref(), snapshot)?;
7299            if filtered.is_empty() {
7300                None
7301            } else {
7302                Some(self.materialize_overlay(&filtered, &projection))
7303            }
7304        };
7305
7306        let overlay_row_count = overlay
7307            .as_ref()
7308            .map(|c| c.first().map(|c| c.len()).unwrap_or(0))
7309            .unwrap_or(0);
7310        crate::trace::QueryTrace::record(|t| {
7311            t.scan_mode = crate::trace::ScanMode::MultiRunCursor;
7312            t.run_count = self.run_refs.len();
7313            t.memtable_rows = self.memtable.len();
7314            t.mutable_run_rows = self.mutable_run.len();
7315            t.overlay_rows = overlay_row_count;
7316            t.conditions_pushed = conditions.len();
7317            t.survivor_count = Some(total);
7318        });
7319
7320        Ok(Some(MultiRunCursor::new(
7321            streams, projection, heap, total, overlay,
7322        )))
7323    }
7324
7325    /// Collect visible, non-deleted overlay rows from the memtable and mutable-
7326    /// run tier at `snapshot`. These are the rows whose data lives only in the
7327    /// in-memory buffers (not yet in a sorted run), or that shadow a stale
7328    /// version in the run.
7329    /// The survivor set that bounds overlay materialization (Priority 2), or
7330    /// `None` when overlay rows must be fully materialized — i.e. there is a
7331    /// `Range`/`RangeF64` residual, for which the index-served survivor set does
7332    /// not cover matching overlay rows (those are evaluated downstream). This
7333    /// mirrors the `all_index_served` branch of
7334    /// [`filter_overlay_rows`](Self::filter_overlay_rows), so bounding here is
7335    /// result-preserving.
7336    fn overlay_materialization_bound<'a>(
7337        conditions: &[crate::query::Condition],
7338        survivors: &'a Option<RowIdSet>,
7339    ) -> Option<&'a RowIdSet> {
7340        use crate::query::Condition;
7341        let has_range = conditions
7342            .iter()
7343            .any(|c| matches!(c, Condition::Range { .. } | Condition::RangeF64 { .. }));
7344        if has_range {
7345            None
7346        } else {
7347            survivors.as_ref()
7348        }
7349    }
7350
7351    /// Materialize the visible overlay rows (memtable + mutable-run, newest
7352    /// version per row, non-deleted).
7353    ///
7354    /// Priority 2 (selective overlay probing): when `bound` is `Some`, only rows
7355    /// whose id is in it are materialized. The caller passes the index-resolved
7356    /// survivor set as `bound` exactly when every condition is index-served — in
7357    /// which case [`filter_overlay_rows`](Self::filter_overlay_rows) would discard
7358    /// any non-survivor overlay row anyway, so this prunes the materialization
7359    /// without changing the result. With a Range/RangeF64 residual the survivor
7360    /// set is incomplete for overlay rows, so the caller passes `None` (full
7361    /// materialization) and the range is re-evaluated downstream.
7362    fn overlay_visible_rows(&self, snapshot: Snapshot, bound: Option<&RowIdSet>) -> Vec<Row> {
7363        let mut best: HashMap<u64, (Epoch, Row)> = HashMap::new();
7364        let mut fold = |row: Row| {
7365            if let Some(b) = bound {
7366                if !b.contains(row.row_id.0) {
7367                    return;
7368                }
7369            }
7370            best.entry(row.row_id.0)
7371                .and_modify(|(be, br)| {
7372                    if row.committed_epoch > *be {
7373                        *be = row.committed_epoch;
7374                        *br = row.clone();
7375                    }
7376                })
7377                .or_insert_with(|| (row.committed_epoch, row));
7378        };
7379        for row in self.memtable.visible_versions(snapshot.epoch) {
7380            fold(row);
7381        }
7382        for row in self.mutable_run.visible_versions(snapshot.epoch) {
7383            fold(row);
7384        }
7385        let mut out: Vec<Row> = best
7386            .into_values()
7387            .filter_map(|(_, r)| if r.deleted { None } else { Some(r) })
7388            .collect();
7389        out.sort_by_key(|r| r.row_id);
7390        out
7391    }
7392
7393    /// Filter overlay rows against the conjunctive predicate. Range / RangeF64
7394    /// are evaluated directly (the reader-served survivor set misses overlay
7395    /// rows). All other conditions are index-served (indexes maintained on
7396    /// every `put`) so the intersected `survivors` set includes overlay rows
7397    /// that match — but ONLY when every condition is index-served. When there
7398    /// is a mix, we compute per-condition index sets for non-range conditions
7399    /// and evaluate range conditions directly, so the intersection is correct.
7400    fn filter_overlay_rows(
7401        &self,
7402        rows: Vec<Row>,
7403        conditions: &[crate::query::Condition],
7404        survivors: Option<&RowIdSet>,
7405        snapshot: Snapshot,
7406    ) -> Result<Vec<Row>> {
7407        if conditions.is_empty() {
7408            return Ok(rows);
7409        }
7410        use crate::query::Condition;
7411        // Determine whether every condition is index-served (survivors set is
7412        // then complete for overlay rows). If so, a simple membership check
7413        // suffices and is cheapest.
7414        let all_index_served = !conditions
7415            .iter()
7416            .any(|c| matches!(c, Condition::Range { .. } | Condition::RangeF64 { .. }));
7417        if all_index_served {
7418            return Ok(rows
7419                .into_iter()
7420                .filter(|r| survivors.map_or(true, |s| s.contains(r.row_id.0)))
7421                .collect());
7422        }
7423        // Mixed: compute per-condition index sets for non-range conditions, and
7424        // evaluate range conditions directly on column values.
7425        let mut per_cond_sets: Vec<RowIdSet> = Vec::with_capacity(conditions.len());
7426        for c in conditions {
7427            let s = match c {
7428                Condition::Range { .. } | Condition::RangeF64 { .. } => RowIdSet::empty(),
7429                _ => self.resolve_condition(c, snapshot)?,
7430            };
7431            per_cond_sets.push(s);
7432        }
7433        Ok(rows
7434            .into_iter()
7435            .filter(|row| {
7436                conditions.iter().enumerate().all(|(i, c)| match c {
7437                    Condition::Range { column_id, lo, hi } => {
7438                        matches!(row.columns.get(column_id), Some(Value::Int64(v)) if *v >= *lo && *v <= *hi)
7439                    }
7440                    Condition::RangeF64 { column_id, lo, lo_inclusive, hi, hi_inclusive } => {
7441                        match row.columns.get(column_id) {
7442                            Some(Value::Float64(v)) => {
7443                                let lo_ok = if *lo_inclusive { *v >= *lo } else { *v > *lo };
7444                                let hi_ok = if *hi_inclusive { *v <= *hi } else { *v < *hi };
7445                                lo_ok && hi_ok
7446                            }
7447                            _ => false,
7448                        }
7449                    }
7450                    _ => per_cond_sets[i].contains(row.row_id.0),
7451                })
7452            })
7453            .collect())
7454    }
7455
7456    /// Materialize overlay rows into typed `NativeColumn`s for the cursor's
7457    /// final batch.
7458    fn materialize_overlay(
7459        &self,
7460        rows: &[Row],
7461        projection: &[(u16, TypeId)],
7462    ) -> Vec<columnar::NativeColumn> {
7463        if projection.is_empty() {
7464            return vec![columnar::null_native(TypeId::Int64, rows.len())];
7465        }
7466        let mut cols = Vec::with_capacity(projection.len());
7467        for (cid, ty) in projection {
7468            let vals: Vec<Value> = rows
7469                .iter()
7470                .map(|r| r.columns.get(cid).cloned().unwrap_or(Value::Null))
7471                .collect();
7472            cols.push(columnar::values_to_native(ty.clone(), &vals));
7473        }
7474        cols
7475    }
7476
7477    /// Resolve a conjunctive predicate to its surviving `RowId` set on the
7478    /// single-run fast path: each condition becomes a `RowId` set via the
7479    /// in-memory indexes or the reader's page-pruned range scan, then they are
7480    /// intersected. Mirrors the resolution inside [`Self::query_columns_native`].
7481    fn resolve_survivor_rids(
7482        &self,
7483        conditions: &[crate::query::Condition],
7484        reader: &mut RunReader,
7485        snapshot: Snapshot,
7486    ) -> Result<RowIdSet> {
7487        use crate::query::Condition;
7488        let mut sets: Vec<RowIdSet> = Vec::new();
7489        for c in conditions {
7490            self.validate_condition(c)?;
7491            let s: RowIdSet = match c {
7492                Condition::Pk(key) => {
7493                    let lookup = self
7494                        .schema
7495                        .primary_key()
7496                        .map(|pk| self.index_lookup_key_bytes(pk.id, key))
7497                        .unwrap_or_else(|| key.clone());
7498                    self.hot
7499                        .get(&lookup)
7500                        .map(|r| RowIdSet::one(r.0))
7501                        .unwrap_or_else(RowIdSet::empty)
7502                }
7503                Condition::BitmapEq { column_id, value } => {
7504                    let lookup = self.index_lookup_key_bytes(*column_id, value);
7505                    self.bitmap
7506                        .get(column_id)
7507                        .map(|b| RowIdSet::from_roaring(b.get(&lookup)))
7508                        .unwrap_or_else(RowIdSet::empty)
7509                }
7510                Condition::BitmapIn { column_id, values } => {
7511                    let bm = self.bitmap.get(column_id);
7512                    let mut acc = roaring::RoaringBitmap::new();
7513                    if let Some(b) = bm {
7514                        for v in values {
7515                            let lookup = self.index_lookup_key_bytes(*column_id, v);
7516                            acc |= b.get(&lookup);
7517                        }
7518                    }
7519                    RowIdSet::from_roaring(acc)
7520                }
7521                Condition::BytesPrefix { column_id, prefix } => {
7522                    if let Some(b) = self.bitmap.get(column_id) {
7523                        let lookup_prefix = self.index_lookup_key_bytes(*column_id, prefix);
7524                        let mut acc = roaring::RoaringBitmap::new();
7525                        for key in b.keys() {
7526                            if key.starts_with(&lookup_prefix) {
7527                                acc |= b.get(key);
7528                            }
7529                        }
7530                        RowIdSet::from_roaring(acc)
7531                    } else {
7532                        RowIdSet::empty()
7533                    }
7534                }
7535                Condition::FmContains { column_id, pattern } => self
7536                    .fm
7537                    .get(column_id)
7538                    .map(|f| {
7539                        RowIdSet::from_unsorted(
7540                            f.locate(pattern).into_iter().map(|r| r.0).collect(),
7541                        )
7542                    })
7543                    .unwrap_or_else(RowIdSet::empty),
7544                Condition::FmContainsAll {
7545                    column_id,
7546                    patterns,
7547                } => {
7548                    if let Some(f) = self.fm.get(column_id) {
7549                        let sets: Vec<RowIdSet> = patterns
7550                            .iter()
7551                            .map(|pat| {
7552                                RowIdSet::from_unsorted(
7553                                    f.locate(pat).into_iter().map(|r| r.0).collect(),
7554                                )
7555                            })
7556                            .collect();
7557                        RowIdSet::intersect_many(sets)
7558                    } else {
7559                        RowIdSet::empty()
7560                    }
7561                }
7562                Condition::Ann {
7563                    column_id,
7564                    query,
7565                    k,
7566                } => RowIdSet::from_unsorted(
7567                    self.retrieve_filtered(
7568                        &crate::query::Retriever::Ann {
7569                            column_id: *column_id,
7570                            query: query.clone(),
7571                            k: *k,
7572                        },
7573                        snapshot,
7574                        None,
7575                        None,
7576                        None,
7577                        None,
7578                    )?
7579                    .into_iter()
7580                    .map(|hit| hit.row_id.0)
7581                    .collect(),
7582                ),
7583                Condition::SparseMatch {
7584                    column_id,
7585                    query,
7586                    k,
7587                } => RowIdSet::from_unsorted(
7588                    self.retrieve_filtered(
7589                        &crate::query::Retriever::Sparse {
7590                            column_id: *column_id,
7591                            query: query.clone(),
7592                            k: *k,
7593                        },
7594                        snapshot,
7595                        None,
7596                        None,
7597                        None,
7598                        None,
7599                    )?
7600                    .into_iter()
7601                    .map(|hit| hit.row_id.0)
7602                    .collect(),
7603                ),
7604                Condition::MinHashSimilar {
7605                    column_id,
7606                    query,
7607                    k,
7608                } => match self.minhash.get(column_id) {
7609                    Some(index) => {
7610                        let candidates = index.candidate_row_ids(query);
7611                        let eligible =
7612                            self.eligible_candidate_ids(&candidates, *column_id, snapshot, None)?;
7613                        RowIdSet::from_unsorted(
7614                            index
7615                                .search_filtered(query, *k, |row_id| eligible.contains(&row_id))
7616                                .into_iter()
7617                                .map(|(row_id, _)| row_id.0)
7618                                .collect(),
7619                        )
7620                    }
7621                    None => RowIdSet::empty(),
7622                },
7623                Condition::Range { column_id, lo, hi } => {
7624                    if let Some(li) = self.learned_range.get(column_id) {
7625                        RowIdSet::from_unsorted(li.range(*lo, *hi).into_iter().collect())
7626                    } else {
7627                        reader.range_row_id_set_i64(*column_id, *lo, *hi)?
7628                    }
7629                }
7630                Condition::RangeF64 {
7631                    column_id,
7632                    lo,
7633                    lo_inclusive,
7634                    hi,
7635                    hi_inclusive,
7636                } => {
7637                    if let Some(li) = self.learned_range.get(column_id) {
7638                        RowIdSet::from_unsorted(
7639                            li.range_f64(*lo, *lo_inclusive, *hi, *hi_inclusive)
7640                                .into_iter()
7641                                .collect(),
7642                        )
7643                    } else {
7644                        reader.range_row_id_set_f64(
7645                            *column_id,
7646                            *lo,
7647                            *lo_inclusive,
7648                            *hi,
7649                            *hi_inclusive,
7650                        )?
7651                    }
7652                }
7653                Condition::IsNull { column_id } => reader.null_row_id_set(*column_id, true)?,
7654                Condition::IsNotNull { column_id } => reader.null_row_id_set(*column_id, false)?,
7655            };
7656            sets.push(s);
7657        }
7658        Ok(RowIdSet::intersect_many(sets))
7659    }
7660
7661    /// Native vectorized aggregate over a (possibly filtered) column on the
7662    /// single-run fast path (Phase 7.2). Resolves survivors via the same
7663    /// page-pruned cursor as the scan, then accumulates the aggregate in one
7664    /// pass over the typed buffer — no `Value`, no Arrow `RecordBatch`.
7665    ///
7666    /// `column` is `None` for `COUNT(*)`. Returns `Ok(None)` when the fast path
7667    /// does not apply (multi-run / non-empty memtable); the caller scans.
7668    /// Open the streaming [`Cursor`](crate::cursor::Cursor) matching the current
7669    /// run layout: the single-run page cursor when there is exactly one sorted
7670    /// run, otherwise the multi-run k-way merge cursor. Both fuse the predicate,
7671    /// skip non-surviving pages, and fold the memtable / mutable-run overlay, so
7672    /// callers stay columnar end-to-end and never materialize `Row`s. Returns
7673    /// `None` when no cursor applies (e.g. an overlay-only table with no sorted
7674    /// run), leaving the caller to fall back.
7675    ///
7676    /// This is the single source of truth for layout-aware cursor selection,
7677    /// shared by the column scan ([`Self::query_columns_native`] / the SQL
7678    /// provider) and the aggregate path ([`Self::aggregate_native`]). New
7679    /// streaming consumers should build on this rather than re-deciding the
7680    /// cursor by run count.
7681    pub fn scan_cursor(
7682        &self,
7683        snapshot: Snapshot,
7684        projection: Vec<(u16, TypeId)>,
7685        conditions: &[crate::query::Condition],
7686    ) -> Result<Option<Box<dyn crate::cursor::Cursor>>> {
7687        if self.ttl.is_some() {
7688            return Ok(None);
7689        }
7690        // A deferred bulk load leaves the live indexes unbuilt; resolving
7691        // conditions against them would return silently-empty survivor sets.
7692        // Signal "can't serve" so the caller falls back to a `&mut` path that
7693        // runs `ensure_indexes_complete`. (Condition-free scans don't touch
7694        // the indexes and stay served.)
7695        if !conditions.is_empty() && !self.indexes_complete {
7696            return Ok(None);
7697        }
7698        if self.run_refs.len() == 1 {
7699            Ok(self
7700                .native_page_cursor(snapshot, projection, conditions)?
7701                .map(|c| Box::new(c) as Box<dyn crate::cursor::Cursor>))
7702        } else {
7703            Ok(self
7704                .native_multi_run_cursor(snapshot, projection, conditions)?
7705                .map(|c| Box::new(c) as Box<dyn crate::cursor::Cursor>))
7706        }
7707    }
7708
7709    /// Native vectorized aggregate over a (possibly filtered) column, in one
7710    /// pass over the typed buffers — no `Value`, no Arrow batch. Layout-agnostic:
7711    /// survivors stream through [`Self::scan_cursor`] (single- or multi-run,
7712    /// overlay-folded), so the same path serves every sorted-run layout.
7713    ///
7714    /// `column` is `None` for `COUNT(*)`. Order of attempts:
7715    /// 1. Single clean run + no `WHERE` ⇒ `MIN`/`MAX`/`COUNT(col)` straight from
7716    ///    page `min`/`max`/`null_count` (no decode).
7717    /// 2. `COUNT(*)` ⇒ survivor cardinality from the cursor's page plans.
7718    /// 3. Otherwise accumulate the projected column over the cursor.
7719    ///
7720    /// Returns `Ok(None)` (caller scans) when no native path applies: an
7721    /// overlay-only table with no sorted run, or a non-numeric column.
7722    pub fn aggregate_native(
7723        &self,
7724        snapshot: Snapshot,
7725        column: Option<u16>,
7726        conditions: &[crate::query::Condition],
7727        agg: NativeAgg,
7728    ) -> Result<Option<NativeAggResult>> {
7729        if self.ttl.is_some() {
7730            return Ok(None);
7731        }
7732        // 1. Single clean run + no WHERE ⇒ MIN/MAX/COUNT(col) from page stats.
7733        if self.run_refs.len() == 1 && conditions.is_empty() {
7734            if let Some(res) = self.aggregate_from_stats(snapshot, column, agg)? {
7735                return Ok(Some(res));
7736            }
7737        }
7738        // 2. COUNT(*) ⇒ survivor count from the cursor's page plans, no decode.
7739        if matches!(agg, NativeAgg::Count) && column.is_none() {
7740            return Ok(self
7741                .scan_cursor(snapshot, Vec::new(), conditions)?
7742                .map(|c| NativeAggResult::Count(c.remaining_rows() as u64)));
7743        }
7744        // 3. Accumulate the projected column. COUNT(col) excludes nulls — the
7745        //    accumulator's count is the non-null count, which `pack_*` returns.
7746        let cid = match column {
7747            Some(c) => c,
7748            None => return Ok(None),
7749        };
7750        let ty = self.column_type(cid);
7751        let Some(mut cursor) = self.scan_cursor(snapshot, vec![(cid, ty.clone())], conditions)?
7752        else {
7753            return Ok(None);
7754        };
7755        match ty {
7756            TypeId::Int64 | TypeId::TimestampNanos | TypeId::Date32 => {
7757                let (count, sum, mn, mx) = accumulate_int(cursor.as_mut())?;
7758                Ok(Some(pack_int(agg, count, sum, mn, mx)))
7759            }
7760            TypeId::Float64 => {
7761                let (count, sum, mn, mx) = accumulate_float(cursor.as_mut())?;
7762                Ok(Some(pack_float(agg, count, sum, mn, mx)))
7763            }
7764            _ => Ok(None),
7765        }
7766    }
7767
7768    /// Phase 7.1 metadata fast path: answer an unfiltered `MIN`/`MAX`/`COUNT(col)`
7769    /// straight from page `min`/`max`/`null_count` — no column decode. Returns
7770    /// `None` (caller decodes) for `COUNT(*)`/`SUM`/`AVG`, when exact stats are
7771    /// unavailable (multi-version run; [`Table::exact_column_stats`] gates this),
7772    /// or for a column whose stats omit `min`/`max` while it still holds values
7773    /// (e.g. an encrypted column) — returning `NULL` there would be a wrong
7774    /// answer, so we fall back to decoding.
7775    fn aggregate_from_stats(
7776        &self,
7777        snapshot: Snapshot,
7778        column: Option<u16>,
7779        agg: NativeAgg,
7780    ) -> Result<Option<NativeAggResult>> {
7781        let cid = match (agg, column) {
7782            (NativeAgg::Count | NativeAgg::Min | NativeAgg::Max, Some(c)) => c,
7783            _ => return Ok(None), // COUNT(*), SUM, AVG: not served from page stats
7784        };
7785        let Some(stats) = self.exact_column_stats(snapshot, &[cid])? else {
7786            return Ok(None);
7787        };
7788        let Some(cs) = stats.get(&cid) else {
7789            return Ok(None);
7790        };
7791        match agg {
7792            // COUNT(col) excludes NULLs: live rows minus the column's null count.
7793            NativeAgg::Count => Ok(Some(NativeAggResult::Count(
7794                self.live_count.saturating_sub(cs.null_count),
7795            ))),
7796            NativeAgg::Min | NativeAgg::Max => {
7797                let bound = if agg == NativeAgg::Min {
7798                    &cs.min
7799                } else {
7800                    &cs.max
7801                };
7802                match bound {
7803                    Some(Value::Int64(x)) => Ok(Some(NativeAggResult::Int(*x))),
7804                    Some(Value::Float64(x)) => Ok(Some(NativeAggResult::Float(*x))),
7805                    Some(_) => Ok(None), // unexpected stat type ⇒ decode
7806                    // No bound: a genuine SQL NULL only when the column is wholly
7807                    // null. Otherwise the stats are simply unavailable (encrypted),
7808                    // so decode for a correct answer.
7809                    None if cs.null_count >= self.live_count => Ok(Some(NativeAggResult::Null)),
7810                    None => Ok(None),
7811                }
7812            }
7813            _ => Ok(None),
7814        }
7815    }
7816
7817    /// Phase 7.1c: exact `COUNT(DISTINCT col)` from the bitmap index's partition
7818    /// cardinality — the number of distinct indexed values — with no scan. Each
7819    /// distinct value is one bitmap key; under the insert-only invariant (empty
7820    /// overlay, single run, `live_count == row_count`) every key has at least one
7821    /// live row, so the key count is exact. `NULL` is excluded from
7822    /// `COUNT(DISTINCT)`, so a null key (from an explicit `Value::Null` put) is
7823    /// discounted. Returns `None` (caller scans) without a bitmap index on the
7824    /// column or when the invariant does not hold.
7825    pub fn count_distinct_from_bitmap(&mut self, column_id: u16) -> Result<Option<u64>> {
7826        if self.ttl.is_some() {
7827            return Ok(None);
7828        }
7829        if !(self.memtable.is_empty() && self.mutable_run.is_empty() && self.run_refs.len() == 1) {
7830            return Ok(None);
7831        }
7832        // A deferred bulk load leaves the bitmap unbuilt; complete it before
7833        // trusting its key count (same lazy contract as `query`/`flush`).
7834        self.ensure_indexes_complete()?;
7835        let reader = self.open_reader(self.run_refs[0].run_id)?;
7836        if self.live_count != reader.row_count() as u64 {
7837            return Ok(None);
7838        }
7839        let Some(bm) = self.bitmap.get(&column_id) else {
7840            return Ok(None); // no bitmap index ⇒ let the caller scan
7841        };
7842        let mut distinct = bm.value_count() as u64;
7843        // A null key (explicit `Value::Null`) is indexed but excluded from
7844        // COUNT(DISTINCT). (Schema-evolution-absent columns are never indexed.)
7845        if !bm.get(&Value::Null.encode_key()).is_empty() {
7846            distinct = distinct.saturating_sub(1);
7847        }
7848        Ok(Some(distinct))
7849    }
7850
7851    /// Incremental aggregate over the live table (Phase 8.3). For an append-only
7852    /// table, a warm cache entry (same `cache_key`) lets the result be refreshed
7853    /// by aggregating **only the newly inserted rows** (row-id watermark delta)
7854    /// and merging, instead of a full recompute. The caller supplies a stable
7855    /// `cache_key` (e.g. a hash of the SQL + projection); distinct queries must
7856    /// use distinct keys.
7857    ///
7858    /// Returns [`IncrementalAggResult`] with the merged state and whether the
7859    /// delta path was taken. A single `delete` (ever) disables the incremental
7860    /// path for the table, so correctness never relies on append-only behavior
7861    /// that deletes invalidate.
7862    pub fn aggregate_incremental(
7863        &mut self,
7864        cache_key: u64,
7865        conditions: &[crate::query::Condition],
7866        column: Option<u16>,
7867        agg: NativeAgg,
7868    ) -> Result<IncrementalAggResult> {
7869        let snap = self.snapshot();
7870        let cur_wm = self.allocator.current().0;
7871        let cur_epoch = snap.epoch.0;
7872        // The watermark equals the committed row count only when the memtable is
7873        // empty (every allocated row id is durably in a run). With pending
7874        // (uncommitted) writes the allocator is ahead of the visible set, so the
7875        // delta range would silently skip just-committed rows — disable the
7876        // incremental path entirely in that case. The mutable-run tier holding
7877        // un-spilled data also disables it (those rows aren't in a run yet).
7878        let incremental_ok = self.ttl.is_none()
7879            && !self.had_deletes
7880            && self.memtable.is_empty()
7881            && self.mutable_run.is_empty();
7882
7883        // Incremental path: append-only, no pending writes, warm cache, advanced
7884        // epoch.
7885        if incremental_ok {
7886            if let Some(cached) = self.agg_cache.get(&cache_key).cloned() {
7887                if cached.epoch == cur_epoch {
7888                    return Ok(IncrementalAggResult {
7889                        state: cached.state,
7890                        incremental: true,
7891                        delta_rows: 0,
7892                    });
7893                }
7894                if cached.epoch < cur_epoch && cached.watermark <= cur_wm {
7895                    let delta_rids: Vec<u64> = (cached.watermark..cur_wm).collect();
7896                    let delta_rows = self.rows_for_rids(&delta_rids, snap)?;
7897                    let index_sets = self.resolve_index_conditions(conditions, snap)?;
7898                    let delta_state = agg_state_from_rows(
7899                        &delta_rows,
7900                        conditions,
7901                        &index_sets,
7902                        column,
7903                        agg,
7904                        &self.schema,
7905                    )?;
7906                    let merged = cached.state.merge(delta_state);
7907                    let delta_n = delta_rids.len() as u64;
7908                    self.agg_cache.insert(
7909                        cache_key,
7910                        CachedAgg {
7911                            state: merged.clone(),
7912                            watermark: cur_wm,
7913                            epoch: cur_epoch,
7914                        },
7915                    );
7916                    return Ok(IncrementalAggResult {
7917                        state: merged,
7918                        incremental: true,
7919                        delta_rows: delta_n,
7920                    });
7921                }
7922            }
7923        }
7924
7925        // Cold path. For Count/Sum/Min/Max the fast vectorized cursor produces a
7926        // directly-seedable state; for Avg it returns only the mean (losing the
7927        // sum+count needed to merge a future delta), so Avg falls back to a
7928        // visible-rows scan that captures both.
7929        let cursor_ok =
7930            self.memtable.is_empty() && self.mutable_run.is_empty() && self.run_refs.len() == 1;
7931        let state = if cursor_ok && agg != NativeAgg::Avg {
7932            match self.aggregate_native(snap, column, conditions, agg)? {
7933                Some(result) => {
7934                    AggState::from_native(result, agg, column.map(|c| self.column_type(c)))
7935                }
7936                None => self.agg_state_full_scan(conditions, column, agg, snap)?,
7937            }
7938        } else {
7939            self.agg_state_full_scan(conditions, column, agg, snap)?
7940        };
7941        // Seed only when the watermark is meaningful (no pending writes).
7942        if incremental_ok {
7943            self.agg_cache.insert(
7944                cache_key,
7945                CachedAgg {
7946                    state: state.clone(),
7947                    watermark: cur_wm,
7948                    epoch: cur_epoch,
7949                },
7950            );
7951        }
7952        Ok(IncrementalAggResult {
7953            state,
7954            incremental: false,
7955            delta_rows: 0,
7956        })
7957    }
7958
7959    /// Full visible-rows scan → [`AggState`] (cold path; captures sum+count for
7960    /// correct Avg seeding).
7961    fn agg_state_full_scan(
7962        &self,
7963        conditions: &[crate::query::Condition],
7964        column: Option<u16>,
7965        agg: NativeAgg,
7966        snap: Snapshot,
7967    ) -> Result<AggState> {
7968        let rows = self.visible_rows(snap)?;
7969        let index_sets = self.resolve_index_conditions(conditions, snap)?;
7970        agg_state_from_rows(&rows, conditions, &index_sets, column, agg, &self.schema)
7971    }
7972
7973    /// Resolve only the index-defined conditions (`Ann`/`SparseMatch`) to row-id
7974    /// sets for membership testing during row-wise aggregation.
7975    fn resolve_index_conditions(
7976        &self,
7977        conditions: &[crate::query::Condition],
7978        snapshot: Snapshot,
7979    ) -> Result<Vec<RowIdSet>> {
7980        use crate::query::Condition;
7981        let mut sets = Vec::new();
7982        for c in conditions {
7983            if matches!(
7984                c,
7985                Condition::Ann { .. }
7986                    | Condition::SparseMatch { .. }
7987                    | Condition::MinHashSimilar { .. }
7988            ) {
7989                sets.push(self.resolve_condition(c, snapshot)?);
7990            }
7991        }
7992        Ok(sets)
7993    }
7994
7995    fn column_type(&self, cid: u16) -> TypeId {
7996        self.schema
7997            .columns
7998            .iter()
7999            .find(|c| c.id == cid)
8000            .map(|c| c.ty.clone())
8001            .unwrap_or(TypeId::Bytes)
8002    }
8003
8004    /// Approximate `COUNT`/`SUM`/`AVG` over a filtered set, computed from the
8005    /// in-memory reservoir sample (Phase 8.2). Returns a point estimate plus a
8006    /// normal-theory confidence interval at the supplied z-score (1.96 ≈ 95 %).
8007    ///
8008    /// The WHERE predicates are evaluated **exactly** on each sampled row (so
8009    /// LIKE/FM and equality/range contribute no index bias); `Ann`/`SparseMatch`
8010    /// are index-defined and resolved once to a row-id set that sampled rows are
8011    /// tested against. `Ok(None)` when there is no usable sample.
8012    pub fn approx_aggregate(
8013        &mut self,
8014        conditions: &[crate::query::Condition],
8015        column: Option<u16>,
8016        agg: ApproxAgg,
8017        z: f64,
8018    ) -> Result<Option<ApproxResult>> {
8019        use crate::query::Condition;
8020        self.ensure_reservoir_complete()?;
8021        let snapshot = self.snapshot();
8022        let n_pop = self.count();
8023        let sample_rids: Vec<u64> = self.reservoir.row_ids().to_vec();
8024        if sample_rids.is_empty() {
8025            return Ok(None);
8026        }
8027        // Materialize the live, non-deleted sampled rows.
8028        let live_sample = self.rows_for_rids(&sample_rids, snapshot)?;
8029        let s = live_sample.len();
8030        if s == 0 {
8031            return Ok(None);
8032        }
8033
8034        // Pre-resolve Ann/Sparse conditions (index-defined predicates) to row-id
8035        // sets; the per-row predicates below are evaluated exactly.
8036        let mut index_sets: Vec<RowIdSet> = Vec::new();
8037        for c in conditions {
8038            if matches!(
8039                c,
8040                Condition::Ann { .. }
8041                    | Condition::SparseMatch { .. }
8042                    | Condition::MinHashSimilar { .. }
8043            ) {
8044                index_sets.push(self.resolve_condition(c, snapshot)?);
8045            }
8046        }
8047
8048        // For Sum/Avg, gather the numeric column value of each passing row.
8049        let cid = match (agg, column) {
8050            (ApproxAgg::Count, _) => None,
8051            (_, Some(c)) => Some(c),
8052            _ => return Ok(None),
8053        };
8054        let mut passing_vals: Vec<f64> = Vec::with_capacity(s);
8055        for r in &live_sample {
8056            // Exact per-row predicate evaluation.
8057            if !conditions
8058                .iter()
8059                .all(|c| condition_matches_row(c, r, &self.schema))
8060            {
8061                continue;
8062            }
8063            // Ann/Sparse membership.
8064            if !index_sets.iter().all(|set| set.contains(r.row_id.0)) {
8065                continue;
8066            }
8067            if let Some(cid) = cid {
8068                if let Some(v) = as_f64(r.columns.get(&cid)) {
8069                    passing_vals.push(v);
8070                } // nulls ⇒ excluded (matching SQL AVG/SUM null semantics)
8071            } else {
8072                passing_vals.push(0.0); // placeholder for COUNT
8073            }
8074        }
8075        let m = passing_vals.len();
8076
8077        let (point, half) = match agg {
8078            ApproxAgg::Count => {
8079                // Proportion estimate scaled to the population.
8080                let p = m as f64 / s as f64;
8081                let point = n_pop as f64 * p;
8082                let var = if s > 1 {
8083                    n_pop as f64 * n_pop as f64 * p * (1.0 - p) / s as f64
8084                        * (1.0 - s as f64 / n_pop as f64).max(0.0)
8085                } else {
8086                    0.0
8087                };
8088                (point, z * var.sqrt())
8089            }
8090            ApproxAgg::Sum => {
8091                // Horvitz–Thompson: each sampled row represents n_pop/s rows.
8092                let y: Vec<f64> = live_sample
8093                    .iter()
8094                    .map(|r| {
8095                        let passes_row = conditions
8096                            .iter()
8097                            .all(|c| condition_matches_row(c, r, &self.schema))
8098                            && index_sets.iter().all(|set| set.contains(r.row_id.0));
8099                        if passes_row {
8100                            cid.and_then(|c| as_f64(r.columns.get(&c))).unwrap_or(0.0)
8101                        } else {
8102                            0.0
8103                        }
8104                    })
8105                    .collect();
8106                let mean_y = y.iter().sum::<f64>() / s as f64;
8107                let point = n_pop as f64 * mean_y;
8108                let var = if s > 1 {
8109                    let ss: f64 = y.iter().map(|v| (v - mean_y).powi(2)).sum();
8110                    let var_y = ss / (s - 1) as f64;
8111                    n_pop as f64 * n_pop as f64 * var_y / s as f64
8112                        * (1.0 - s as f64 / n_pop as f64).max(0.0)
8113                } else {
8114                    0.0
8115                };
8116                (point, z * var.sqrt())
8117            }
8118            ApproxAgg::Avg => {
8119                if m == 0 {
8120                    return Ok(Some(ApproxResult {
8121                        point: 0.0,
8122                        ci_low: 0.0,
8123                        ci_high: 0.0,
8124                        n_population: n_pop,
8125                        n_sample_live: s,
8126                        n_passing: 0,
8127                    }));
8128                }
8129                let mean = passing_vals.iter().sum::<f64>() / m as f64;
8130                let half = if m > 1 {
8131                    let ss: f64 = passing_vals.iter().map(|v| (v - mean).powi(2)).sum();
8132                    let sd = (ss / (m - 1) as f64).sqrt();
8133                    let fpc = (1.0 - s as f64 / n_pop as f64).max(0.0);
8134                    z * sd / (m as f64).sqrt() * fpc.sqrt()
8135                } else {
8136                    0.0
8137                };
8138                (mean, half)
8139            }
8140        };
8141
8142        Ok(Some(ApproxResult {
8143            point,
8144            ci_low: point - half,
8145            ci_high: point + half,
8146            n_population: n_pop,
8147            n_sample_live: s,
8148            n_passing: m,
8149        }))
8150    }
8151
8152    /// Exact per-column statistics for the analytical aggregate fast path
8153    /// (Phase 7.1: `MIN`/`MAX`/`COUNT(col)` from page stats). Returns `None`
8154    /// unless the table is effectively insert-only at `snapshot` — empty
8155    /// memtable, a single sorted run, and `live_count == run.row_count()` — so
8156    /// the run's page `min`/`max`/`null_count` are exact (no tombstoned or
8157    /// superseded versions skew them). Under deletes/updates the caller falls
8158    /// back to scanning.
8159    pub fn exact_column_stats(
8160        &self,
8161        _snapshot: Snapshot,
8162        projection: &[u16],
8163    ) -> Result<Option<HashMap<u16, ColumnStat>>> {
8164        if self.ttl.is_some()
8165            || !(self.memtable.is_empty()
8166                && self.mutable_run.is_empty()
8167                && self.run_refs.len() == 1)
8168        {
8169            return Ok(None);
8170        }
8171        let reader = self.open_reader(self.run_refs[0].run_id)?;
8172        if self.live_count != reader.row_count() as u64 {
8173            return Ok(None);
8174        }
8175        let mut out = HashMap::new();
8176        for &cid in projection {
8177            let cdef = match self.schema.columns.iter().find(|c| c.id == cid) {
8178                Some(c) => c,
8179                None => continue,
8180            };
8181            // Absent column (schema evolution) ⇒ all rows null.
8182            let Some(stats) = reader.column_page_stats(cid) else {
8183                out.insert(
8184                    cid,
8185                    ColumnStat {
8186                        min: None,
8187                        max: None,
8188                        null_count: self.live_count,
8189                    },
8190                );
8191                continue;
8192            };
8193            let stat = match cdef.ty {
8194                TypeId::Int64 | TypeId::TimestampNanos | TypeId::Date32 => {
8195                    agg_int(stats, crate::sorted_run::be_i64).map(|(mn, mx, n)| ColumnStat {
8196                        min: mn.map(Value::Int64),
8197                        max: mx.map(Value::Int64),
8198                        null_count: n,
8199                    })
8200                }
8201                TypeId::Float64 => {
8202                    agg_float(stats, crate::sorted_run::be_f64).map(|(mn, mx, n)| ColumnStat {
8203                        min: mn.map(Value::Float64),
8204                        max: mx.map(Value::Float64),
8205                        null_count: n,
8206                    })
8207                }
8208                _ => None,
8209            };
8210            if let Some(s) = stat {
8211                out.insert(cid, s);
8212            }
8213        }
8214        Ok(Some(out))
8215    }
8216
8217    pub fn dir(&self) -> &Path {
8218        &self.dir
8219    }
8220
8221    pub fn schema(&self) -> &Schema {
8222        &self.schema
8223    }
8224
8225    pub(crate) fn set_catalog_name(&mut self, name: String) {
8226        self.name = name;
8227    }
8228
8229    pub(crate) fn prepare_alter_column(
8230        &mut self,
8231        column_name: &str,
8232        change: &AlterColumn,
8233    ) -> Result<ColumnDef> {
8234        if !self.pending_rows.is_empty() || !self.pending_dels.is_empty() {
8235            return Err(MongrelError::InvalidArgument(
8236                "ALTER COLUMN requires committing staged writes first".into(),
8237            ));
8238        }
8239        let old = self
8240            .schema
8241            .columns
8242            .iter()
8243            .find(|c| c.name == column_name)
8244            .cloned()
8245            .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
8246        let mut next = old.clone();
8247
8248        if let Some(name) = &change.name {
8249            let trimmed = name.trim();
8250            if trimmed.is_empty() {
8251                return Err(MongrelError::InvalidArgument(
8252                    "ALTER COLUMN name must not be empty".into(),
8253                ));
8254            }
8255            if trimmed != old.name && self.schema.columns.iter().any(|c| c.name == trimmed) {
8256                return Err(MongrelError::Schema(format!(
8257                    "column {trimmed} already exists"
8258                )));
8259            }
8260            next.name = trimmed.to_string();
8261        }
8262
8263        if let Some(ty) = &change.ty {
8264            next.ty = ty.clone();
8265        }
8266        if let Some(flags) = change.flags {
8267            validate_alter_column_flags(old.flags, flags)?;
8268            next.flags = flags;
8269        }
8270
8271        if let Some(default_change) = &change.default_value {
8272            next.default_value = default_change.clone();
8273        }
8274
8275        validate_alter_column_type(&self.schema, &old, &next, self.has_stored_versions())?;
8276        if old.flags.contains(ColumnFlags::NULLABLE)
8277            && !next.flags.contains(ColumnFlags::NULLABLE)
8278            && self.column_has_nulls(old.id)?
8279        {
8280            return Err(MongrelError::InvalidArgument(format!(
8281                "column '{}' contains NULL values",
8282                old.name
8283            )));
8284        }
8285        Ok(next)
8286    }
8287
8288    pub(crate) fn apply_altered_column(&mut self, column: ColumnDef) -> Result<()> {
8289        let idx = self
8290            .schema
8291            .columns
8292            .iter()
8293            .position(|c| c.id == column.id)
8294            .ok_or_else(|| MongrelError::Schema(format!("unknown column {}", column.id)))?;
8295        if self.schema.columns[idx] == column {
8296            return Ok(());
8297        }
8298        self.schema.columns[idx] = column;
8299        self.schema.schema_id = self.schema.schema_id.saturating_add(1);
8300        self.schema.validate_auto_increment()?;
8301        self.schema.validate_defaults()?;
8302        self.auto_inc = resolve_auto_inc(&self.schema);
8303        self.column_keys = build_column_keys(self.kek.as_deref(), &self.schema);
8304        write_schema(&self.dir, &self.schema)?;
8305        self.clear_result_cache();
8306        let _ = std::fs::remove_dir_all(self.dir.join("_shadow"));
8307        self.persist_manifest(self.current_epoch())?;
8308        Ok(())
8309    }
8310
8311    pub fn alter_column(&mut self, column_name: &str, change: AlterColumn) -> Result<ColumnDef> {
8312        self.ensure_writable()?;
8313        let column = self.prepare_alter_column(column_name, &change)?;
8314        self.apply_altered_column(column.clone())?;
8315        Ok(column)
8316    }
8317
8318    fn column_has_nulls(&mut self, column_id: u16) -> Result<bool> {
8319        if self.live_count == 0 {
8320            return Ok(false);
8321        }
8322        let snap = self.snapshot();
8323        let columns = self.visible_columns_native(snap, Some(&[column_id]))?;
8324        Ok(columns
8325            .first()
8326            .map(|(_, col)| col.null_count(col.len()) != 0)
8327            .unwrap_or(true))
8328    }
8329
8330    fn has_stored_versions(&self) -> bool {
8331        !self.memtable.is_empty()
8332            || !self.mutable_run.is_empty()
8333            || self.run_refs.iter().any(|r| r.row_count > 0)
8334            || !self.retiring.is_empty()
8335    }
8336
8337    /// Add a column to the schema (schema evolution). Existing runs simply read
8338    /// back as null for the new column until re-written. Persists the new schema
8339    /// and manifest. The caller supplies the full [`ColumnFlags`] so migrations
8340    /// can add `PRIMARY KEY` / `AUTO_INCREMENT` columns correctly.
8341    pub fn add_column(
8342        &mut self,
8343        name: &str,
8344        ty: TypeId,
8345        flags: ColumnFlags,
8346        default_value: Option<crate::schema::DefaultExpr>,
8347    ) -> Result<u16> {
8348        self.add_column_with_id(name, ty, flags, default_value, None)
8349    }
8350
8351    pub fn add_column_with_id(
8352        &mut self,
8353        name: &str,
8354        ty: TypeId,
8355        flags: ColumnFlags,
8356        default_value: Option<crate::schema::DefaultExpr>,
8357        requested_id: Option<u16>,
8358    ) -> Result<u16> {
8359        self.ensure_writable()?;
8360        if self.schema.columns.iter().any(|c| c.name == name) {
8361            return Err(MongrelError::Schema(format!(
8362                "column {name} already exists"
8363            )));
8364        }
8365        let id = if let Some(id) = requested_id.filter(|id| *id != 0) {
8366            if self.schema.columns.iter().any(|c| c.id == id) {
8367                return Err(MongrelError::Schema(format!(
8368                    "column id {id} already exists"
8369                )));
8370            }
8371            id
8372        } else {
8373            self.schema
8374                .columns
8375                .iter()
8376                .map(|c| c.id)
8377                .max()
8378                .unwrap_or(0)
8379                .checked_add(1)
8380                .ok_or_else(|| MongrelError::Schema("column id space exhausted".into()))?
8381        };
8382        self.schema.columns.push(ColumnDef {
8383            id,
8384            name: name.to_string(),
8385            ty,
8386            flags,
8387            default_value,
8388        });
8389        self.schema.schema_id = self.schema.schema_id.saturating_add(1);
8390        self.schema.validate_auto_increment()?;
8391        self.schema.validate_defaults()?;
8392        if flags.contains(ColumnFlags::AUTO_INCREMENT) {
8393            self.auto_inc = resolve_auto_inc(&self.schema);
8394        }
8395        write_schema(&self.dir, &self.schema)?;
8396        self.clear_result_cache();
8397        // Phase 15.5: invalidate Arrow IPC shadows (schema changed).
8398        let _ = std::fs::remove_dir_all(self.dir.join("_shadow"));
8399        self.persist_manifest(self.current_epoch())?;
8400        Ok(id)
8401    }
8402
8403    /// Declare a `LearnedRange` (PGM) index on an existing numeric column and
8404    /// build it immediately from the current sorted run (Phase 13.3). After
8405    /// this, `Condition::Range` / `Condition::RangeF64` on that column resolve
8406    /// survivors sub-linearly (O(log segments + log ε)) instead of scanning the
8407    /// full column.
8408    ///
8409    /// Requires exactly one sorted run (call after `flush`). The index is
8410    /// rebuilt automatically on subsequent flushes.
8411    pub fn add_learned_range_index(&mut self, column_name: &str) -> Result<()> {
8412        self.ensure_writable()?;
8413        let cid = self
8414            .schema
8415            .columns
8416            .iter()
8417            .find(|c| c.name == column_name)
8418            .map(|c| c.id)
8419            .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
8420        let ty = self
8421            .schema
8422            .columns
8423            .iter()
8424            .find(|c| c.id == cid)
8425            .map(|c| c.ty.clone())
8426            .unwrap_or(TypeId::Int64);
8427        if !matches!(
8428            ty,
8429            TypeId::Int64 | TypeId::Float64 | TypeId::TimestampNanos | TypeId::Date32
8430        ) {
8431            return Err(MongrelError::Schema(format!(
8432                "LearnedRange requires a numeric column; {column_name} is {ty:?}"
8433            )));
8434        }
8435        if self
8436            .schema
8437            .indexes
8438            .iter()
8439            .any(|i| i.column_id == cid && i.kind == IndexKind::LearnedRange)
8440        {
8441            return Ok(()); // already declared
8442        }
8443        self.schema.indexes.push(IndexDef {
8444            name: format!("{}_learned_range", column_name),
8445            column_id: cid,
8446            kind: IndexKind::LearnedRange,
8447            predicate: None,
8448            options: Default::default(),
8449        });
8450        self.schema.schema_id = self.schema.schema_id.saturating_add(1);
8451        write_schema(&self.dir, &self.schema)?;
8452        self.build_learned_ranges()?;
8453        Ok(())
8454    }
8455
8456    /// Tuning knob for the WAL auto-sync threshold. A no-op on a mounted table
8457    /// (the shared WAL's durability is governed by the group-commit coordinator).
8458    pub fn set_sync_byte_threshold(&mut self, threshold: u64) {
8459        self.sync_byte_threshold = threshold;
8460        if let WalSink::Private(w) = &mut self.wal {
8461            w.set_sync_byte_threshold(threshold);
8462        }
8463    }
8464
8465    /// Flush all live page-cache entries to the persistent `_cache/` backing
8466    /// directory (best-effort). Useful before a clean shutdown so hot pages
8467    /// survive restart.
8468    pub fn page_cache_flush(&self) {
8469        self.page_cache.flush_to_disk();
8470    }
8471
8472    /// Number of entries currently in the shared page cache (diagnostic).
8473    pub fn page_cache_len(&self) -> usize {
8474        self.page_cache.len()
8475    }
8476
8477    /// Number of entries currently in the shared decoded-page cache (Phase
8478    /// 15.4 diagnostic).
8479    pub fn decoded_cache_len(&self) -> usize {
8480        self.decoded_cache.len()
8481    }
8482
8483    /// Drain the live memtable (prototype/testing helper used by the flush path
8484    /// demos). Prefer [`Table::flush`] for the durable path.
8485    pub fn drain_memtable_sorted(&mut self) -> Vec<Row> {
8486        self.memtable.drain_sorted()
8487    }
8488
8489    pub(crate) fn run_path(&self, run_id: u64) -> PathBuf {
8490        self.dir.join(RUNS_DIR).join(format!("r-{run_id}.sr"))
8491    }
8492
8493    pub(crate) fn table_dir(&self) -> &Path {
8494        &self.dir
8495    }
8496
8497    pub(crate) fn schema_ref(&self) -> &crate::schema::Schema {
8498        &self.schema
8499    }
8500
8501    pub(crate) fn alloc_run_id(&mut self) -> u64 {
8502        let id = self.next_run_id;
8503        self.next_run_id += 1;
8504        id
8505    }
8506
8507    pub(crate) fn link_run(&mut self, run_ref: crate::manifest::RunRef) {
8508        self.run_refs.push(run_ref);
8509    }
8510
8511    /// Link a spilled run found during shared-WAL recovery (spec §8.5).
8512    /// **Idempotent**: if the run is already in the manifest (the publish phase
8513    /// persisted it before the crash, or this is a clean reopen with the
8514    /// `TxnCommit` still in the WAL) this is a no-op returning `false`, so the
8515    /// caller never double-links or double-counts. Otherwise — a crash *after*
8516    /// the commit fsync but *before* publish persisted the manifest — the run is
8517    /// Enqueue a compaction-superseded run for retention-gated deletion (spec
8518    /// §6.4). The file stays on disk until [`Self::reap_retiring`] removes it
8519    /// once `min_active_snapshot` has advanced past `retire_epoch`.
8520    pub(crate) fn retire_run(&mut self, run_id: u128, retire_epoch: u64) {
8521        self.retiring.push(crate::manifest::RetiredRun {
8522            run_id,
8523            retire_epoch,
8524        });
8525    }
8526
8527    /// Physically delete retired run files whose `retire_epoch` no pinned reader
8528    /// can still need (`min_active >= retire_epoch`), drop them from the queue,
8529    /// and persist the manifest if anything changed. Returns the count reaped.
8530    pub(crate) fn reap_retiring(
8531        &mut self,
8532        min_active: Epoch,
8533        backup_pinned: &std::collections::HashSet<u128>,
8534    ) -> Result<usize> {
8535        if self.retiring.is_empty() {
8536            return Ok(0);
8537        }
8538        let mut reaped = 0;
8539        let mut kept: Vec<crate::manifest::RetiredRun> = Vec::new();
8540        // Delete-then-persist is crash-idempotent: if we crash after unlinking
8541        // some files but before persisting, the manifest still lists them in
8542        // `retiring`; the next `reap_retiring` re-issues `remove_file` (the
8543        // error is ignored) and `check()` excludes `retiring` ids from orphan
8544        // detection, so the lingering entries are harmless until then.
8545        for r in std::mem::take(&mut self.retiring) {
8546            if min_active.0 >= r.retire_epoch && !backup_pinned.contains(&r.run_id) {
8547                let _ = std::fs::remove_file(self.run_path(r.run_id as u64));
8548                reaped += 1;
8549            } else {
8550                kept.push(r);
8551            }
8552        }
8553        self.retiring = kept;
8554        if reaped > 0 {
8555            self.persist_manifest(self.current_epoch())?;
8556        }
8557        Ok(reaped)
8558    }
8559
8560    pub(crate) fn recover_spilled_run(&mut self, run_ref: crate::manifest::RunRef) -> bool {
8561        if self.run_refs.iter().any(|r| r.run_id == run_ref.run_id) {
8562            return false;
8563        }
8564        self.live_count = self.live_count.saturating_add(run_ref.row_count);
8565        self.run_refs.push(run_ref);
8566        self.indexes_complete = false;
8567        true
8568    }
8569
8570    pub(crate) fn kek_ref(&self) -> Option<&Arc<Kek>> {
8571        self.kek.as_ref()
8572    }
8573
8574    pub(crate) fn open_reader(&self, run_id: u128) -> Result<RunReader> {
8575        let mut reader = RunReader::open_with_cache(
8576            self.dir.join(RUNS_DIR).join(format!("r-{run_id}.sr")),
8577            self.schema.clone(),
8578            self.kek.clone(),
8579            Some(self.page_cache.clone()),
8580            Some(self.decoded_cache.clone()),
8581            self.table_id,
8582            Some(&self.verified_runs),
8583        )?;
8584        // Overlay the real commit epoch for uniform-epoch (large-txn spill) runs:
8585        // their stored `_epoch` is a placeholder; the manifest RunRef carries the
8586        // assigned epoch. A no-op for ordinary runs.
8587        if let Some(rr) = self.run_refs.iter().find(|r| r.run_id == run_id) {
8588            reader.set_uniform_epoch(Epoch(rr.epoch_created));
8589        }
8590        Ok(reader)
8591    }
8592
8593    pub(crate) fn run_refs(&self) -> &[RunRef] {
8594        &self.run_refs
8595    }
8596
8597    pub(crate) fn retiring_run_ids(&self) -> impl Iterator<Item = u128> + '_ {
8598        self.retiring.iter().map(|run| run.run_id)
8599    }
8600
8601    pub(crate) fn runs_dir(&self) -> PathBuf {
8602        self.dir.join(RUNS_DIR)
8603    }
8604
8605    pub(crate) fn wal_dir(&self) -> PathBuf {
8606        self.dir.join(WAL_DIR)
8607    }
8608
8609    pub(crate) fn set_run_refs(&mut self, refs: Vec<RunRef>) {
8610        self.run_refs = refs;
8611    }
8612
8613    pub(crate) fn next_run_id(&self) -> u64 {
8614        self.next_run_id
8615    }
8616
8617    pub(crate) fn compaction_zstd_level(&self) -> i32 {
8618        self.compaction_zstd_level
8619    }
8620
8621    pub(crate) fn bump_next_run_id(&mut self) {
8622        self.next_run_id += 1;
8623    }
8624
8625    pub(crate) fn kek(&self) -> Option<Arc<Kek>> {
8626        self.kek.clone()
8627    }
8628
8629    /// The index-checkpoint DEK (KEK-derived) for encrypted tables; `None` for
8630    /// plaintext tables. The checkpoint embeds index keys / PGM segment values
8631    /// derived from user data, so an encrypted table must encrypt it at rest.
8632    #[cfg(feature = "encryption")]
8633    fn idx_dek(&self) -> Option<Zeroizing<[u8; DEK_LEN]>> {
8634        self.kek.as_ref().map(|k| k.derive_idx_key())
8635    }
8636
8637    #[cfg(not(feature = "encryption"))]
8638    fn idx_dek(&self) -> Option<Zeroizing<[u8; DEK_LEN]>> {
8639        None
8640    }
8641
8642    /// Manifest (and other DB-wide metadata) meta DEK, derived from the KEK so
8643    /// the on-disk manifest is encrypted + authenticated at rest for encrypted
8644    /// tables. `None` for plaintext.
8645    #[cfg(feature = "encryption")]
8646    fn manifest_meta_dek(&self) -> Option<[u8; DEK_LEN]> {
8647        self.kek.as_ref().map(|k| *k.derive_meta_key())
8648    }
8649
8650    #[cfg(not(feature = "encryption"))]
8651    fn manifest_meta_dek(&self) -> Option<[u8; DEK_LEN]> {
8652        None
8653    }
8654
8655    /// `(column_id, scheme)` for every ENCRYPTED_INDEXABLE column — passed to
8656    /// the run writer so each run's descriptor records the column keys.
8657    pub(crate) fn indexable_column_specs(&self) -> Vec<(u16, u8)> {
8658        self.column_keys
8659            .iter()
8660            .map(|(&id, &(_, scheme))| (id, scheme))
8661            .collect()
8662    }
8663
8664    /// Tokenize a value for an ENCRYPTED_INDEXABLE column (HMAC-eq or OPE-range,
8665    /// per the column's scheme). Returns `None` for plaintext columns. Indexes
8666    /// over such columns store tokens, and queries tokenize literals the same
8667    /// way — so lookups never decrypt the stored (encrypted) page payloads.
8668    #[cfg(feature = "encryption")]
8669    fn tokenize_value(&self, column_id: u16, v: &Value) -> Option<Value> {
8670        self.tokenize_value_enc(column_id, v)
8671    }
8672
8673    #[cfg(feature = "encryption")]
8674    fn tokenize_value_enc(&self, column_id: u16, v: &Value) -> Option<Value> {
8675        use crate::encryption::{hmac_token, ope_token_f64, ope_token_i64, SCHEME_HMAC_EQ};
8676        let (key, scheme) = self.column_keys.get(&column_id)?;
8677        let token: Vec<u8> = match (*scheme, v) {
8678            (SCHEME_HMAC_EQ, _) => hmac_token(key, &v.encode_key()).to_vec(),
8679            (_, Value::Int64(x)) => ope_token_i64(key, *x).to_vec(),
8680            (_, Value::Float64(x)) => ope_token_f64(key, *x).to_vec(),
8681            _ => hmac_token(key, &v.encode_key()).to_vec(),
8682        };
8683        Some(Value::Bytes(token))
8684    }
8685
8686    /// Encoded index key for a `Value`, tokenized for HMAC-eq columns.
8687    fn index_lookup_key(&self, column_id: u16, v: &Value) -> Vec<u8> {
8688        self.index_lookup_key_bytes(column_id, &v.encode_key())
8689    }
8690
8691    /// Tokenize an already-encoded lookup key (equality queries pass the
8692    /// encoded search value; HMAC-eq columns wrap it under the column key).
8693    fn index_lookup_key_bytes(&self, column_id: u16, encoded: &[u8]) -> Vec<u8> {
8694        #[cfg(feature = "encryption")]
8695        {
8696            use crate::encryption::{hmac_token, SCHEME_HMAC_EQ};
8697            if let Some((key, scheme)) = self.column_keys.get(&column_id) {
8698                if *scheme == SCHEME_HMAC_EQ {
8699                    return hmac_token(key, encoded).to_vec();
8700                }
8701            }
8702        }
8703        let _ = column_id;
8704        encoded.to_vec()
8705    }
8706}
8707
8708fn native_int64_strictly_increasing(col: &columnar::NativeColumn, n: usize) -> bool {
8709    let columnar::NativeColumn::Int64 { data, validity } = col else {
8710        return false;
8711    };
8712    if data.len() < n || !columnar::all_non_null(validity, n) {
8713        return false;
8714    }
8715    data.iter()
8716        .take(n)
8717        .zip(data.iter().skip(1))
8718        .all(|(a, b)| a < b)
8719}
8720
8721/// Exact aggregate of a column's page stats into a min/max/null_count triple
8722/// (Phase 7.1). Only meaningful when the owning table is insert-only, which
8723/// [`Table::exact_column_stats`] gates on.
8724#[derive(Debug, Clone)]
8725pub struct ColumnStat {
8726    pub min: Option<Value>,
8727    pub max: Option<Value>,
8728    pub null_count: u64,
8729}
8730
8731/// A supported native aggregate (Phase 7.2).
8732#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8733pub enum NativeAgg {
8734    Count,
8735    Sum,
8736    Min,
8737    Max,
8738    Avg,
8739}
8740
8741/// The typed result of a [`NativeAgg`] over a column.
8742#[derive(Debug, Clone, PartialEq)]
8743pub enum NativeAggResult {
8744    Count(u64),
8745    Int(i64),
8746    Float(f64),
8747    /// No non-null inputs (SUM/MIN/MAX/AVG over zero rows ⇒ SQL NULL).
8748    Null,
8749}
8750
8751/// A supported approximate aggregate over the reservoir sample (Phase 8.2).
8752#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8753pub enum ApproxAgg {
8754    Count,
8755    Sum,
8756    Avg,
8757}
8758
8759/// Point estimate with a normal-theory confidence interval from the reservoir
8760/// sample (Phase 8.2). `ci_low`/`ci_high` bracket `point` at the requested
8761/// z-score; the interval has zero width when the sample equals the whole table.
8762#[derive(Debug, Clone)]
8763pub struct ApproxResult {
8764    /// Point estimate of the aggregate.
8765    pub point: f64,
8766    /// Lower bound (`point − z·SE`).
8767    pub ci_low: f64,
8768    /// Upper bound (`point + z·SE`).
8769    pub ci_high: f64,
8770    /// Live population size (the table's `count()`).
8771    pub n_population: u64,
8772    /// Live rows in the sample (`≤` reservoir capacity).
8773    pub n_sample_live: usize,
8774    /// Sampled rows passing the WHERE predicate.
8775    pub n_passing: usize,
8776}
8777
8778/// A mergeable running aggregate state (Phase 8.3). Two states over disjoint
8779/// row sets `merge` into the state over their union, so a cached analytical
8780/// aggregate can be updated by merging in only the delta (newly inserted rows)
8781/// instead of a full recompute.
8782#[derive(Debug, Clone, PartialEq)]
8783pub enum AggState {
8784    /// `COUNT(*)` or `COUNT(col)` over `n` matching rows.
8785    Count(u64),
8786    /// Int64 `SUM`: running `i128` sum + non-null count.
8787    SumI {
8788        sum: i128,
8789        count: u64,
8790    },
8791    /// Float64 `SUM`: running `f64` sum + non-null count.
8792    SumF {
8793        sum: f64,
8794        count: u64,
8795    },
8796    /// Int64 `AVG`: running `i128` sum + non-null count (avg = sum/count).
8797    AvgI {
8798        sum: i128,
8799        count: u64,
8800    },
8801    /// Float64 `AVG`: running `f64` sum + non-null count.
8802    AvgF {
8803        sum: f64,
8804        count: u64,
8805    },
8806    /// Int64 `MIN`/`MAX`.
8807    MinI(i64),
8808    MaxI(i64),
8809    /// Float64 `MIN`/`MAX`.
8810    MinF(f64),
8811    MaxF(f64),
8812    /// No matching rows observed yet.
8813    Empty,
8814}
8815
8816impl AggState {
8817    /// Combine two states over disjoint row sets into the state over the union.
8818    pub fn merge(self, other: AggState) -> AggState {
8819        use AggState::*;
8820        match (self, other) {
8821            (Empty, x) | (x, Empty) => x,
8822            (Count(a), Count(b)) => Count(a + b),
8823            (SumI { sum: sa, count: ca }, SumI { sum: sb, count: cb }) => SumI {
8824                sum: sa + sb,
8825                count: ca + cb,
8826            },
8827            (SumF { sum: sa, count: ca }, SumF { sum: sb, count: cb }) => SumF {
8828                sum: sa + sb,
8829                count: ca + cb,
8830            },
8831            (AvgI { sum: sa, count: ca }, AvgI { sum: sb, count: cb }) => AvgI {
8832                sum: sa + sb,
8833                count: ca + cb,
8834            },
8835            (AvgF { sum: sa, count: ca }, AvgF { sum: sb, count: cb }) => AvgF {
8836                sum: sa + sb,
8837                count: ca + cb,
8838            },
8839            (MinI(a), MinI(b)) => MinI(a.min(b)),
8840            (MaxI(a), MaxI(b)) => MaxI(a.max(b)),
8841            (MinF(a), MinF(b)) => MinF(a.min(b)),
8842            (MaxF(a), MaxF(b)) => MaxF(a.max(b)),
8843            _ => Empty, // mismatched kinds — shouldn't happen (same query)
8844        }
8845    }
8846
8847    /// The scalar point value (`f64`), or `None` when there were no inputs.
8848    pub fn point(&self) -> Option<f64> {
8849        match self {
8850            AggState::Count(n) => Some(*n as f64),
8851            AggState::SumI { sum, .. } => Some(*sum as f64),
8852            AggState::SumF { sum, .. } => Some(*sum),
8853            AggState::AvgI { sum, count } if *count > 0 => Some(*sum as f64 / *count as f64),
8854            AggState::AvgF { sum, count } if *count > 0 => Some(*sum / *count as f64),
8855            AggState::MinI(n) => Some(*n as f64),
8856            AggState::MaxI(n) => Some(*n as f64),
8857            AggState::MinF(n) => Some(*n),
8858            AggState::MaxF(n) => Some(*n),
8859            AggState::AvgI { .. } | AggState::AvgF { .. } | AggState::Empty => None,
8860        }
8861    }
8862
8863    /// Convert a vectorized [`NativeAggResult`] (from the cursor path) into a
8864    /// mergeable [`AggState`], so the incremental cache can be seeded from the
8865    /// fast cold path. `ty` is the column's type (`None` for COUNT(*)).
8866    pub fn from_native(result: NativeAggResult, agg: NativeAgg, ty: Option<TypeId>) -> Self {
8867        let is_float = matches!(ty, Some(TypeId::Float64));
8868        match (agg, result) {
8869            (NativeAgg::Count, NativeAggResult::Count(n)) => AggState::Count(n),
8870            (NativeAgg::Sum, NativeAggResult::Int(x)) => AggState::SumI {
8871                sum: x as i128,
8872                count: 1, // count unknown from NativeAggResult; use sentinel
8873            },
8874            (NativeAgg::Sum, NativeAggResult::Float(x)) => AggState::SumF { sum: x, count: 1 },
8875            (NativeAgg::Avg, NativeAggResult::Float(x)) => AggState::AvgF { sum: x, count: 1 },
8876            (NativeAgg::Min, NativeAggResult::Int(x)) => AggState::MinI(x),
8877            (NativeAgg::Max, NativeAggResult::Int(x)) => AggState::MaxI(x),
8878            (NativeAgg::Min, NativeAggResult::Float(x)) => AggState::MinF(x),
8879            (NativeAgg::Max, NativeAggResult::Float(x)) => AggState::MaxF(x),
8880            (NativeAgg::Count, _) => AggState::Empty,
8881            (_, NativeAggResult::Null) => AggState::Empty,
8882            _ => {
8883                let _ = is_float;
8884                AggState::Empty
8885            }
8886        }
8887    }
8888}
8889
8890/// A cached incremental aggregate (Phase 8.3): the mergeable state, the row-id
8891/// watermark it covers (rows `[0, watermark)`), and the snapshot epoch.
8892#[derive(Debug, Clone)]
8893pub struct CachedAgg {
8894    pub state: AggState,
8895    pub watermark: u64,
8896    pub epoch: u64,
8897}
8898
8899/// Outcome of [`Table::aggregate_incremental`].
8900#[derive(Debug, Clone)]
8901pub struct IncrementalAggResult {
8902    /// The aggregate state covering all rows at the current epoch.
8903    pub state: AggState,
8904    /// `true` when produced by merging only the delta (new rows); `false` when
8905    /// a full recompute was required (cold cache, deletes, or same epoch).
8906    pub incremental: bool,
8907    /// Rows processed in the delta pass (`0` for a full recompute).
8908    pub delta_rows: u64,
8909}
8910
8911/// Compute a mergeable [`AggState`] over `rows` that pass every per-row
8912/// `conditions` conjunct (and whose row id is in every pre-resolved
8913/// `index_sets`). Shared by the cold (full) and warm (delta) incremental paths.
8914fn agg_state_from_rows(
8915    rows: &[Row],
8916    conditions: &[crate::query::Condition],
8917    index_sets: &[RowIdSet],
8918    column: Option<u16>,
8919    agg: NativeAgg,
8920    schema: &Schema,
8921) -> Result<AggState> {
8922    let mut count: u64 = 0;
8923    let mut sum_i: i128 = 0;
8924    let mut sum_f: f64 = 0.0;
8925    let mut mn_i: i64 = i64::MAX;
8926    let mut mx_i: i64 = i64::MIN;
8927    let mut mn_f: f64 = f64::INFINITY;
8928    let mut mx_f: f64 = f64::NEG_INFINITY;
8929    let mut saw_int = false;
8930    let mut saw_float = false;
8931    for r in rows {
8932        if !conditions
8933            .iter()
8934            .all(|c| condition_matches_row(c, r, schema))
8935        {
8936            continue;
8937        }
8938        if !index_sets.iter().all(|s| s.contains(r.row_id.0)) {
8939            continue;
8940        }
8941        match agg {
8942            NativeAgg::Count => match column {
8943                // COUNT(*) counts every passing row.
8944                None => count += 1,
8945                // COUNT(col) excludes NULLs — explicit `Value::Null` and a column
8946                // absent from the row (schema evolution) are both NULL.
8947                Some(cid) => match r.columns.get(&cid) {
8948                    None | Some(Value::Null) => {}
8949                    Some(_) => count += 1,
8950                },
8951            },
8952            _ => match column.and_then(|cid| r.columns.get(&cid)) {
8953                Some(Value::Int64(n)) => {
8954                    count += 1;
8955                    sum_i += *n as i128;
8956                    mn_i = mn_i.min(*n);
8957                    mx_i = mx_i.max(*n);
8958                    saw_int = true;
8959                }
8960                Some(Value::Float64(f)) => {
8961                    count += 1;
8962                    sum_f += f;
8963                    mn_f = mn_f.min(*f);
8964                    mx_f = mx_f.max(*f);
8965                    saw_float = true;
8966                }
8967                _ => {}
8968            },
8969        }
8970    }
8971    Ok(match agg {
8972        NativeAgg::Count => {
8973            if count == 0 {
8974                AggState::Empty
8975            } else {
8976                AggState::Count(count)
8977            }
8978        }
8979        NativeAgg::Sum => {
8980            if count == 0 {
8981                AggState::Empty
8982            } else if saw_int {
8983                AggState::SumI { sum: sum_i, count }
8984            } else {
8985                AggState::SumF { sum: sum_f, count }
8986            }
8987        }
8988        NativeAgg::Avg => {
8989            if count == 0 {
8990                AggState::Empty
8991            } else if saw_int {
8992                AggState::AvgI { sum: sum_i, count }
8993            } else {
8994                AggState::AvgF { sum: sum_f, count }
8995            }
8996        }
8997        NativeAgg::Min => {
8998            if !saw_int && !saw_float {
8999                AggState::Empty
9000            } else if saw_int {
9001                AggState::MinI(mn_i)
9002            } else {
9003                AggState::MinF(mn_f)
9004            }
9005        }
9006        NativeAgg::Max => {
9007            if !saw_int && !saw_float {
9008                AggState::Empty
9009            } else if saw_int {
9010                AggState::MaxI(mx_i)
9011            } else {
9012                AggState::MaxF(mx_f)
9013            }
9014        }
9015    })
9016}
9017
9018/// Evaluate an index-served [`Condition`] exactly against a materialized row.
9019/// `Ann`/`SparseMatch` (index-defined) always pass here; callers test those via a
9020/// pre-resolved row-id set.
9021fn condition_matches_row(c: &crate::query::Condition, row: &Row, schema: &Schema) -> bool {
9022    use crate::query::Condition;
9023    match c {
9024        Condition::Pk(key) => match schema.primary_key() {
9025            Some(pk) => row
9026                .columns
9027                .get(&pk.id)
9028                .map(|v| v.encode_key() == *key)
9029                .unwrap_or(false),
9030            None => false,
9031        },
9032        Condition::BitmapEq { column_id, value } => row
9033            .columns
9034            .get(column_id)
9035            .map(|v| v.encode_key() == *value)
9036            .unwrap_or(false),
9037        Condition::BitmapIn { column_id, values } => {
9038            let key = row.columns.get(column_id).map(|v| v.encode_key());
9039            match key {
9040                Some(k) => values.contains(&k),
9041                None => false,
9042            }
9043        }
9044        Condition::BytesPrefix { column_id, prefix } => row
9045            .columns
9046            .get(column_id)
9047            .map(|v| v.encode_key().starts_with(prefix))
9048            .unwrap_or(false),
9049        Condition::Range { column_id, lo, hi } => match row.columns.get(column_id) {
9050            Some(Value::Int64(n)) => *n >= *lo && *n <= *hi,
9051            _ => false,
9052        },
9053        Condition::RangeF64 {
9054            column_id,
9055            lo,
9056            lo_inclusive,
9057            hi,
9058            hi_inclusive,
9059        } => match row.columns.get(column_id) {
9060            Some(Value::Float64(n)) => {
9061                let lo_ok = if *lo_inclusive { *n >= *lo } else { *n > *lo };
9062                let hi_ok = if *hi_inclusive { *n <= *hi } else { *n < *hi };
9063                lo_ok && hi_ok
9064            }
9065            _ => false,
9066        },
9067        Condition::FmContains { column_id, pattern } => match row.columns.get(column_id) {
9068            Some(Value::Bytes(b)) => {
9069                !pattern.is_empty() && b.windows(pattern.len()).any(|w| w == &pattern[..])
9070            }
9071            _ => false,
9072        },
9073        Condition::FmContainsAll {
9074            column_id,
9075            patterns,
9076        } => match row.columns.get(column_id) {
9077            Some(Value::Bytes(b)) => patterns
9078                .iter()
9079                .all(|pat| !pat.is_empty() && b.windows(pat.len()).any(|w| w == &pat[..])),
9080            _ => false,
9081        },
9082        Condition::Ann { .. }
9083        | Condition::SparseMatch { .. }
9084        | Condition::MinHashSimilar { .. } => true,
9085        Condition::IsNull { column_id } => {
9086            matches!(row.columns.get(column_id), Some(Value::Null) | None)
9087        }
9088        Condition::IsNotNull { column_id } => {
9089            !matches!(row.columns.get(column_id), Some(Value::Null) | None)
9090        }
9091    }
9092}
9093
9094/// Coerce a cell to `f64` for Sum/Avg (Int64/Float64 only).
9095fn as_f64(v: Option<&Value>) -> Option<f64> {
9096    match v {
9097        Some(Value::Int64(n)) => Some(*n as f64),
9098        Some(Value::Float64(f)) => Some(*f),
9099        _ => None,
9100    }
9101}
9102
9103/// One-pass vectorized accumulation of `(non-null count, sum, min, max)` over an
9104/// Int64 column streamed through `cursor`. The inner loop over a contiguous
9105/// `&[i64]` autovectorizes (SIMD) for the all-non-null prefix.
9106fn accumulate_int(cursor: &mut dyn crate::cursor::Cursor) -> Result<(u64, i128, i64, i64)> {
9107    let mut count: u64 = 0;
9108    let mut sum: i128 = 0;
9109    let mut mn: i64 = i64::MAX;
9110    let mut mx: i64 = i64::MIN;
9111    while let Some(cols) = cursor.next_batch()? {
9112        if let Some(crate::columnar::NativeColumn::Int64 { data, validity }) = cols.first() {
9113            if crate::columnar::all_non_null(validity, data.len()) {
9114                // All-non-null: vectorized sum/min/max with no per-element branch.
9115                count += data.len() as u64;
9116                sum += data.iter().map(|&v| v as i128).sum::<i128>();
9117                mn = mn.min(*data.iter().min().unwrap_or(&mn));
9118                mx = mx.max(*data.iter().max().unwrap_or(&mx));
9119            } else {
9120                for (i, &v) in data.iter().enumerate() {
9121                    if crate::columnar::validity_bit(validity, i) {
9122                        count += 1;
9123                        sum += v as i128;
9124                        mn = mn.min(v);
9125                        mx = mx.max(v);
9126                    }
9127                }
9128            }
9129        }
9130    }
9131    Ok((count, sum, mn, mx))
9132}
9133
9134/// f64 analogue of [`accumulate_int`].
9135fn accumulate_float(cursor: &mut dyn crate::cursor::Cursor) -> Result<(u64, f64, f64, f64)> {
9136    let mut count: u64 = 0;
9137    let mut sum: f64 = 0.0;
9138    let mut mn: f64 = f64::INFINITY;
9139    let mut mx: f64 = f64::NEG_INFINITY;
9140    while let Some(cols) = cursor.next_batch()? {
9141        if let Some(crate::columnar::NativeColumn::Float64 { data, validity }) = cols.first() {
9142            if crate::columnar::all_non_null(validity, data.len()) {
9143                count += data.len() as u64;
9144                sum += data.iter().sum::<f64>();
9145                mn = mn.min(data.iter().copied().fold(f64::INFINITY, f64::min));
9146                mx = mx.max(data.iter().copied().fold(f64::NEG_INFINITY, f64::max));
9147            } else {
9148                for (i, &v) in data.iter().enumerate() {
9149                    if crate::columnar::validity_bit(validity, i) {
9150                        count += 1;
9151                        sum += v;
9152                        mn = mn.min(v);
9153                        mx = mx.max(v);
9154                    }
9155                }
9156            }
9157        }
9158    }
9159    Ok((count, sum, mn, mx))
9160}
9161
9162fn pack_int(agg: NativeAgg, count: u64, sum: i128, mn: i64, mx: i64) -> NativeAggResult {
9163    if count == 0 && !matches!(agg, NativeAgg::Count) {
9164        return NativeAggResult::Null;
9165    }
9166    match agg {
9167        NativeAgg::Count => NativeAggResult::Count(count),
9168        // i64 overflow on Sum ⇒ SQL NULL (DataFusion errors on overflow; null is
9169        // a safe, non-misleading fallback rather than a saturated wrong value).
9170        NativeAgg::Sum => match sum.try_into() {
9171            Ok(v) => NativeAggResult::Int(v),
9172            Err(_) => NativeAggResult::Null,
9173        },
9174        NativeAgg::Min => NativeAggResult::Int(mn),
9175        NativeAgg::Max => NativeAggResult::Int(mx),
9176        NativeAgg::Avg => NativeAggResult::Float((sum as f64) / (count as f64)),
9177    }
9178}
9179
9180fn pack_float(agg: NativeAgg, count: u64, sum: f64, mn: f64, mx: f64) -> NativeAggResult {
9181    if count == 0 && !matches!(agg, NativeAgg::Count) {
9182        return NativeAggResult::Null;
9183    }
9184    match agg {
9185        NativeAgg::Count => NativeAggResult::Count(count),
9186        NativeAgg::Sum => NativeAggResult::Float(sum),
9187        NativeAgg::Min => NativeAggResult::Float(mn),
9188        NativeAgg::Max => NativeAggResult::Float(mx),
9189        NativeAgg::Avg => NativeAggResult::Float(sum / (count as f64)),
9190    }
9191}
9192
9193/// Aggregate per-page `min`/`max`/`null_count` into a column-wide i64 triple.
9194/// Returns `None` if no page contributes a non-null min/max (all-null column).
9195fn agg_int(
9196    stats: &[crate::page::PageStat],
9197    decode: fn(Option<&[u8]>) -> Option<i64>,
9198) -> Option<(Option<i64>, Option<i64>, u64)> {
9199    let (mut mn, mut mx, mut nulls) = (i64::MAX, i64::MIN, 0u64);
9200    let mut any = false;
9201    for s in stats {
9202        if let Some(v) = decode(s.min.as_deref()) {
9203            mn = mn.min(v);
9204            any = true;
9205        }
9206        if let Some(v) = decode(s.max.as_deref()) {
9207            mx = mx.max(v);
9208            any = true;
9209        }
9210        nulls += s.null_count;
9211    }
9212    any.then_some((Some(mn), Some(mx), nulls))
9213}
9214
9215/// f64 analogue of [`agg_int`] (compares as f64, not as bit patterns).
9216fn agg_float(
9217    stats: &[crate::page::PageStat],
9218    decode: fn(Option<&[u8]>) -> Option<f64>,
9219) -> Option<(Option<f64>, Option<f64>, u64)> {
9220    let (mut mn, mut mx, mut nulls) = (f64::INFINITY, f64::NEG_INFINITY, 0u64);
9221    let mut any = false;
9222    for s in stats {
9223        if let Some(v) = decode(s.min.as_deref()) {
9224            mn = mn.min(v);
9225            any = true;
9226        }
9227        if let Some(v) = decode(s.max.as_deref()) {
9228            mx = mx.max(v);
9229            any = true;
9230        }
9231        nulls += s.null_count;
9232    }
9233    any.then_some((Some(mn), Some(mx), nulls))
9234}
9235
9236/// The four maintained secondary-index maps, keyed by column id.
9237type SecondaryIndexes = (
9238    HashMap<u16, BitmapIndex>,
9239    HashMap<u16, AnnIndex>,
9240    HashMap<u16, FmIndex>,
9241    HashMap<u16, SparseIndex>,
9242    HashMap<u16, MinHashIndex>,
9243);
9244
9245fn empty_indexes(schema: &Schema) -> SecondaryIndexes {
9246    let mut bitmap = HashMap::new();
9247    let mut ann = HashMap::new();
9248    let mut fm = HashMap::new();
9249    let mut sparse = HashMap::new();
9250    let mut minhash = HashMap::new();
9251    for idef in &schema.indexes {
9252        match idef.kind {
9253            IndexKind::Bitmap => {
9254                bitmap.insert(idef.column_id, BitmapIndex::new());
9255            }
9256            IndexKind::Ann => {
9257                let dim = schema
9258                    .columns
9259                    .iter()
9260                    .find(|c| c.id == idef.column_id)
9261                    .and_then(|c| match c.ty {
9262                        TypeId::Embedding { dim } => Some(dim as usize),
9263                        _ => None,
9264                    })
9265                    .unwrap_or(0);
9266                let options = idef.options.ann.clone().unwrap_or_default();
9267                ann.insert(
9268                    idef.column_id,
9269                    AnnIndex::with_options(
9270                        dim,
9271                        options.m,
9272                        options.ef_construction,
9273                        options.ef_search,
9274                    ),
9275                );
9276            }
9277            IndexKind::FmIndex => {
9278                fm.insert(idef.column_id, FmIndex::new());
9279            }
9280            IndexKind::Sparse => {
9281                sparse.insert(idef.column_id, SparseIndex::new());
9282            }
9283            IndexKind::MinHash => {
9284                let options = idef.options.minhash.clone().unwrap_or_default();
9285                minhash.insert(
9286                    idef.column_id,
9287                    MinHashIndex::with_options(options.permutations, options.bands),
9288                );
9289            }
9290            _ => {}
9291        }
9292    }
9293    (bitmap, ann, fm, sparse, minhash)
9294}
9295
9296const ALTER_COLUMN_PROTECTED_FLAGS: u32 = ColumnFlags::PRIMARY_KEY
9297    | ColumnFlags::AUTO_INCREMENT
9298    | ColumnFlags::ENCRYPTED
9299    | ColumnFlags::ENCRYPTED_INDEXABLE
9300    | ColumnFlags::EMBEDDING_BINARY_QUANTIZED;
9301
9302fn validate_alter_column_flags(old: ColumnFlags, new: ColumnFlags) -> Result<()> {
9303    if (old.bits() ^ new.bits()) & ALTER_COLUMN_PROTECTED_FLAGS != 0 {
9304        return Err(MongrelError::Schema(
9305            "ALTER COLUMN may only change NULLABLE; primary key, auto-increment, encryption, and embedding flags are immutable".into(),
9306        ));
9307    }
9308    Ok(())
9309}
9310
9311fn validate_alter_column_type(
9312    schema: &Schema,
9313    old: &ColumnDef,
9314    next: &ColumnDef,
9315    has_stored_versions: bool,
9316) -> Result<()> {
9317    if old.ty == next.ty {
9318        return Ok(());
9319    }
9320    if schema.indexes.iter().any(|i| i.column_id == old.id) {
9321        return Err(MongrelError::Schema(format!(
9322            "ALTER COLUMN TYPE is not supported for indexed column '{}'",
9323            old.name
9324        )));
9325    }
9326    if !has_stored_versions || storage_compatible_type_change(old.ty.clone(), next.ty.clone()) {
9327        return Ok(());
9328    }
9329    Err(MongrelError::Schema(format!(
9330        "ALTER COLUMN TYPE from {:?} to {:?} requires an empty column or a representation-compatible type",
9331        old.ty, next.ty
9332    )))
9333}
9334
9335fn storage_compatible_type_change(old: TypeId, new: TypeId) -> bool {
9336    matches!(
9337        (old, new),
9338        (TypeId::Int64, TypeId::TimestampNanos) | (TypeId::TimestampNanos, TypeId::Int64)
9339    )
9340}
9341
9342/// True when every row carries an `Int64` PK value and the sequence is
9343/// strictly increasing — no intra-batch duplicate is possible. The row-major
9344/// mirror of `native_int64_strictly_increasing` (the `bulk_pk_winner_indices`
9345/// fast path), used by `apply_put_rows_inner` to skip upsert probing for
9346/// append-style batches.
9347fn rows_pk_strictly_increasing(rows: &[Row], pk_id: u16) -> bool {
9348    let mut prev: Option<i64> = None;
9349    for r in rows {
9350        match r.columns.get(&pk_id) {
9351            Some(Value::Int64(v)) => {
9352                if prev.is_some_and(|p| p >= *v) {
9353                    return false;
9354                }
9355                prev = Some(*v);
9356            }
9357            _ => return false,
9358        }
9359    }
9360    true
9361}
9362
9363#[allow(clippy::too_many_arguments)]
9364fn index_into(
9365    schema: &Schema,
9366    row: &Row,
9367    hot: &mut HotIndex,
9368    bitmap: &mut HashMap<u16, BitmapIndex>,
9369    ann: &mut HashMap<u16, AnnIndex>,
9370    fm: &mut HashMap<u16, FmIndex>,
9371    sparse: &mut HashMap<u16, SparseIndex>,
9372    minhash: &mut HashMap<u16, MinHashIndex>,
9373) {
9374    for idef in &schema.indexes {
9375        let Some(val) = row.columns.get(&idef.column_id) else {
9376            continue;
9377        };
9378        match idef.kind {
9379            IndexKind::Bitmap => {
9380                if let Some(b) = bitmap.get_mut(&idef.column_id) {
9381                    b.insert(val.encode_key(), row.row_id);
9382                }
9383            }
9384            IndexKind::Ann => {
9385                if let (Some(a), Value::Embedding(v)) = (ann.get_mut(&idef.column_id), val) {
9386                    a.insert_validated(v, row.row_id);
9387                }
9388            }
9389            IndexKind::FmIndex => {
9390                if let (Some(f), Value::Bytes(b)) = (fm.get_mut(&idef.column_id), val) {
9391                    f.insert(b.clone(), row.row_id);
9392                }
9393            }
9394            IndexKind::Sparse => {
9395                if let (Some(s), Value::Bytes(b)) = (sparse.get_mut(&idef.column_id), val) {
9396                    // A sparse vector is stored as a bincode'd `Vec<(u32, f32)>`
9397                    // in a Bytes column (SPLADE weights in, retrieval out).
9398                    if let Ok(terms) = bincode::deserialize::<Vec<(u32, f32)>>(b) {
9399                        s.insert(&terms, row.row_id);
9400                    }
9401                }
9402            }
9403            IndexKind::MinHash => {
9404                if let (Some(mh), Value::Bytes(b)) = (minhash.get_mut(&idef.column_id), val) {
9405                    // The set is a JSON array (the Kit's `set_similarity` shape);
9406                    // tokenize + hash its members into the MinHash signature.
9407                    let tokens = crate::index::token_hashes_from_bytes(b);
9408                    mh.insert(&tokens, row.row_id);
9409                }
9410            }
9411            _ => {}
9412        }
9413    }
9414    if let Some(pk_col) = schema.primary_key() {
9415        if let Some(pk_val) = row.columns.get(&pk_col.id) {
9416            hot.insert(pk_val.encode_key(), row.row_id);
9417        }
9418    }
9419}
9420
9421/// Index a row into a single specific index (used for partial indexes where
9422/// only matching indexes should receive the row).
9423#[allow(clippy::too_many_arguments)]
9424fn index_into_single(
9425    idef: &IndexDef,
9426    _schema: &Schema,
9427    row: &Row,
9428    _hot: &mut HotIndex,
9429    bitmap: &mut HashMap<u16, BitmapIndex>,
9430    ann: &mut HashMap<u16, AnnIndex>,
9431    fm: &mut HashMap<u16, FmIndex>,
9432    sparse: &mut HashMap<u16, SparseIndex>,
9433    minhash: &mut HashMap<u16, MinHashIndex>,
9434) {
9435    let Some(val) = row.columns.get(&idef.column_id) else {
9436        return;
9437    };
9438    match idef.kind {
9439        IndexKind::Bitmap => {
9440            if let Some(b) = bitmap.get_mut(&idef.column_id) {
9441                b.insert(val.encode_key(), row.row_id);
9442            }
9443        }
9444        IndexKind::Ann => {
9445            if let (Some(a), Value::Embedding(v)) = (ann.get_mut(&idef.column_id), val) {
9446                a.insert_validated(v, row.row_id);
9447            }
9448        }
9449        IndexKind::FmIndex => {
9450            if let (Some(f), Value::Bytes(b)) = (fm.get_mut(&idef.column_id), val) {
9451                f.insert(b.clone(), row.row_id);
9452            }
9453        }
9454        IndexKind::Sparse => {
9455            if let (Some(s), Value::Bytes(b)) = (sparse.get_mut(&idef.column_id), val) {
9456                if let Ok(terms) = bincode::deserialize::<Vec<(u32, f32)>>(b) {
9457                    s.insert(&terms, row.row_id);
9458                }
9459            }
9460        }
9461        IndexKind::MinHash => {
9462            if let (Some(mh), Value::Bytes(b)) = (minhash.get_mut(&idef.column_id), val) {
9463                let tokens = crate::index::token_hashes_from_bytes(b);
9464                mh.insert(&tokens, row.row_id);
9465            }
9466        }
9467        _ => {}
9468    }
9469}
9470
9471/// Evaluate a partial-index predicate against a row. Supports the most common
9472/// patterns: `"column IS NOT NULL"` and `"column IS NULL"`. More complex
9473/// expressions require a full SQL evaluator in core (future work); the
9474/// predicate string is stored verbatim and this function provides a pragmatic
9475/// subset. Returns `true` if the row should be indexed.
9476fn eval_partial_predicate(
9477    pred: &str,
9478    columns_map: &HashMap<u16, &Value>,
9479    name_to_id: &HashMap<&str, u16>,
9480) -> bool {
9481    let lower = pred.trim().to_ascii_lowercase();
9482    // Pattern: "column_name IS NOT NULL"
9483    if let Some(rest) = lower.strip_suffix(" is not null") {
9484        let col_name = rest.trim();
9485        if let Some(col_id) = name_to_id.get(col_name) {
9486            return columns_map
9487                .get(col_id)
9488                .is_some_and(|v| !matches!(v, Value::Null));
9489        }
9490    }
9491    // Pattern: "column_name IS NULL"
9492    if let Some(rest) = lower.strip_suffix(" is null") {
9493        let col_name = rest.trim();
9494        if let Some(col_id) = name_to_id.get(col_name) {
9495            return columns_map
9496                .get(col_id)
9497                .map_or(true, |v| matches!(v, Value::Null));
9498        }
9499    }
9500    // Unknown predicate syntax: index the row (conservative — better to
9501    // over-index than to miss rows).
9502    true
9503}
9504
9505/// Per-element index key for the typed bulk-index path (Phase 14.2): mirrors
9506/// `index_into` on a `tokenized_for_indexes(row)` — encodes the element the way
9507/// [`Value::encode_key`] would, then applies the column's
9508/// `ENCRYPTED_INDEXABLE` tokenization (HMAC-eq / OPE) so bitmap/HOT keys match
9509/// what the incremental path stores. Returns `None` for null slots.
9510#[allow(dead_code)]
9511fn bulk_index_key(
9512    column_keys: &HashMap<u16, ([u8; 32], u8)>,
9513    column_id: u16,
9514    ty: TypeId,
9515    col: &columnar::NativeColumn,
9516    i: usize,
9517) -> Option<Vec<u8>> {
9518    let encoded = columnar::encode_key_native(ty, col, i)?;
9519    #[cfg(feature = "encryption")]
9520    {
9521        use crate::encryption::{hmac_token, ope_token_f64, ope_token_i64, SCHEME_HMAC_EQ};
9522        if let Some((key, scheme)) = column_keys.get(&column_id) {
9523            return Some(match (*scheme, col) {
9524                (SCHEME_HMAC_EQ, _) => hmac_token(key, &encoded).to_vec(),
9525                (_, columnar::NativeColumn::Int64 { data, .. }) => {
9526                    ope_token_i64(key, data[i]).to_vec()
9527                }
9528                (_, columnar::NativeColumn::Float64 { data, .. }) => {
9529                    ope_token_f64(key, data[i]).to_vec()
9530                }
9531                _ => hmac_token(key, &encoded).to_vec(),
9532            });
9533        }
9534    }
9535    #[cfg(not(feature = "encryption"))]
9536    {
9537        let _ = (column_id, column_keys, col);
9538    }
9539    Some(encoded)
9540}
9541
9542pub(crate) fn write_schema(dir: &Path, schema: &Schema) -> Result<()> {
9543    let json = serde_json::to_string_pretty(schema)
9544        .map_err(|e| MongrelError::Schema(format!("encode schema: {e}")))?;
9545    std::fs::write(dir.join(SCHEMA_FILENAME), json)?;
9546    Ok(())
9547}
9548
9549fn read_schema(dir: &Path) -> Result<Schema> {
9550    serde_json::from_str(&std::fs::read_to_string(dir.join(SCHEMA_FILENAME))?)
9551        .map_err(|e| MongrelError::Schema(format!("decode schema: {e}")))
9552}
9553
9554fn next_wal_segment(wal_dir: &Path) -> Result<PathBuf> {
9555    Ok(wal_dir.join(format!("seg-{:06}.wal", next_wal_number(wal_dir)?)))
9556}
9557
9558fn latest_wal_segment(wal_dir: &Path) -> Result<Option<PathBuf>> {
9559    let n = list_wal_numbers(wal_dir)?;
9560    Ok(n.map(|max| wal_dir.join(format!("seg-{max:06}.wal"))))
9561}
9562
9563fn next_wal_number(wal_dir: &Path) -> Result<u32> {
9564    Ok(list_wal_numbers(wal_dir)?.map(|m| m + 1).unwrap_or(0))
9565}
9566
9567fn list_wal_numbers(wal_dir: &Path) -> Result<Option<u32>> {
9568    let _ = std::fs::create_dir_all(wal_dir);
9569    let mut max_n = None;
9570    for entry in std::fs::read_dir(wal_dir)? {
9571        let entry = entry?;
9572        let fname = entry.file_name();
9573        let Some(s) = fname.to_str() else {
9574            continue;
9575        };
9576        let Some(stripped) = s.strip_prefix("seg-") else {
9577            continue;
9578        };
9579        let Some(stripped) = stripped.strip_suffix(".wal") else {
9580            continue;
9581        };
9582        if let Ok(n) = stripped.parse::<u32>() {
9583            max_n = Some(max_n.map(|m: u32| m.max(n)).unwrap_or(n));
9584        }
9585    }
9586    Ok(max_n)
9587}