Skip to main content

ailake_query/
scanner.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2use std::sync::Arc;
3
4use futures::future::try_join_all;
5use rayon::prelude::*;
6use tracing::{debug, error, warn};
7
8use ailake_catalog::{
9    CatalogProvider, DataFileEntry, IndexStatus, SchemaField, TableIdent, TableMetadata,
10};
11use ailake_core::{AilakeError, AilakeResult, EmbeddingModelInfo, RowId, VectorMetric};
12use ailake_file::AilakeFileReader;
13use ailake_index::AnyIndex;
14use ailake_store::Store;
15use ailake_vec::exact_distance;
16use arrow_array::{Array, RecordBatch};
17use bytes::Bytes;
18
19use crate::equality_delete::EqualityDeleteFilter;
20use crate::pruner::{BloomPruner, VectorPruner};
21use crate::schema_filler::SchemaFiller;
22
23/// Injectable per-result scoring function for hybrid ranking.
24///
25/// Called after HNSW retrieval with the HNSW distance and a single-row
26/// `RecordBatch` containing all Parquet columns for that result. Returns a
27/// replacement score (lower = better rank, same convention as distance).
28///
29/// Typical use: combine HNSW distance with recency and importance signals
30/// from the `episodic_columns` for agent memory tables:
31///
32/// ```rust,no_run
33/// use ailake_core::{hybrid_score, episodic_columns};
34/// use ailake_query::scanner::ScoreFn;
35/// use arrow_array::{RecordBatch, cast::AsArray};
36/// use arrow_array::types::Float32Type;
37///
38/// let score_fn = ScoreFn::new(|distance, row| {
39///     let recency = row
40///         .column_by_name(episodic_columns::RECENCY_WEIGHT)
41///         .and_then(|c| c.as_primitive_opt::<Float32Type>())
42///         .and_then(|a| a.iter().next().flatten())
43///         .unwrap_or(1.0);
44///     let importance = row
45///         .column_by_name(episodic_columns::IMPORTANCE_SCORE)
46///         .and_then(|c| c.as_primitive_opt::<Float32Type>())
47///         .and_then(|a| a.iter().next().flatten())
48///         .unwrap_or(1.0);
49///     hybrid_score(distance, recency, importance)
50/// });
51/// ```
52#[allow(clippy::type_complexity)]
53pub struct ScoreFn(pub std::sync::Arc<dyn Fn(f32, &RecordBatch) -> f32 + Send + Sync>);
54
55impl ScoreFn {
56    pub fn new(f: impl Fn(f32, &RecordBatch) -> f32 + Send + Sync + 'static) -> Self {
57        Self(std::sync::Arc::new(f))
58    }
59
60    #[inline]
61    pub fn call(&self, distance: f32, row: &RecordBatch) -> f32 {
62        (self.0)(distance, row)
63    }
64}
65
66impl Clone for ScoreFn {
67    fn clone(&self) -> Self {
68        Self(std::sync::Arc::clone(&self.0))
69    }
70}
71
72impl std::fmt::Debug for ScoreFn {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.write_str("ScoreFn(<fn>)")
75    }
76}
77
78#[derive(Debug, Clone)]
79pub struct SearchConfig {
80    pub top_k: usize,
81    pub ef_search: usize,
82    /// Maximum distance from query to file centroid edge for a file to be searched.
83    /// Files where `distance(query, centroid) - radius > pruning_threshold` are skipped.
84    /// Set to `f32::INFINITY` to disable pruning (scan all files).
85    pub pruning_threshold: f32,
86    /// When `Some(factor)`, fetch `top_k * factor` candidates from the HNSW index and
87    /// rerank them using exact F32 distances before truncating to `top_k`.
88    /// Corrects the approximation error introduced by PQ-compressed HNSW distances.
89    /// `None` (default) disables reranking.
90    pub rerank_factor: Option<usize>,
91    /// Hybrid BM25+vector search configuration.
92    ///
93    /// When set, the pipeline loads global IDF stats from the table's BM25 stats file,
94    /// fetches a larger candidate pool from HNSW (`candidate_pool` or `10 * top_k`),
95    /// scores each candidate with BM25 against `query_text`, then fuses vector distance
96    /// and BM25 score via RRF (default) or linear combination.
97    ///
98    /// The BM25 stats file (`metadata/ailake_bm25_stats.bin`) is populated automatically
99    /// by `TableWriter` when `bm25_text_column` is configured. If absent, pure vector
100    /// distances are used (BM25 scores default to 0).
101    pub hybrid: Option<crate::bm25::HybridConfig>,
102    /// Optional scoring function for hybrid ranking.
103    ///
104    /// When set, the search pipeline reads the Parquet row for each HNSW
105    /// candidate and calls `score_fn(distance, &single_row_batch)`. The
106    /// returned value replaces `distance` in `SearchResult` and determines
107    /// final ranking (lower = better).
108    ///
109    /// If `rerank_factor` is also set, `score_fn` receives the exact
110    /// (non-approximated) distance from the reranking step.
111    ///
112    /// Use `ScoreFn::new(|d, row| ...)` to construct. See `ScoreFn` docs
113    /// for an example using `hybrid_score` with episodic memory columns.
114    pub score_fn: Option<ScoreFn>,
115    /// Partition filter: only search files whose `DataFileEntry::partition_value`
116    /// matches this string. `None` searches all files (no partition pruning).
117    /// Set to `agent_id` in Agent.recall() for per-agent isolated search.
118    pub partition_filter: Option<String>,
119    /// Column-level predicate pushed down to the Parquet read of each surviving
120    /// file, via `AilakeFileReader::read_parquet_filtered` (row-group statistics
121    /// skip + exact Arrow `RowFilter` — see `ailake-parquet::ParquetVectorReader`).
122    /// Applies only where a Parquet read already happens (flat-scan fallback,
123    /// reranking, hybrid, `score_fn`, or equality-delete row checks) — it does
124    /// not gate which files survive centroid/partition pruning, and it isn't
125    /// applied against columns only materialized later by `SchemaFiller`
126    /// (schema-evolution defaults). `None` disables pushdown (default).
127    pub column_filter: Option<ailake_core::ColumnFilter>,
128}
129
130impl Default for SearchConfig {
131    fn default() -> Self {
132        Self {
133            top_k: 10,
134            ef_search: 50,
135            pruning_threshold: f32::INFINITY,
136            rerank_factor: None,
137            score_fn: None,
138            partition_filter: None,
139            hybrid: None,
140            column_filter: None,
141        }
142    }
143}
144
145impl SearchConfig {
146    pub fn with_column_filter(mut self, filter: ailake_core::ColumnFilter) -> Self {
147        self.column_filter = Some(filter);
148        self
149    }
150
151    pub fn with_pruning(mut self, threshold: f32) -> Self {
152        self.pruning_threshold = threshold;
153        self
154    }
155
156    pub fn with_reranking(mut self, factor: usize) -> Self {
157        self.rerank_factor = Some(factor);
158        self
159    }
160
161    pub fn with_score_fn(
162        mut self,
163        f: impl Fn(f32, &RecordBatch) -> f32 + Send + Sync + 'static,
164    ) -> Self {
165        self.score_fn = Some(ScoreFn::new(f));
166        self
167    }
168
169    pub fn with_hybrid(mut self, cfg: crate::bm25::HybridConfig) -> Self {
170        self.hybrid = Some(cfg);
171        self
172    }
173}
174
175#[derive(Debug)]
176pub struct SearchResult {
177    pub row_id: RowId,
178    pub distance: f32,
179    pub file_path: String,
180}
181
182/// Search across all files in the latest snapshot, with geometric pruning.
183///
184/// Flow:
185/// 1. Load file list from catalog (includes centroid metadata)
186/// 2. Prune files whose centroid + radius cannot contain a result within `pruning_threshold`
187/// 3. For surviving files: load bytes, deserialize HNSW, run top-k search
188/// 4. Global merge of all per-file top-k lists, return global top-k
189pub async fn search(
190    table: &TableIdent,
191    query: &[f32],
192    config: SearchConfig,
193    vector_column: &str,
194    dim: u32,
195    catalog: Arc<dyn CatalogProvider>,
196    store: Arc<dyn Store>,
197) -> AilakeResult<Vec<SearchResult>> {
198    if config.top_k > ailake_core::MAX_TOP_K {
199        return Err(AilakeError::InvalidArgument(format!(
200            "top_k {} exceeds maximum supported value ({})",
201            config.top_k,
202            ailake_core::MAX_TOP_K
203        )));
204    }
205    // Get file metadata (includes centroid info) without reading any data files
206    let all_files = catalog.list_files(table, None).await?;
207
208    // Determine vector metric from table metadata for correct distance computation
209    let table_meta = catalog.load_table(table).await?;
210
211    // Validate query dim against the column's stored dim.
212    // Primary column: use `ailake.vector-dim`. Secondary columns: use `ailake.dim-<col>`.
213    // Skip validation when the column has no stored dim (e.g. old tables written before
214    // multi-column support).
215    let primary_col = table_meta
216        .properties
217        .get("ailake.vector-column")
218        .map(String::as_str)
219        .unwrap_or("");
220    let stored_dim_key = if vector_column == primary_col {
221        "ailake.vector-dim".to_string()
222    } else {
223        format!("ailake.dim-{vector_column}")
224    };
225    if let Some(table_dim_str) = table_meta.properties.get(&stored_dim_key) {
226        if let Ok(table_dim) = table_dim_str.parse::<u32>() {
227            let query_dim = query.len() as u32;
228            if query_dim != table_dim {
229                let table_model = table_meta
230                    .properties
231                    .get(EmbeddingModelInfo::property_key())
232                    .cloned()
233                    .unwrap_or_else(|| format!("dim={}", table_dim));
234                return Err(AilakeError::ModelMismatch {
235                    table_model,
236                    table_dim,
237                    batch_model: format!("query dim={}", query_dim),
238                    batch_dim: query_dim,
239                });
240            }
241        }
242    }
243
244    // Metric: prefer per-column `ailake.metric-<col>`, fall back to primary metric.
245    let metric_key = if vector_column == primary_col {
246        "ailake.vector-metric".to_string()
247    } else {
248        format!("ailake.metric-{vector_column}")
249    };
250    let metric = parse_metric(
251        table_meta
252            .properties
253            .get(&metric_key)
254            .or_else(|| table_meta.properties.get("ailake.vector-metric"))
255            .map(String::as_str)
256            .unwrap_or("cosine"),
257    );
258
259    // Partition pruning: skip files not belonging to the requested partition value.
260    let all_files = if let Some(ref pv) = config.partition_filter {
261        let before = all_files.len();
262        let filtered: Vec<_> = all_files
263            .into_iter()
264            .filter(|f| f.partition_value.as_deref() == Some(pv.as_str()))
265            .collect();
266        debug!(
267            "ailake: partition pruning '{}' — {}/{} files survive",
268            pv,
269            filtered.len(),
270            before
271        );
272        filtered
273    } else {
274        all_files
275    };
276
277    // Geometric pruning: skip files whose centroid is too far from the query
278    let total_files = all_files.len();
279    let surviving_files = VectorPruner::prune(all_files, query, metric, config.pruning_threshold);
280    debug!(
281        "ailake: geometric pruning — {}/{} files survive (threshold={})",
282        surviving_files.len(),
283        total_files,
284        config.pruning_threshold
285    );
286
287    // Phase F — Bloom pruning: for hybrid queries, load per-file Bloom filters from
288    // the Puffin stats file and skip files where no query term can be present.
289    let surviving_files = if let Some(ref h) = config.hybrid {
290        let bloom_map = load_bloom_map(&table_meta, store.as_ref()).await;
291        if !bloom_map.is_empty() {
292            BloomPruner::prune(surviving_files, &h.query_text, &bloom_map)
293        } else {
294            surviving_files
295        }
296    } else {
297        surviving_files
298    };
299
300    // Phase H: load equality delete filter for this snapshot.
301    // Reads delete manifests from the catalog and downloads each equality delete Avro file.
302    // Empty filter is a no-op. On error: warn and continue with empty filter (data visible).
303    let eq_del_filter = match catalog.list_equality_deletes(table, None).await {
304        Ok(edfs) if !edfs.is_empty() => {
305            match EqualityDeleteFilter::from_files(&store, &edfs).await {
306                Ok(f) => f,
307                Err(e) => {
308                    warn!("ailake: equality delete filter build failed: {e} — rows may appear");
309                    EqualityDeleteFilter::empty()
310                }
311            }
312        }
313        _ => EqualityDeleteFilter::empty(),
314    };
315
316    // Compute candidate pool: hybrid needs a larger pool for BM25 re-ranking.
317    let candidate_k = match (&config.hybrid, config.rerank_factor) {
318        (Some(h), rf) => {
319            let pool = h.candidate_pool.unwrap_or(config.top_k * 10);
320            pool.max(rf.map_or(config.top_k, |f| f * config.top_k))
321        }
322        (None, Some(factor)) => config.top_k * factor,
323        (None, None) => config.top_k,
324    };
325
326    let use_hybrid = config.hybrid.is_some();
327
328    // Load BM25 stats from the table's stats file when hybrid search is active.
329    let bm25_stats: Option<crate::bm25::IdfStats> = if let Some(ref h) = config.hybrid {
330        if h.text_columns.is_empty() {
331            None
332        } else {
333            let stats_path = table_meta
334                .properties
335                .get(crate::bm25::BM25_STATS_PATH_PROP)
336                .map(String::as_str)
337                .unwrap_or(crate::bm25::BM25_STATS_FILE);
338            match store.get(stats_path).await {
339                Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).ok(),
340                Err(_) => {
341                    debug!(
342                        "ailake: BM25 stats not found at '{}' — falling back to empty corpus IDF",
343                        stats_path
344                    );
345                    None
346                }
347            }
348        }
349    } else {
350        None
351    };
352
353    // raw_candidates: (row_id, vec_dist, file_path, bm25_text) for hybrid re-ranking.
354    // Only populated when use_hybrid = true; otherwise all_results is populated directly.
355    let mut raw_candidates: Vec<(RowId, f32, String, String)> = Vec::new();
356    let mut all_results: Vec<SearchResult> = Vec::new();
357
358    // Observability for the flat-scan fallback below: `deferred` counts files still
359    // being indexed by our own deferred-write path (expected, transient). `unexpected`
360    // counts files with no AI-Lake index that are NOT in that state — most likely
361    // rewritten by a generic Iceberg engine (Spark/Trino OPTIMIZE, DuckDB) with no
362    // knowledge of AI-Lake. Those files still return correct results (flat scan is
363    // exact), just O(N) instead of O(log N), and silently forever unless recompacted.
364    let mut flat_scan_deferred = 0usize;
365    let mut flat_scan_unexpected = 0usize;
366
367    // Fetch + search each surviving file concurrently instead of one at a time —
368    // the dominant cost per file is a network round-trip (`store.get`), so this
369    // overlaps their latencies instead of serializing them. `try_join_all` runs
370    // all of them concurrently on the current task (no OS-thread parallelism,
371    // no `tokio::spawn`); at the post-pruning scale this operates on (dozens of
372    // files, see `VectorPruner` — geometric pruning is designed to cut a
373    // 10k-file table down to ~50-100 survivors before this point), that's the
374    // right trade-off: no bound needed, and no per-task spawn overhead.
375    let outcomes: Vec<FileSearchOutcome> = try_join_all(surviving_files.iter().map(|file_entry| {
376        search_one_file(
377            file_entry,
378            query,
379            candidate_k,
380            metric,
381            &table_meta,
382            &config,
383            vector_column,
384            dim,
385            &store,
386            &eq_del_filter,
387            use_hybrid,
388        )
389    }))
390    .await?;
391
392    for outcome in outcomes {
393        match outcome.flat_scan {
394            Some(FlatScanKind::Deferred) => flat_scan_deferred += 1,
395            Some(FlatScanKind::Unexpected) => flat_scan_unexpected += 1,
396            None => {}
397        }
398        all_results.extend(outcome.results);
399        raw_candidates.extend(outcome.candidates);
400    }
401
402    if flat_scan_unexpected > 0 {
403        warn!(
404            "ailake: search degraded — {}/{} files scanned without an AI-Lake index \
405             (unexpected — likely external rewrites; {} more in expected deferred-indexing \
406             state). Run compaction to restore O(log N) search on affected files",
407            flat_scan_unexpected,
408            surviving_files.len(),
409            flat_scan_deferred
410        );
411    } else if flat_scan_deferred > 0 {
412        debug!(
413            "ailake: search — {}/{} files scanned via flat fallback (deferred indexing)",
414            flat_scan_deferred,
415            surviving_files.len()
416        );
417    }
418
419    // Hybrid BM25 fusion: applied after all HNSW candidates are collected.
420    if let Some(ref h) = config.hybrid {
421        let empty_stats = crate::bm25::IdfStats::default();
422        let stats = bm25_stats.as_ref().unwrap_or(&empty_stats);
423        let scorer = crate::bm25::BM25Scorer::new(stats);
424
425        // Compute BM25 scores before sorting so they stay positionally aligned.
426        let bm25_scores_pre: Vec<f32> = raw_candidates
427            .iter()
428            .map(|(_, _, _, text)| scorer.score(&h.query_text, text))
429            .collect();
430
431        // Zip BM25 scores into candidates so they sort together — avoids index mismatch
432        // when raw_candidates is reordered by distance below.
433        let mut candidates_with_bm25: Vec<((RowId, f32, String, String), f32)> =
434            raw_candidates.into_iter().zip(bm25_scores_pre).collect();
435        candidates_with_bm25.sort_by(|a, b| a.0 .1.total_cmp(&b.0 .1));
436        let n = candidates_with_bm25.len();
437
438        let vec_ranks: Vec<usize> = (0..n).collect();
439
440        // Rank by BM25 score descending using post-sort aligned scores.
441        let mut bm25_indexed: Vec<(usize, f32)> = candidates_with_bm25
442            .iter()
443            .map(|(_, b)| *b)
444            .enumerate()
445            .collect();
446        bm25_indexed.sort_by(|a, b| b.1.total_cmp(&a.1));
447        let mut bm25_rank_of = vec![0usize; n];
448        for (rank, (idx, _)) in bm25_indexed.iter().enumerate() {
449            bm25_rank_of[*idx] = rank;
450        }
451
452        use crate::bm25::{linear_score, rrf_score, HybridFusion};
453
454        let fused: Vec<f32> = match h.fusion {
455            HybridFusion::Rrf => vec_ranks
456                .iter()
457                .enumerate()
458                .map(|(i, &vr)| rrf_score(vr, bm25_rank_of[i], h.bm25_weight))
459                .collect(),
460            HybridFusion::Linear => {
461                let min_d = candidates_with_bm25
462                    .iter()
463                    .map(|(r, _)| r.1)
464                    .fold(f32::INFINITY, f32::min);
465                let max_d = candidates_with_bm25
466                    .iter()
467                    .map(|(r, _)| r.1)
468                    .fold(f32::NEG_INFINITY, f32::max);
469                let min_b = candidates_with_bm25
470                    .iter()
471                    .map(|(_, b)| *b)
472                    .fold(f32::INFINITY, f32::min);
473                let max_b = candidates_with_bm25
474                    .iter()
475                    .map(|(_, b)| *b)
476                    .fold(f32::NEG_INFINITY, f32::max);
477                candidates_with_bm25
478                    .iter()
479                    .map(|(r, b)| linear_score(r.1, min_d, max_d, *b, min_b, max_b, h.bm25_weight))
480                    .collect()
481            }
482        };
483
484        for (i, ((row_id, _, file_path, _), _)) in candidates_with_bm25.into_iter().enumerate() {
485            all_results.push(SearchResult {
486                row_id,
487                distance: fused[i],
488                file_path,
489            });
490        }
491
492        // For RRF: lower (more negative) = better; for Linear: lower = better. Same convention.
493        all_results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
494    } else {
495        all_results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
496    }
497
498    all_results.truncate(config.top_k);
499    Ok(all_results)
500}
501
502/// Why this file fell back to a flat (exact, O(N)) scan instead of HNSW —
503/// carried out of `search_one_file` so the caller can aggregate counts across
504/// all concurrently-searched files for the post-loop `warn!`/`debug!` summary.
505enum FlatScanKind {
506    /// Background index build still in progress (deferred write) — expected, transient.
507    Deferred,
508    /// No AI-Lake index and NOT in the deferred-indexing state — most likely an
509    /// external engine (Spark/Trino OPTIMIZE, DuckDB) rewrote the file, or an
510    /// internal inconsistency. Results are still correct, just degraded.
511    Unexpected,
512}
513
514/// Per-file result of `search_one_file`, folded into `search()`'s accumulators
515/// after all files have been searched concurrently.
516#[derive(Default)]
517struct FileSearchOutcome {
518    /// Populated when hybrid search is off.
519    results: Vec<SearchResult>,
520    /// Populated when hybrid search is on: (row_id, vector distance, file path, text).
521    candidates: Vec<(RowId, f32, String, String)>,
522    flat_scan: Option<FlatScanKind>,
523}
524
525/// Fetches, index-searches (or flat-scans), and filters a single file — the
526/// per-file body of `search()`'s main loop, extracted so it can be run
527/// concurrently across files via `try_join_all`. Pure with respect to the
528/// caller's accumulators: everything it would have mutated in place (result
529/// lists, flat-scan counters) comes back in the returned `FileSearchOutcome`
530/// instead, so concurrent invocations never share mutable state.
531#[allow(clippy::too_many_arguments)]
532async fn search_one_file(
533    file_entry: &DataFileEntry,
534    query: &[f32],
535    candidate_k: usize,
536    metric: VectorMetric,
537    table_meta: &TableMetadata,
538    config: &SearchConfig,
539    vector_column: &str,
540    dim: u32,
541    store: &Arc<dyn Store>,
542    eq_del_filter: &EqualityDeleteFilter,
543    use_hybrid: bool,
544) -> AilakeResult<FileSearchOutcome> {
545    let mut outcome = FileSearchOutcome::default();
546
547    // V3 Deletion Vector: fetch bitmap once per file (range GET from Puffin .dvd).
548    // Independent of the file's own bytes — fetched first so both the range-GET
549    // fast path below and the full-file fallback path can use it.
550    // None for V2 tables or V3 files with no deletes. On fetch error: warn + continue
551    // without mask (surfacing deleted rows is safer than hard-failing the search).
552    let dv_bitmap: Option<roaring::RoaringBitmap> = if let Some(ref dv) = file_entry.deletion_vector
553    {
554        match crate::dv::load_deletion_vector(store, dv).await {
555            Ok(bm) => {
556                debug!(
557                    "ailake: DV loaded ({} deletions) for {}",
558                    bm.len(),
559                    file_entry.path
560                );
561                Some(bm)
562            }
563            Err(e) => {
564                warn!(
565                    "ailake: DV fetch failed for '{}': {e} — deleted rows may appear",
566                    file_entry.path
567                );
568                None
569            }
570        }
571    } else {
572        None
573    };
574
575    // Range-GET fast path (Fase 16): when this query needs nothing from the
576    // file besides the index itself, skip the whole-file GET and range-GET
577    // just the HNSW/IVF-PQ blob (typically 10-20% of the file's total size —
578    // see CLAUDE.md §6). Every condition here is knowable from the catalog
579    // manifest / query config alone, with no file bytes fetched yet. Limited
580    // to the primary vector column and `IndexStatus::Ready` — see
581    // `index_loader`'s module doc for why secondary columns can't use it, and
582    // `crate::index_loader::load_primary_index`'s contract: any failure there
583    // is safe to treat as "use the full-file path", never a hard error.
584    let primary_col = table_meta
585        .properties
586        .get("ailake.vector-column")
587        .map(String::as_str)
588        .unwrap_or("");
589    let fast_path_eligible = config.column_filter.is_none()
590        && config.rerank_factor.is_none()
591        && config.score_fn.is_none()
592        && !use_hybrid
593        && eq_del_filter.is_empty()
594        && file_entry.index_status == IndexStatus::Ready
595        && !file_entry.is_foreign()
596        && vector_column == primary_col;
597
598    if fast_path_eligible {
599        match crate::index_loader::load_primary_index(store, &file_entry.path).await {
600            Ok(index) => {
601                let local_results = index.search(query, candidate_k, config.ef_search);
602                for (row_id, approx_dist) in local_results {
603                    if dv_bitmap
604                        .as_ref()
605                        .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
606                    {
607                        continue;
608                    }
609                    outcome.results.push(SearchResult {
610                        row_id,
611                        distance: approx_dist,
612                        file_path: file_entry.path.clone(),
613                    });
614                }
615                return Ok(outcome);
616            }
617            Err(e) => {
618                debug!(
619                    "ailake: range-GET fast path failed for {} ({e}) — falling back to full-file GET",
620                    file_entry.path
621                );
622            }
623        }
624    }
625
626    let file_bytes: Bytes = store.get(&file_entry.path).await?;
627    let reader = AilakeFileReader::new(file_bytes, vector_column, dim);
628
629    // Predicate pushdown: resolve which original row positions in this file
630    // satisfy `config.column_filter`, without disturbing row identity (see
631    // `AilakeFileReader::matching_row_ids`). When the set comes back empty,
632    // no row in the file can pass — skip HNSW search and any Parquet
633    // decode for it entirely (the real perf win: files that don't contain
634    // the filtered value are never scanned at all, not just post-filtered).
635    let matching_row_ids: Option<std::collections::HashSet<u64>> =
636        match config.column_filter.as_ref() {
637            Some(filter) => {
638                let matching = reader.matching_row_ids(filter)?;
639                if matching.is_empty() {
640                    return Ok(outcome);
641                }
642                Some(matching)
643            }
644            None => None,
645        };
646
647    // Parquet read required for: flat scan fallback, exact reranking, score_fn, hybrid,
648    // or when equality delete filter must check column values per-row.
649    let need_parquet = file_entry.index_status == IndexStatus::Indexing
650        || !reader.is_ailake_file()
651        || config.rerank_factor.is_some()
652        || config.score_fn.is_some()
653        || use_hybrid
654        || !eq_del_filter.is_empty();
655
656    if file_entry.index_status == IndexStatus::Indexing || !reader.is_ailake_file() {
657        match file_entry.index_status {
658            IndexStatus::Indexing => {
659                outcome.flat_scan = Some(FlatScanKind::Deferred);
660                debug!(
661                    "ailake: flat scan fallback for {} — index build in progress \
662                     (deferred write, expected to resolve once background job completes)",
663                    file_entry.path
664                );
665            }
666            IndexStatus::Failed => {
667                // An internal indexing failure, not a foreign write — the file still
668                // has a real (foreign-write-style) centroid_b64 from make_data_file_entry
669                // *_indexing, since only index_status/index_error get patched on failure
670                // (see writer.rs::patch_index_failed). Attributing this to an external
671                // engine would send on-call looking for a Spark job that never ran.
672                outcome.flat_scan = Some(FlatScanKind::Unexpected);
673                warn!(
674                    "ailake: flat scan fallback for {} — background index build failed \
675                     permanently ({}); serving via flat scan until the next compaction \
676                     rebuilds the index",
677                    file_entry.path,
678                    file_entry
679                        .index_error
680                        .as_deref()
681                        .unwrap_or("no error recorded")
682                );
683            }
684            IndexStatus::Ready => {
685                // Ready but no loadable index/footer: either a genuine foreign write
686                // (no centroid_b64 — see CompactionPlanner::plan's detection) or a rare
687                // internally-inconsistent state (Ready without ever getting an index).
688                outcome.flat_scan = Some(FlatScanKind::Unexpected);
689                if file_entry.is_foreign() {
690                    warn!(
691                        "ailake: flat scan fallback for {} — file has no AI-Lake index \
692                         and no centroid; likely rewritten by a generic Iceberg engine \
693                         (OPTIMIZE / rewrite_data_files) with no knowledge of AI-Lake. \
694                         Results are still correct (exact O(N) scan), but degraded until \
695                         this file is recompacted by the AI-Lake SDK",
696                        file_entry.path
697                    );
698                } else {
699                    warn!(
700                        "ailake: flat scan fallback for {} — marked Ready but has no \
701                         loadable AI-Lake index despite a recorded centroid; internal \
702                         inconsistency, not an external rewrite. Run compaction to rebuild",
703                        file_entry.path
704                    );
705                }
706            }
707        }
708        let (raw_batch, raw_vectors) = reader.read_parquet()?;
709        // Phase G: inject columns added via schema evolution with initial_default values.
710        let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
711        for (row_id, distance) in flat_search(&raw_vectors, query, candidate_k, metric) {
712            // Skip rows marked as deleted by a V3 Deletion Vector.
713            if dv_bitmap
714                .as_ref()
715                .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
716            {
717                continue;
718            }
719            // Phase H: skip rows matched by an equality delete predicate.
720            if eq_del_filter.should_delete_row(&batch, row_id.as_u64() as usize) {
721                continue;
722            }
723            // Predicate pushdown: row survived the file-level check above,
724            // but only rows in the matching set itself pass the filter.
725            if matching_row_ids
726                .as_ref()
727                .is_some_and(|ids| !ids.contains(&row_id.as_u64()))
728            {
729                continue;
730            }
731            if use_hybrid {
732                let text = extract_text_for_row(
733                    &batch,
734                    row_id.as_u64() as usize,
735                    config.hybrid.as_ref().unwrap(),
736                );
737                outcome
738                    .candidates
739                    .push((row_id, distance, file_entry.path.clone(), text));
740            } else {
741                let final_score = apply_score_fn(&config.score_fn, distance, row_id, &batch);
742                outcome.results.push(SearchResult {
743                    row_id,
744                    distance: final_score,
745                    file_path: file_entry.path.clone(),
746                });
747            }
748        }
749        return Ok(outcome);
750    }
751
752    let index = reader.load_any_index_for_column(vector_column)?;
753    let local_results = index.search(query, candidate_k, config.ef_search);
754
755    let parquet_data = if need_parquet {
756        let (raw_batch, raw_vecs) = reader.read_parquet()?;
757        // Phase G: fill missing columns for old files before score_fn / hybrid BM25.
758        let filled = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
759        Some((filled, raw_vecs))
760    } else {
761        None
762    };
763
764    for (row_id, approx_dist) in local_results {
765        // Skip rows marked as deleted by a V3 Deletion Vector.
766        if dv_bitmap
767            .as_ref()
768            .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
769        {
770            continue;
771        }
772        let idx = row_id.as_u64() as usize;
773        // Phase H: skip rows matched by an equality delete predicate.
774        // parquet_data is always loaded when eq_del_filter is non-empty (see need_parquet).
775        if let Some((ref batch, _)) = parquet_data {
776            if eq_del_filter.should_delete_row(batch, idx) {
777                continue;
778            }
779        }
780        // Predicate pushdown: row survived the file-level check above,
781        // but only rows in the matching set itself pass the filter.
782        if matching_row_ids
783            .as_ref()
784            .is_some_and(|ids| !ids.contains(&row_id.as_u64()))
785        {
786            continue;
787        }
788
789        let distance = if config.rerank_factor.is_some() {
790            match parquet_data.as_ref().and_then(|(_, vecs)| vecs.get(idx)) {
791                Some(v) => exact_distance(metric, query, v),
792                None => {
793                    error!(
794                        "ailake: invariant violated — row_id {} out of bounds \
795                         (file={}); Parquet and HNSW node count out of sync; \
796                         run compaction to rebuild",
797                        idx, file_entry.path
798                    );
799                    f32::INFINITY
800                }
801            }
802        } else {
803            approx_dist
804        };
805
806        if use_hybrid {
807            let text = parquet_data.as_ref().map_or(String::new(), |(batch, _)| {
808                extract_text_for_row(batch, idx, config.hybrid.as_ref().unwrap())
809            });
810            outcome
811                .candidates
812                .push((row_id, distance, file_entry.path.clone(), text));
813        } else {
814            let final_score = if let Some((ref batch, _)) = parquet_data {
815                apply_score_fn(&config.score_fn, distance, row_id, batch)
816            } else {
817                distance
818            };
819            outcome.results.push(SearchResult {
820                row_id,
821                distance: final_score,
822                file_path: file_entry.path.clone(),
823            });
824        }
825    }
826
827    Ok(outcome)
828}
829
830/// Extract concatenated text from specified columns for a single row.
831fn extract_text_for_row(
832    batch: &RecordBatch,
833    row_idx: usize,
834    hybrid: &crate::bm25::HybridConfig,
835) -> String {
836    use arrow_array::cast::AsArray;
837    hybrid
838        .text_columns
839        .iter()
840        .filter_map(|col| {
841            batch.column_by_name(col).and_then(|arr| {
842                arr.as_string_opt::<i32>().and_then(|sa| {
843                    if row_idx < sa.len() && sa.is_valid(row_idx) {
844                        Some(sa.value(row_idx).to_string())
845                    } else {
846                        None
847                    }
848                })
849            })
850        })
851        .collect::<Vec<_>>()
852        .join(" ")
853}
854
855/// One query arm in a cross-modal search.
856#[derive(Debug, Clone)]
857pub struct ModalQuery<'a> {
858    /// Vector column to search (must exist in the table).
859    pub column: &'a str,
860    /// Query vector for this modality.
861    pub query: &'a [f32],
862    /// Relative weight applied in the RRF formula: `weight / (k + rank)`.
863    /// `1.0` means equal weight across all modalities.
864    pub weight: f32,
865    /// Dimensionality of this column's vectors. `0` = auto-detect from table metadata
866    /// (`ailake.dim-<column>` for secondary columns, `ailake.vector-dim` for primary).
867    pub dim: u32,
868}
869
870/// Fusion method for combining results from multiple vector columns.
871#[derive(Debug, Clone, Copy, PartialEq, Eq)]
872pub enum FusionMethod {
873    /// Reciprocal Rank Fusion: `score(d) = Σ weight_i / (k + rank_i(d))`.
874    /// `k = 60` (standard). Returned `SearchResult.distance` = `-rrf_score`
875    /// so that sort-ascending-by-distance gives the correct RRF ranking.
876    Rrf,
877}
878
879/// Cross-modal search: run independent HNSW searches across N vector columns,
880/// then fuse per-column ranked lists using Reciprocal Rank Fusion.
881///
882/// Each `ModalQuery` specifies a column name, its query vector, RRF weight, and dim.
883/// When `ModalQuery.dim == 0`, the dim is auto-detected from `ailake.dim-<col>` /
884/// `ailake.vector-dim` in table metadata.
885/// Results are de-duplicated by `(file_path, row_id)` and ranked by aggregate
886/// RRF score. `SearchResult.distance` stores `-rrf_score` (lower = better) so
887/// existing sort-ascending callers get the correct ordering.
888pub async fn search_multimodal(
889    table: &TableIdent,
890    queries: &[ModalQuery<'_>],
891    config: SearchConfig,
892    catalog: Arc<dyn CatalogProvider>,
893    store: Arc<dyn Store>,
894    fusion: FusionMethod,
895) -> AilakeResult<Vec<SearchResult>> {
896    use std::collections::HashMap;
897
898    if queries.is_empty() {
899        return Err(AilakeError::InvalidArgument(
900            "search_multimodal requires at least one ModalQuery".into(),
901        ));
902    }
903    if config.top_k > ailake_core::MAX_TOP_K {
904        return Err(AilakeError::InvalidArgument(format!(
905            "top_k {} exceeds maximum supported value ({})",
906            config.top_k,
907            ailake_core::MAX_TOP_K
908        )));
909    }
910
911    // Load table metadata once for dim auto-detection and metric resolution.
912    let table_meta = catalog.load_table(table).await?;
913    let primary_col = table_meta
914        .properties
915        .get("ailake.vector-column")
916        .cloned()
917        .unwrap_or_default();
918    let primary_dim: u32 = table_meta
919        .properties
920        .get("ailake.vector-dim")
921        .and_then(|s| s.parse().ok())
922        .unwrap_or(0);
923
924    // Fetch more candidates per column so RRF has enough to fuse.
925    let per_col_k = (config.top_k * queries.len().max(2)).min(1000);
926
927    let mut per_col_results: Vec<(f32, Vec<SearchResult>)> = Vec::with_capacity(queries.len());
928    for mq in queries {
929        // Resolve dim: caller-supplied > per-column property > primary column dim.
930        let resolved_dim = if mq.dim > 0 {
931            mq.dim
932        } else if mq.column == primary_col {
933            primary_dim
934        } else {
935            table_meta
936                .properties
937                .get(&format!("ailake.dim-{}", mq.column))
938                .and_then(|s| s.parse().ok())
939                .unwrap_or(mq.query.len() as u32)
940        };
941
942        let col_config = SearchConfig {
943            top_k: per_col_k,
944            ef_search: config.ef_search,
945            pruning_threshold: config.pruning_threshold,
946            rerank_factor: config.rerank_factor,
947            score_fn: None,
948            partition_filter: config.partition_filter.clone(),
949            hybrid: None,
950            column_filter: config.column_filter.clone(),
951        };
952        let results = search(
953            table,
954            mq.query,
955            col_config,
956            mq.column,
957            resolved_dim,
958            catalog.clone(),
959            store.clone(),
960        )
961        .await?;
962        per_col_results.push((mq.weight, results));
963    }
964
965    // RRF fusion: accumulate score per (file_path, row_id).
966    const K: f32 = 60.0;
967    let mut scores: HashMap<(String, u64), f32> = HashMap::new();
968
969    for (weight, results) in &per_col_results {
970        for (rank, r) in results.iter().enumerate() {
971            let key = (r.file_path.clone(), r.row_id.as_u64());
972            let rrf = weight / (K + rank as f32 + 1.0);
973            *scores.entry(key).or_insert(0.0) += rrf;
974        }
975    }
976
977    // Build SearchResult list sorted by descending RRF score.
978    // Store `-rrf_score` as `.distance` so callers sorting ascending get correct order.
979    let all_files = catalog.list_files(table, None).await?;
980    let _ = all_files; // centroid not needed for fusion — just need file_path+row_id
981
982    // Collect unique candidates: prefer the row's appearance in the first column's results.
983    let mut seen: HashMap<(String, u64), f32> = HashMap::new();
984    for (_, results) in &per_col_results {
985        for r in results {
986            let key = (r.file_path.clone(), r.row_id.as_u64());
987            let rrf_score = *scores.get(&key).unwrap_or(&0.0);
988            seen.entry(key).or_insert(rrf_score);
989        }
990    }
991
992    let mut fused: Vec<SearchResult> = seen
993        .into_iter()
994        .map(|((file_path, row_id_u64), rrf_score)| SearchResult {
995            row_id: RowId::new(row_id_u64),
996            distance: -rrf_score,
997            file_path,
998        })
999        .collect();
1000
1001    fused.sort_by(|a, b| {
1002        a.distance
1003            .partial_cmp(&b.distance)
1004            .unwrap_or(std::cmp::Ordering::Equal)
1005    });
1006    fused.truncate(config.top_k);
1007
1008    let _ = fusion; // only RRF implemented; enum is extensible
1009
1010    Ok(fused)
1011}
1012
1013/// Apply `score_fn` to a single result row, or return `distance` unchanged.
1014///
1015/// Slices the batch to a 1-row RecordBatch at `row_id` and calls the fn.
1016/// If `score_fn` is `None` or the row index is out of bounds, returns `distance`.
1017#[inline]
1018fn apply_score_fn(
1019    score_fn: &Option<ScoreFn>,
1020    distance: f32,
1021    row_id: RowId,
1022    batch: &RecordBatch,
1023) -> f32 {
1024    match score_fn {
1025        None => distance,
1026        Some(f) => {
1027            let idx = row_id.as_u64() as usize;
1028            if idx < batch.num_rows() {
1029                f.call(distance, &batch.slice(idx, 1))
1030            } else {
1031                distance
1032            }
1033        }
1034    }
1035}
1036
1037/// Brute-force top-k search over raw vectors. Used for Indexing shards.
1038fn flat_search(
1039    raw: &[Vec<f32>],
1040    query: &[f32],
1041    top_k: usize,
1042    metric: VectorMetric,
1043) -> Vec<(RowId, f32)> {
1044    let mut results: Vec<(RowId, f32)> = raw
1045        .iter()
1046        .enumerate()
1047        .map(|(i, v)| (RowId::new(i as u64), exact_distance(metric, query, v)))
1048        .collect();
1049    results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1050    results.truncate(top_k);
1051    results
1052}
1053
1054fn parse_metric(s: &str) -> VectorMetric {
1055    match s {
1056        "euclidean" => VectorMetric::Euclidean,
1057        "dotproduct" | "dot_product" | "dot" => VectorMetric::DotProduct,
1058        "normalized_cosine" | "normalizedcosine" => VectorMetric::NormalizedCosine,
1059        _ => VectorMetric::Cosine,
1060    }
1061}
1062
1063/// Pre-loaded search session: all HNSW indexes loaded into memory once.
1064///
1065/// Useful for benchmarks and servers that issue many queries against the same
1066/// snapshot. Avoids re-loading and re-deserializing indexes on every call.
1067///
1068/// **Deleted rows are NOT filtered here**: unlike [`search`]/[`search_text`],
1069/// this session does not load deletion vectors or equality delete files —
1070/// rows removed via `delete_rows`/`delete_where` still appear in results.
1071/// Use [`search`] when delete visibility matters; this type trades that for
1072/// raw throughput on static snapshots (its benchmark use case).
1073pub struct SearchSession {
1074    shards: Vec<LoadedShard>,
1075    metric: VectorMetric,
1076}
1077
1078struct LoadedShard {
1079    entry: DataFileEntry,
1080    /// None when the shard is still being indexed (IndexStatus::Indexing).
1081    index: Option<AnyIndex>,
1082    /// Raw F32 vectors: always present for Indexing shards (flat scan), optionally
1083    /// present for Ready shards when `load_raw = true` (reranking).
1084    raw_vectors: Option<Vec<Vec<f32>>>,
1085}
1086
1087impl SearchSession {
1088    /// Load all indexes for the latest snapshot into memory.
1089    ///
1090    /// Pass `load_raw = true` when reranking will be used (`rerank_factor` is
1091    /// `Some`); it reads the full parquet columns so exact distances are
1092    /// available without extra I/O during `search_query`.
1093    pub async fn load(
1094        table: &TableIdent,
1095        vector_column: &str,
1096        dim: u32,
1097        catalog: Arc<dyn CatalogProvider>,
1098        store: Arc<dyn Store>,
1099        load_raw: bool,
1100    ) -> AilakeResult<Self> {
1101        let all_files = catalog.list_files(table, None).await?;
1102        let table_meta = catalog.load_table(table).await?;
1103        let metric = parse_metric(
1104            table_meta
1105                .properties
1106                .get("ailake.vector-metric")
1107                .map(String::as_str)
1108                .unwrap_or("cosine"),
1109        );
1110
1111        let mut shards = Vec::with_capacity(all_files.len());
1112        for entry in all_files {
1113            let file_bytes: Bytes = store.get(&entry.path).await?;
1114            let reader = AilakeFileReader::new(file_bytes, vector_column, dim);
1115
1116            if entry.index_status == IndexStatus::Indexing {
1117                // HNSW not yet built — load raw vectors for flat scan.
1118                let (_, raw_vecs) = reader.read_parquet()?;
1119                shards.push(LoadedShard {
1120                    entry,
1121                    index: None,
1122                    raw_vectors: Some(raw_vecs),
1123                });
1124            } else if reader.is_ailake_file() {
1125                let mut index = reader.load_any_index_for_column(vector_column)?;
1126                let raw_vectors = if load_raw {
1127                    index.quantize_to_f16();
1128                    let (_, vecs) = reader.read_parquet()?;
1129                    Some(vecs)
1130                } else {
1131                    None
1132                };
1133                shards.push(LoadedShard {
1134                    entry,
1135                    index: Some(index),
1136                    raw_vectors,
1137                });
1138            }
1139        }
1140
1141        Ok(Self { shards, metric })
1142    }
1143
1144    /// Number of loaded shards.
1145    pub fn shard_count(&self) -> usize {
1146        self.shards.len()
1147    }
1148
1149    /// Search multiple queries in one call.
1150    ///
1151    /// For shards with raw vectors (Indexing or reranking): dispatches to GPU batch
1152    /// matmul when a CUDA device is available, falling back to CPU flat scan.
1153    /// For indexed shards (HNSW / IVF-PQ): rayon parallel-map over queries — graph
1154    /// traversal is inherently sequential and has no GPU batch path.
1155    ///
1156    /// Returns one `Vec<SearchResult>` per input query, in the same order.
1157    pub fn search_batch(
1158        &self,
1159        queries: &[Vec<f32>],
1160        config: &SearchConfig,
1161    ) -> Vec<Vec<SearchResult>> {
1162        if queries.is_empty() {
1163            return vec![];
1164        }
1165
1166        let n_queries = queries.len();
1167        let candidate_k = match config.rerank_factor {
1168            Some(factor) => config.top_k * factor,
1169            None => config.top_k,
1170        };
1171        let use_nvidia = ailake_index::hardware::detect_cuda();
1172        let use_amd = ailake_index::hardware::detect_rocm();
1173
1174        // Accumulate per-query results across all shards.
1175        let mut all_results: Vec<Vec<SearchResult>> = (0..n_queries).map(|_| Vec::new()).collect();
1176
1177        for shard in &self.shards {
1178            if let Some(raw) = &shard.raw_vectors {
1179                // Flat-scan shard — try GPU batch path (NVIDIA first, then AMD ROCm).
1180                if !raw.is_empty() {
1181                    let dim = raw[0].len();
1182                    let flat: Vec<f32> = raw.iter().flat_map(|v| v.iter().copied()).collect();
1183                    let row_ids: Vec<u64> = (0..raw.len() as u64).collect();
1184                    let q_refs: Vec<&[f32]> = queries.iter().map(|q| q.as_slice()).collect();
1185
1186                    let gpu_batch = if use_nvidia {
1187                        ailake_index::gpu::try_nvidia_search_batch(
1188                            &q_refs,
1189                            &row_ids,
1190                            &flat,
1191                            dim,
1192                            self.metric,
1193                            candidate_k,
1194                        )
1195                    } else if use_amd {
1196                        ailake_index::gpu::try_rocm_search_batch(
1197                            &q_refs,
1198                            &row_ids,
1199                            &flat,
1200                            dim,
1201                            self.metric,
1202                            candidate_k,
1203                        )
1204                    } else {
1205                        None
1206                    };
1207
1208                    if let Some(batch) = gpu_batch {
1209                        for (qi, results) in batch.into_iter().enumerate() {
1210                            for (row_id, distance) in results {
1211                                all_results[qi].push(SearchResult {
1212                                    row_id,
1213                                    distance,
1214                                    file_path: shard.entry.path.clone(),
1215                                });
1216                            }
1217                        }
1218                        continue;
1219                    }
1220                }
1221
1222                // CPU fallback for flat scan.
1223                for (qi, query) in queries.iter().enumerate() {
1224                    for (row_id, distance) in flat_search(raw, query, candidate_k, self.metric) {
1225                        all_results[qi].push(SearchResult {
1226                            row_id,
1227                            distance,
1228                            file_path: shard.entry.path.clone(),
1229                        });
1230                    }
1231                }
1232            } else if let Some(index) = &shard.index {
1233                // Indexed shard — rayon parallel-map over queries.
1234                let shard_results: Vec<Vec<SearchResult>> = queries
1235                    .par_iter()
1236                    .map(|query| {
1237                        index
1238                            .search(query, candidate_k, config.ef_search)
1239                            .into_iter()
1240                            .map(|(row_id, distance)| SearchResult {
1241                                row_id,
1242                                distance,
1243                                file_path: shard.entry.path.clone(),
1244                            })
1245                            .collect()
1246                    })
1247                    .collect();
1248
1249                for (qi, results) in shard_results.into_iter().enumerate() {
1250                    all_results[qi].extend(results);
1251                }
1252            }
1253        }
1254
1255        // Sort + truncate per query.
1256        for results in &mut all_results {
1257            results.sort_by(|a, b| {
1258                a.distance
1259                    .partial_cmp(&b.distance)
1260                    .unwrap_or(std::cmp::Ordering::Equal)
1261            });
1262            results.truncate(config.top_k);
1263        }
1264
1265        all_results
1266    }
1267
1268    /// Search using pre-loaded indexes. No I/O — pure in-memory search.
1269    pub fn search_query(&self, query: &[f32], config: &SearchConfig) -> Vec<SearchResult> {
1270        let candidate_k = match config.rerank_factor {
1271            Some(factor) => config.top_k * factor,
1272            None => config.top_k,
1273        };
1274
1275        let mut all_results: Vec<SearchResult> = self
1276            .shards
1277            .par_iter()
1278            .flat_map(|shard| {
1279                // Geometric pruning per shard.
1280                if let Some(centroid) = ailake_catalog::decode_centroid(&shard.entry, self.metric) {
1281                    let dist = match self.metric {
1282                        VectorMetric::Cosine | VectorMetric::NormalizedCosine => {
1283                            ailake_vec::cosine_distance(query, &centroid.values)
1284                        }
1285                        VectorMetric::Euclidean => {
1286                            ailake_vec::euclidean_distance(query, &centroid.values)
1287                        }
1288                        VectorMetric::DotProduct => {
1289                            -ailake_vec::dot_product(query, &centroid.values)
1290                        }
1291                    };
1292                    if dist - centroid.radius > config.pruning_threshold {
1293                        return vec![];
1294                    }
1295                }
1296
1297                if let Some(index) = &shard.index {
1298                    // Ready shard: HNSW or IVF-PQ search (dispatched by AnyIndex).
1299                    let local_results = index.search(query, candidate_k, config.ef_search);
1300                    if config.rerank_factor.is_some() {
1301                        if let Some(raw) = &shard.raw_vectors {
1302                            local_results
1303                                .into_iter()
1304                                .map(|(row_id, _approx_dist)| {
1305                                    let idx = row_id.as_u64() as usize;
1306                                    let exact_dist = raw
1307                                        .get(idx)
1308                                        .map(|v| exact_distance(self.metric, query, v))
1309                                        .unwrap_or(f32::INFINITY);
1310                                    SearchResult {
1311                                        row_id,
1312                                        distance: exact_dist,
1313                                        file_path: shard.entry.path.clone(),
1314                                    }
1315                                })
1316                                .collect()
1317                        } else {
1318                            local_results
1319                                .into_iter()
1320                                .map(|(row_id, distance)| SearchResult {
1321                                    row_id,
1322                                    distance,
1323                                    file_path: shard.entry.path.clone(),
1324                                })
1325                                .collect()
1326                        }
1327                    } else {
1328                        local_results
1329                            .into_iter()
1330                            .map(|(row_id, distance)| SearchResult {
1331                                row_id,
1332                                distance,
1333                                file_path: shard.entry.path.clone(),
1334                            })
1335                            .collect()
1336                    }
1337                } else if let Some(raw) = &shard.raw_vectors {
1338                    // Indexing shard: exact flat scan.
1339                    flat_search(raw, query, candidate_k, self.metric)
1340                        .into_iter()
1341                        .map(|(row_id, distance)| SearchResult {
1342                            row_id,
1343                            distance,
1344                            file_path: shard.entry.path.clone(),
1345                        })
1346                        .collect()
1347                } else {
1348                    vec![]
1349                }
1350            })
1351            .collect();
1352
1353        all_results.sort_by(|a, b| {
1354            a.distance
1355                .partial_cmp(&b.distance)
1356                .unwrap_or(std::cmp::Ordering::Equal)
1357        });
1358        all_results.truncate(config.top_k);
1359        all_results
1360    }
1361}
1362
1363/// Pure BM25 full-text search across all Parquet files in the table.
1364///
1365/// Scans every surviving file (O(N) complexity), scores each row with BM25 against
1366/// `query_text`, and returns the global top-k by score. IDF stats are loaded from
1367/// `metadata/ailake_bm25_stats.bin` (written by `TableWriter` when `bm25_text_column`
1368/// is configured). If the stats file is absent, IDF defaults to an empty corpus
1369/// (all terms treated as maximally rare — directionally correct but less precise).
1370///
1371/// For pure-lexical search at scale (millions of rows, hundreds of files), consider
1372/// using SQL `LIKE` / `ILIKE` via DuckDB/Trino over the Iceberg-compatible table.
1373/// This function is best suited for small-medium tables or as a lexical complement
1374/// to `search()` for tables where the document count per file is manageable.
1375pub async fn search_text(
1376    table: &TableIdent,
1377    query_text: &str,
1378    text_columns: &[&str],
1379    top_k: usize,
1380    catalog: Arc<dyn CatalogProvider>,
1381    store: Arc<dyn Store>,
1382    partition_filter: Option<&str>,
1383) -> AilakeResult<Vec<SearchResult>> {
1384    use arrow_array::cast::AsArray;
1385
1386    if text_columns.is_empty() {
1387        return Err(AilakeError::InvalidArgument(
1388            "search_text requires at least one text column".into(),
1389        ));
1390    }
1391    if top_k > ailake_core::MAX_TOP_K {
1392        return Err(AilakeError::InvalidArgument(format!(
1393            "top_k {top_k} exceeds maximum supported value ({})",
1394            ailake_core::MAX_TOP_K
1395        )));
1396    }
1397
1398    let all_files = catalog.list_files(table, None).await?;
1399    let table_meta = catalog.load_table(table).await?;
1400
1401    // Partition pruning
1402    let files: Vec<_> = if let Some(pv) = partition_filter {
1403        all_files
1404            .into_iter()
1405            .filter(|f| f.partition_value.as_deref() == Some(pv))
1406            .collect()
1407    } else {
1408        all_files
1409    };
1410
1411    // Load BM25 stats
1412    let stats_path = table_meta
1413        .properties
1414        .get(crate::bm25::BM25_STATS_PATH_PROP)
1415        .map(String::as_str)
1416        .unwrap_or(crate::bm25::BM25_STATS_FILE);
1417    let stats = match store.get(stats_path).await {
1418        Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).unwrap_or_default(),
1419        Err(_) => {
1420            debug!(
1421                "ailake: BM25 stats not found at '{}' — using empty corpus IDF",
1422                stats_path
1423            );
1424            crate::bm25::IdfStats::default()
1425        }
1426    };
1427    let scorer = crate::bm25::BM25Scorer::new(&stats);
1428
1429    // Phase H: equality delete filter for search_text results.
1430    let eq_del_filter = match catalog.list_equality_deletes(table, None).await {
1431        Ok(edfs) if !edfs.is_empty() => {
1432            match EqualityDeleteFilter::from_files(&store, &edfs).await {
1433                Ok(f) => f,
1434                Err(e) => {
1435                    warn!("ailake: equality delete filter build failed in search_text: {e}");
1436                    EqualityDeleteFilter::empty()
1437                }
1438            }
1439        }
1440        _ => EqualityDeleteFilter::empty(),
1441    };
1442
1443    let mut results: Vec<SearchResult> = Vec::new();
1444
1445    for file_entry in &files {
1446        let file_bytes = store.get(&file_entry.path).await?;
1447        // Use dim=0 — we only read the Parquet columns, not the HNSW.
1448        let reader = AilakeFileReader::new(file_bytes.clone(), "", 0);
1449
1450        // Fast path: per-file Tantivy index (O(log N) via inverted index).
1451        // Falls back to BM25 O(N) brute-force for files without an FTS section.
1452        if let Ok(Some(fts_blob)) = reader.load_fts_blob() {
1453            match ailake_fts::FtsSearcher::from_blob(&fts_blob) {
1454                Ok(fts) => {
1455                    let hits = fts.search(query_text, top_k * 3).unwrap_or_default();
1456                    if !hits.is_empty() {
1457                        // Load batch only for equality delete checking (not for scoring).
1458                        let reader2 = AilakeFileReader::new(file_bytes, "", 0);
1459                        let (raw_batch, _) = reader2.read_parquet()?;
1460                        let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
1461                        for hit in hits {
1462                            let row_idx = hit.row_id as usize;
1463                            if row_idx >= batch.num_rows() {
1464                                continue;
1465                            }
1466                            if eq_del_filter.should_delete_row(&batch, row_idx) {
1467                                continue;
1468                            }
1469                            results.push(SearchResult {
1470                                row_id: RowId::new(hit.row_id),
1471                                distance: -hit.score,
1472                                file_path: file_entry.path.clone(),
1473                            });
1474                        }
1475                    }
1476                    continue; // skip O(N) BM25 fallback
1477                }
1478                Err(e) => {
1479                    warn!("ailake: FTS blob corrupt for '{}': {e}", file_entry.path);
1480                    // fall through to BM25 brute-force
1481                }
1482            }
1483        }
1484
1485        // Fallback: O(N) BM25 brute-force — unchanged from pre-Phase-T behaviour.
1486        let reader_fb = AilakeFileReader::new(file_bytes, "", 0);
1487        let (raw_batch, _) = reader_fb.read_parquet()?;
1488        // Phase G: fill missing columns for old files before BM25 text extraction.
1489        let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
1490
1491        for row_idx in 0..batch.num_rows() {
1492            // Phase H: skip rows matched by equality delete predicate.
1493            if eq_del_filter.should_delete_row(&batch, row_idx) {
1494                continue;
1495            }
1496            let doc_text: String = text_columns
1497                .iter()
1498                .filter_map(|&col| {
1499                    batch.column_by_name(col).and_then(|arr| {
1500                        arr.as_string_opt::<i32>().and_then(|sa| {
1501                            if sa.is_valid(row_idx) {
1502                                Some(sa.value(row_idx).to_string())
1503                            } else {
1504                                None
1505                            }
1506                        })
1507                    })
1508                })
1509                .collect::<Vec<_>>()
1510                .join(" ");
1511
1512            if doc_text.is_empty() {
1513                continue;
1514            }
1515
1516            let bm25 = scorer.score(query_text, &doc_text);
1517            if bm25 > 0.0 {
1518                // Negate so that sort-ascending = best-first (lower distance = higher BM25).
1519                results.push(SearchResult {
1520                    row_id: RowId::new(row_idx as u64),
1521                    distance: -bm25,
1522                    file_path: file_entry.path.clone(),
1523                });
1524            }
1525        }
1526    }
1527
1528    results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
1529    results.truncate(top_k);
1530    Ok(results)
1531}
1532
1533/// Fetch full row data for a slice of search results.
1534///
1535/// Groups results by Parquet file, reads each file once, extracts the matching rows
1536/// via `arrow_select::take`, then concatenates everything back in original top-k order
1537/// with a `_distance: Float32` column appended.
1538///
1539/// Use this immediately after `search()` to retrieve the actual text / metadata
1540/// columns (e.g. `chunk_text`, `document_title`) alongside the distance scores.
1541pub async fn fetch_rows(
1542    results: &[SearchResult],
1543    store: Arc<dyn Store>,
1544    vector_column: &str,
1545    dim: u32,
1546    schema_fields: &[SchemaField],
1547) -> AilakeResult<RecordBatch> {
1548    use std::collections::HashMap;
1549
1550    use arrow_array::{ArrayRef, Float32Array, UInt32Array};
1551    use arrow_schema::{DataType, Field, Schema};
1552    use arrow_select::{concat::concat_batches, take::take};
1553
1554    if results.is_empty() {
1555        return Ok(RecordBatch::new_empty(Arc::new(Schema::empty())));
1556    }
1557
1558    // Group by file path; preserve original position for re-sorting.
1559    let mut by_file: HashMap<&str, Vec<(u64, f32, usize)>> = HashMap::new();
1560    for (i, r) in results.iter().enumerate() {
1561        by_file
1562            .entry(r.file_path.as_str())
1563            .or_default()
1564            .push((r.row_id.as_u64(), r.distance, i));
1565    }
1566
1567    use arrow_array::FixedSizeListArray;
1568
1569    // `vector_column` is deliberately excluded from the batch AilakeFileReader::read_parquet()
1570    // returns — it's decoded separately into `vectors: Vec<Vec<f32>>` and re-appended below as
1571    // a FixedSizeList<Float32> field. SchemaFiller has no way to know that; left unfiltered it
1572    // treats the vector column as "missing" (it genuinely isn't in the tabular batch) and
1573    // injects a synthetic column for it — wrong type (falls through iceberg_type_to_arrow's
1574    // Utf8 fallback for the vector column's Iceberg type string) and a duplicate field name
1575    // once the real decoded vector column is appended, breaking pandas' arrow->pandas
1576    // conversion (`Unsupported cast from fixed_size_list<...> to large_utf8`). Found writing
1577    // the regression test for the *other* schema-projection bug this function has — filtering
1578    // it out here is required for schema-filling to be correct at all in fetch_rows.
1579    let schema_fields_for_fill: Vec<SchemaField> = schema_fields
1580        .iter()
1581        .filter(|sf| sf.name != vector_column)
1582        .cloned()
1583        .collect();
1584
1585    // (original_index, distance, single-row RecordBatch, decoded F32 vector)
1586    let mut collected: Vec<(usize, f32, RecordBatch, Vec<f32>)> = Vec::with_capacity(results.len());
1587
1588    for (file_path, rows) in &by_file {
1589        let bytes = store.get(file_path).await?;
1590        let reader = AilakeFileReader::new(bytes, vector_column, dim);
1591        let (raw_batch, vectors) = reader.read_parquet()?;
1592        // Project against the table's *current* Iceberg schema — old files written
1593        // before a metadata-only evolve_schema/add_column don't physically have the
1594        // new column. Without this, a file that happens to land first in `collected`
1595        // silently drives `base_schema` below and the new column never appears in the
1596        // response at all (not even as null) — confirmed live via Spark's
1597        // AilakeNative.scan() and ailake.search(fetch_data=True), both of which call
1598        // this function. Same fix SchemaFiller::fill already applies on the
1599        // pointer-search path (search()); this was the one full-row-fetch path it
1600        // never reached.
1601        let batch = SchemaFiller::fill(raw_batch, &schema_fields_for_fill)?;
1602
1603        for &(row_id, distance, pos) in rows {
1604            let idx = row_id as usize;
1605            if idx >= batch.num_rows() {
1606                tracing::warn!(
1607                    "fetch_rows: row_id {} out of bounds (file_rows={}, file={}), skipping",
1608                    idx,
1609                    batch.num_rows(),
1610                    file_path
1611                );
1612                continue;
1613            }
1614
1615            let indices = UInt32Array::from(vec![idx as u32]);
1616            let row_cols: Vec<ArrayRef> = batch
1617                .columns()
1618                .iter()
1619                .map(|col| {
1620                    take(col.as_ref(), &indices, None)
1621                        .map_err(|e| AilakeError::Arrow(e.to_string()))
1622                })
1623                .collect::<AilakeResult<Vec<_>>>()?;
1624
1625            let row_batch = RecordBatch::try_new(batch.schema(), row_cols)
1626                .map_err(|e| AilakeError::Arrow(e.to_string()))?;
1627
1628            // Capture decoded F32 vector for this row (empty vec if not available).
1629            let vec = vectors
1630                .get(idx)
1631                .cloned()
1632                .unwrap_or_else(|| vec![0.0f32; dim as usize]);
1633
1634            collected.push((pos, distance, row_batch, vec));
1635        }
1636    }
1637
1638    if collected.is_empty() {
1639        return Ok(RecordBatch::new_empty(Arc::new(Schema::empty())));
1640    }
1641
1642    // Restore original top-k order from the search results slice.
1643    collected.sort_by_key(|(pos, _, _, _)| *pos);
1644
1645    let distances: Vec<f32> = collected.iter().map(|(_, d, _, _)| *d).collect();
1646    let row_batches: Vec<&RecordBatch> = collected.iter().map(|(_, _, b, _)| b).collect();
1647    let base_schema = collected[0].2.schema();
1648
1649    let combined =
1650        concat_batches(&base_schema, row_batches).map_err(|e| AilakeError::Arrow(e.to_string()))?;
1651
1652    // Build FixedSizeList<Float32> column with decoded vectors (F32, not raw F16 bytes).
1653    let flat_vecs: Vec<f32> = collected
1654        .iter()
1655        .flat_map(|(_, _, _, v)| v.iter().copied())
1656        .collect();
1657    let item_field = Arc::new(Field::new("item", DataType::Float32, false));
1658    let values_arr = Arc::new(Float32Array::from(flat_vecs)) as ArrayRef;
1659    let vec_col = FixedSizeListArray::new(item_field.clone(), dim as i32, values_arr, None);
1660    let vec_field = Arc::new(Field::new(
1661        vector_column,
1662        DataType::FixedSizeList(item_field, dim as i32),
1663        false,
1664    ));
1665
1666    // Schema: tabular cols, then decoded vector col, then _distance.
1667    let mut fields: Vec<Arc<Field>> = base_schema.fields().to_vec();
1668    fields.push(vec_field);
1669    fields.push(Arc::new(Field::new("_distance", DataType::Float32, false)));
1670    let new_schema = Arc::new(Schema::new(fields));
1671
1672    let mut columns: Vec<ArrayRef> = combined.columns().to_vec();
1673    columns.push(Arc::new(vec_col));
1674    columns.push(Arc::new(Float32Array::from(distances)));
1675
1676    RecordBatch::try_new(new_schema, columns).map_err(|e| AilakeError::Arrow(e.to_string()))
1677}
1678
1679/// Load per-file BM25 Bloom filters from the Puffin stats file for the current snapshot.
1680///
1681/// Returns a map of `file_path → BloomFilter`. Empty map = no stats file available
1682/// (V2 table, first write, or fetch failure). The scanner applies Bloom pruning only
1683/// when the map is non-empty.
1684async fn load_bloom_map(
1685    table_meta: &ailake_catalog::TableMetadata,
1686    store: &dyn Store,
1687) -> std::collections::HashMap<String, crate::bloom::BloomFilter> {
1688    let stats_path = match &table_meta.current_statistics_path {
1689        Some(p) => p.clone(),
1690        None => return std::collections::HashMap::new(),
1691    };
1692    let bytes = match store.get(&stats_path).await {
1693        Ok(b) => b,
1694        Err(e) => {
1695            debug!("ailake: Phase F — could not load Puffin stats ({stats_path}): {e}");
1696            return std::collections::HashMap::new();
1697        }
1698    };
1699    let reader = ailake_catalog::AilakePuffinReader::new(&bytes);
1700    let bloom_entries = match reader.read_bm25_blooms() {
1701        Ok(e) => e,
1702        Err(e) => {
1703            warn!("ailake: Phase F — Puffin bloom parse error: {e}");
1704            return std::collections::HashMap::new();
1705        }
1706    };
1707    bloom_entries
1708        .into_iter()
1709        .filter_map(|entry| {
1710            let bf = crate::bloom::BloomFilter::from_bytes(&entry.bloom_bytes)?;
1711            Some((entry.path, bf))
1712        })
1713        .collect()
1714}
1715
1716#[cfg(test)]
1717mod tests {
1718    use super::*;
1719    use crate::writer::MultiVectorBatch;
1720    use ailake_catalog::{HadoopCatalog, TableIdent};
1721    use ailake_core::{VectorMetric, VectorPrecision, VectorStoragePolicy};
1722    use ailake_store::LocalStore;
1723    use arrow_array::{Int32Array, RecordBatch};
1724    use arrow_schema::{DataType, Field, Schema};
1725    use std::sync::Arc;
1726    use tempfile::TempDir;
1727
1728    fn make_policy(dim: u32) -> VectorStoragePolicy {
1729        VectorStoragePolicy {
1730            column_name: "embedding".to_string(),
1731            dim,
1732            metric: VectorMetric::Cosine,
1733            precision: VectorPrecision::F16,
1734            pq: None,
1735            keep_raw_for_reranking: true,
1736            pre_normalize: false,
1737            hnsw_m: None,
1738            hnsw_ef_construction: None,
1739            ivf_residual: false,
1740            embedding_model: None,
1741            modality: None,
1742            partition_by: None,
1743            partition_value: None,
1744            partition_column_type: None,
1745            partition_fields: vec![],
1746        }
1747    }
1748
1749    async fn write_demo_table(dir: &TempDir, dim: usize, rows: usize) {
1750        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1751        let catalog = Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1752        let table = TableIdent::new("default", "table");
1753
1754        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1755        let ids: Vec<i32> = (0..rows as i32).collect();
1756        let batch = RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
1757
1758        // Each row i has embedding with 1.0 at dimension i and 0 elsewhere (unit basis vectors)
1759        let embeddings: Vec<Vec<f32>> = (0..rows)
1760            .map(|i| {
1761                let mut v = vec![0.0f32; dim];
1762                v[i % dim] = 1.0;
1763                v
1764            })
1765            .collect();
1766
1767        let mut writer =
1768            crate::TableWriter::create_or_open(catalog, store, make_policy(dim as u32), table, 2)
1769                .await
1770                .unwrap();
1771        writer.write_batch(&batch, &embeddings).await.unwrap();
1772        writer.commit().await.unwrap();
1773    }
1774
1775    /// Same shape as `write_demo_table`, plus a `category` column ("even"/"odd"
1776    /// by row id) — lets predicate-pushdown tests assert against a real column
1777    /// while still using the same unit-basis-vector embeddings (row `i`'s
1778    /// nearest neighbor for query `i` is unambiguous).
1779    async fn write_demo_table_with_category(dir: &TempDir, dim: usize, rows: usize) {
1780        use arrow_array::StringArray;
1781        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1782        let catalog = Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1783        let table = TableIdent::new("default", "table");
1784
1785        let schema = Arc::new(Schema::new(vec![
1786            Field::new("id", DataType::Int32, false),
1787            Field::new("category", DataType::Utf8, false),
1788        ]));
1789        let ids: Vec<i32> = (0..rows as i32).collect();
1790        let categories: Vec<&str> = (0..rows)
1791            .map(|i| if i % 2 == 0 { "even" } else { "odd" })
1792            .collect();
1793        let batch = RecordBatch::try_new(
1794            schema,
1795            vec![
1796                Arc::new(Int32Array::from(ids)),
1797                Arc::new(StringArray::from(categories)),
1798            ],
1799        )
1800        .unwrap();
1801
1802        let embeddings: Vec<Vec<f32>> = (0..rows)
1803            .map(|i| {
1804                let mut v = vec![0.0f32; dim];
1805                v[i % dim] = 1.0;
1806                v
1807            })
1808            .collect();
1809
1810        let mut writer =
1811            crate::TableWriter::create_or_open(catalog, store, make_policy(dim as u32), table, 2)
1812                .await
1813                .unwrap();
1814        writer.write_batch(&batch, &embeddings).await.unwrap();
1815        writer.commit().await.unwrap();
1816    }
1817
1818    #[tokio::test]
1819    async fn column_filter_preserves_row_identity_on_indexed_path() {
1820        // The critical correctness property: row_ids returned under a
1821        // column_filter must still be the TRUE file-relative row ids (usable
1822        // to fetch the right row), not positions into some internally
1823        // compacted/filtered batch. Every "even" row's embedding is a unit
1824        // basis vector at its own dimension, so searching with that exact
1825        // query and asserting the returned row_id matches would fail loudly
1826        // if row identity got shifted by the pushdown.
1827        let dir = TempDir::new().unwrap();
1828        let dim = 8usize;
1829        write_demo_table_with_category(&dir, dim, 8).await;
1830
1831        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1832        let catalog: Arc<dyn CatalogProvider> =
1833            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1834        let table = TableIdent::new("default", "table");
1835
1836        // Query matches row 4 exactly ("even" — 4 % 2 == 0).
1837        let mut query = vec![0.0f32; dim];
1838        query[4] = 1.0;
1839
1840        let config = SearchConfig {
1841            top_k: 1,
1842            ef_search: 50,
1843            pruning_threshold: f32::INFINITY,
1844            rerank_factor: None,
1845            score_fn: None,
1846            partition_filter: None,
1847            hybrid: None,
1848            column_filter: Some(ailake_core::ColumnFilter::eq(
1849                "category",
1850                ailake_core::FilterValue::Str("even".to_string()),
1851            )),
1852        };
1853
1854        let results = search(
1855            &table,
1856            &query,
1857            config,
1858            "embedding",
1859            dim as u32,
1860            catalog,
1861            store,
1862        )
1863        .await
1864        .unwrap();
1865
1866        assert_eq!(results.len(), 1);
1867        assert_eq!(
1868            results[0].row_id.as_u64(),
1869            4,
1870            "row_id must be the true file-relative position, not a filtered-batch index"
1871        );
1872    }
1873
1874    #[tokio::test]
1875    async fn column_filter_excludes_non_matching_rows() {
1876        let dir = TempDir::new().unwrap();
1877        let dim = 8usize;
1878        write_demo_table_with_category(&dir, dim, 8).await;
1879
1880        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1881        let catalog: Arc<dyn CatalogProvider> =
1882            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1883        let table = TableIdent::new("default", "table");
1884
1885        // Query matches row 3 exactly ("odd"); filtering for "even" must exclude it.
1886        let mut query = vec![0.0f32; dim];
1887        query[3] = 1.0;
1888
1889        let config = SearchConfig {
1890            top_k: 8,
1891            ef_search: 50,
1892            pruning_threshold: f32::INFINITY,
1893            rerank_factor: None,
1894            score_fn: None,
1895            partition_filter: None,
1896            hybrid: None,
1897            column_filter: Some(ailake_core::ColumnFilter::eq(
1898                "category",
1899                ailake_core::FilterValue::Str("even".to_string()),
1900            )),
1901        };
1902
1903        let results = search(
1904            &table,
1905            &query,
1906            config,
1907            "embedding",
1908            dim as u32,
1909            catalog,
1910            store,
1911        )
1912        .await
1913        .unwrap();
1914
1915        assert_eq!(results.len(), 4, "only the 4 even rows should survive");
1916        for r in &results {
1917            assert_eq!(
1918                r.row_id.as_u64() % 2,
1919                0,
1920                "row {} is not even",
1921                r.row_id.as_u64()
1922            );
1923        }
1924    }
1925
1926    #[tokio::test]
1927    async fn column_filter_no_match_returns_empty() {
1928        let dir = TempDir::new().unwrap();
1929        let dim = 8usize;
1930        write_demo_table_with_category(&dir, dim, 8).await;
1931
1932        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1933        let catalog: Arc<dyn CatalogProvider> =
1934            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1935        let table = TableIdent::new("default", "table");
1936
1937        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1938        let config = SearchConfig {
1939            top_k: 8,
1940            ef_search: 50,
1941            pruning_threshold: f32::INFINITY,
1942            rerank_factor: None,
1943            score_fn: None,
1944            partition_filter: None,
1945            hybrid: None,
1946            column_filter: Some(ailake_core::ColumnFilter::eq(
1947                "category",
1948                ailake_core::FilterValue::Str("nonexistent".to_string()),
1949            )),
1950        };
1951
1952        let results = search(
1953            &table,
1954            &query,
1955            config,
1956            "embedding",
1957            dim as u32,
1958            catalog,
1959            store,
1960        )
1961        .await
1962        .unwrap();
1963        assert!(results.is_empty());
1964    }
1965
1966    /// Wraps a `Store`, recording every `get`/`get_range` call's byte range
1967    /// per path — used to prove the Fase 16 range-GET fast path never
1968    /// touches the row-group tabular/vector data section of a file, not just
1969    /// to assert it doesn't crash. A whole-file `get` is recorded as
1970    /// `0..file_size` (its full extent), since that's what it touches even
1971    /// though the `Store` trait doesn't expose a byte offset for it.
1972    struct CountingStore {
1973        inner: Arc<dyn Store>,
1974        ranges_by_path: std::sync::Mutex<std::collections::HashMap<String, Vec<(u64, u64)>>>,
1975    }
1976
1977    impl CountingStore {
1978        fn new(inner: Arc<dyn Store>) -> Self {
1979            Self {
1980                inner,
1981                ranges_by_path: std::sync::Mutex::new(std::collections::HashMap::new()),
1982            }
1983        }
1984
1985        fn record(&self, path: &str, start: u64, end: u64) {
1986            self.ranges_by_path
1987                .lock()
1988                .unwrap()
1989                .entry(path.to_string())
1990                .or_default()
1991                .push((start, end));
1992        }
1993
1994        /// Lowest byte offset fetched for `path` across every recorded call —
1995        /// the precise, ratio-independent proof that the tabular/vector data
1996        /// section (everything before the AILK section) was never touched.
1997        fn min_offset_for(&self, path: &str) -> Option<u64> {
1998            self.ranges_by_path
1999                .lock()
2000                .unwrap()
2001                .get(path)
2002                .and_then(|ranges| ranges.iter().map(|(s, _)| *s).min())
2003        }
2004    }
2005
2006    #[async_trait::async_trait]
2007    impl Store for CountingStore {
2008        async fn get(&self, path: &str) -> AilakeResult<Bytes> {
2009            let b = self.inner.get(path).await?;
2010            self.record(path, 0, b.len() as u64);
2011            Ok(b)
2012        }
2013        async fn get_range(&self, path: &str, range: std::ops::Range<u64>) -> AilakeResult<Bytes> {
2014            let b = self.inner.get_range(path, range.clone()).await?;
2015            self.record(path, range.start, range.end);
2016            Ok(b)
2017        }
2018        async fn put(&self, path: &str, data: Bytes) -> AilakeResult<()> {
2019            self.inner.put(path, data).await
2020        }
2021        async fn list(&self, prefix: &str) -> AilakeResult<Vec<String>> {
2022            self.inner.list(prefix).await
2023        }
2024        async fn file_size(&self, path: &str) -> AilakeResult<u64> {
2025            self.inner.file_size(path).await
2026        }
2027        async fn exists(&self, path: &str) -> AilakeResult<bool> {
2028            self.inner.exists(path).await
2029        }
2030        async fn delete(&self, path: &str) -> AilakeResult<()> {
2031            self.inner.delete(path).await
2032        }
2033    }
2034
2035    #[tokio::test]
2036    async fn range_get_fast_path_never_touches_tabular_data_section() {
2037        let dir = TempDir::new().unwrap();
2038        let dim = 64usize;
2039        write_demo_table(&dir, dim, 400).await;
2040
2041        let local: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2042        let data_files = local.list("data").await.unwrap();
2043        assert_eq!(
2044            data_files.len(),
2045            1,
2046            "expect a single part file for this test"
2047        );
2048        let data_path = data_files[0].clone();
2049
2050        // Ground truth: the exact tabular/data-section boundary, from a plain
2051        // full-file read+parse — independent of the fast path under test.
2052        let full_bytes = local.get(&data_path).await.unwrap();
2053        let ailk_offset = ailake_file::AilakeFileReader::new(full_bytes, "embedding", dim as u32)
2054            .ailk_offset()
2055            .unwrap();
2056
2057        let counting = Arc::new(CountingStore::new(local.clone()));
2058        let store: Arc<dyn Store> = counting.clone();
2059        let catalog: Arc<dyn CatalogProvider> =
2060            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2061        let table = TableIdent::new("default", "table");
2062
2063        let mut query = vec![0.0f32; dim];
2064        query[3] = 1.0;
2065        let config = SearchConfig {
2066            top_k: 5,
2067            ef_search: 50,
2068            pruning_threshold: f32::INFINITY,
2069            rerank_factor: None,
2070            score_fn: None,
2071            partition_filter: None,
2072            hybrid: None,
2073            column_filter: None,
2074        };
2075
2076        let results = search(
2077            &table,
2078            &query,
2079            config,
2080            "embedding",
2081            dim as u32,
2082            catalog,
2083            store,
2084        )
2085        .await
2086        .unwrap();
2087        assert!(!results.is_empty());
2088
2089        // The precise, ratio-independent guarantee this feature exists for:
2090        // regardless of how large the HNSW blob happens to be relative to the
2091        // tabular data (small synthetic tables can skew that ratio either
2092        // way — see `range_get_fast_path_matches_full_file_path_results` for
2093        // the separate correctness-parity proof), the fast path must never
2094        // fetch a single byte positioned before the AILK section starts.
2095        let min_offset = counting
2096            .min_offset_for(&data_path)
2097            .expect("should have fetched something from the data file");
2098        assert!(
2099            min_offset >= ailk_offset,
2100            "fast path fetched bytes from the tabular/vector data section: \
2101             min_offset={min_offset} < ailk_offset={ailk_offset}"
2102        );
2103    }
2104
2105    #[tokio::test]
2106    async fn range_get_fast_path_matches_full_file_path_results() {
2107        // Forces the full-file fallback via a column_filter that matches
2108        // every row (id >= 0) — same semantic query and identical distances,
2109        // just disqualified from the fast path (config.column_filter.is_some()).
2110        // Comparing the two proves the fast path isn't silently returning
2111        // different (or wrong-row-identity) results.
2112        let dir = TempDir::new().unwrap();
2113        let dim = 16usize;
2114        write_demo_table(&dir, dim, 20).await;
2115
2116        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2117        let catalog: Arc<dyn CatalogProvider> =
2118            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2119        let table = TableIdent::new("default", "table");
2120
2121        let mut query = vec![0.0f32; dim];
2122        query[3] = 1.0;
2123
2124        let fast_config = SearchConfig {
2125            top_k: 5,
2126            ef_search: 50,
2127            pruning_threshold: f32::INFINITY,
2128            rerank_factor: None,
2129            score_fn: None,
2130            partition_filter: None,
2131            hybrid: None,
2132            column_filter: None,
2133        };
2134        let fast_results = search(
2135            &table,
2136            &query,
2137            fast_config,
2138            "embedding",
2139            dim as u32,
2140            catalog.clone(),
2141            store.clone(),
2142        )
2143        .await
2144        .unwrap();
2145
2146        let slow_config = SearchConfig {
2147            top_k: 5,
2148            ef_search: 50,
2149            pruning_threshold: f32::INFINITY,
2150            rerank_factor: None,
2151            score_fn: None,
2152            partition_filter: None,
2153            hybrid: None,
2154            column_filter: Some(ailake_core::ColumnFilter::new(
2155                "id",
2156                ailake_core::FilterOp::Gte,
2157                ailake_core::FilterValue::I64(0),
2158            )),
2159        };
2160        let slow_results = search(
2161            &table,
2162            &query,
2163            slow_config,
2164            "embedding",
2165            dim as u32,
2166            catalog,
2167            store,
2168        )
2169        .await
2170        .unwrap();
2171
2172        assert_eq!(fast_results.len(), slow_results.len());
2173        assert!(!fast_results.is_empty());
2174        for (f, s) in fast_results.iter().zip(slow_results.iter()) {
2175            assert_eq!(f.row_id, s.row_id, "fast/slow path row_id mismatch");
2176            assert!(
2177                (f.distance - s.distance).abs() < 1e-4,
2178                "fast/slow path distance mismatch: {} vs {}",
2179                f.distance,
2180                s.distance
2181            );
2182        }
2183    }
2184
2185    #[tokio::test]
2186    async fn rerank_returns_correct_top_k_count() {
2187        let dir = TempDir::new().unwrap();
2188        let dim = 8usize;
2189        write_demo_table(&dir, dim, 8).await;
2190
2191        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2192        let catalog: Arc<dyn CatalogProvider> =
2193            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2194        let table = TableIdent::new("default", "table");
2195
2196        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2197        let config = SearchConfig {
2198            top_k: 3,
2199            ef_search: 50,
2200            pruning_threshold: f32::INFINITY,
2201            rerank_factor: Some(2),
2202            score_fn: None,
2203            partition_filter: None,
2204            hybrid: None,
2205            column_filter: None,
2206        };
2207
2208        let results = search(
2209            &table,
2210            &query,
2211            config,
2212            "embedding",
2213            dim as u32,
2214            catalog,
2215            store,
2216        )
2217        .await
2218        .unwrap();
2219
2220        assert_eq!(results.len(), 3);
2221    }
2222
2223    #[tokio::test]
2224    async fn rerank_nearest_is_exact_match() {
2225        let dir = TempDir::new().unwrap();
2226        let dim = 8usize;
2227        write_demo_table(&dir, dim, 8).await;
2228
2229        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2230        let catalog: Arc<dyn CatalogProvider> =
2231            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2232        let table = TableIdent::new("default", "table");
2233
2234        // Row 0 has [1,0,0,...] — cosine distance to same query is 0
2235        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2236        let config = SearchConfig {
2237            top_k: 1,
2238            ef_search: 50,
2239            pruning_threshold: f32::INFINITY,
2240            rerank_factor: Some(4),
2241            score_fn: None,
2242            partition_filter: None,
2243            hybrid: None,
2244            column_filter: None,
2245        };
2246
2247        let results = search(
2248            &table,
2249            &query,
2250            config,
2251            "embedding",
2252            dim as u32,
2253            catalog,
2254            store,
2255        )
2256        .await
2257        .unwrap();
2258
2259        assert_eq!(results.len(), 1);
2260        // Exact cosine distance between identical unit vectors is ~0 (F16 rounding allowed)
2261        assert!(
2262            results[0].distance < 1e-3,
2263            "distance was {}",
2264            results[0].distance
2265        );
2266        assert_eq!(results[0].row_id, RowId::new(0));
2267    }
2268
2269    #[tokio::test]
2270    async fn no_rerank_matches_default_behavior() {
2271        let dir = TempDir::new().unwrap();
2272        let dim = 4usize;
2273        write_demo_table(&dir, dim, 4).await;
2274
2275        let store_a: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2276        let store_b: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2277        let cat_a: Arc<dyn CatalogProvider> =
2278            Arc::new(HadoopCatalog::new(store_a.clone(), "warehouse"));
2279        let cat_b: Arc<dyn CatalogProvider> =
2280            Arc::new(HadoopCatalog::new(store_b.clone(), "warehouse"));
2281        let table = TableIdent::new("default", "table");
2282
2283        let query = vec![1.0f32, 0.0, 0.0, 0.0];
2284        let cfg_plain = SearchConfig {
2285            top_k: 2,
2286            ef_search: 50,
2287            pruning_threshold: f32::INFINITY,
2288            rerank_factor: None,
2289            score_fn: None,
2290            partition_filter: None,
2291            hybrid: None,
2292            column_filter: None,
2293        };
2294        let cfg_rerank = SearchConfig {
2295            top_k: 2,
2296            ef_search: 50,
2297            pruning_threshold: f32::INFINITY,
2298            rerank_factor: Some(2),
2299            score_fn: None,
2300            partition_filter: None,
2301            hybrid: None,
2302            column_filter: None,
2303        };
2304
2305        let plain = search(
2306            &table,
2307            &query,
2308            cfg_plain,
2309            "embedding",
2310            dim as u32,
2311            cat_a,
2312            store_a,
2313        )
2314        .await
2315        .unwrap();
2316        let reranked = search(
2317            &table,
2318            &query,
2319            cfg_rerank,
2320            "embedding",
2321            dim as u32,
2322            cat_b,
2323            store_b,
2324        )
2325        .await
2326        .unwrap();
2327
2328        // Both should return same top-1 result (row 0, distance ~0)
2329        assert_eq!(plain[0].row_id, reranked[0].row_id);
2330    }
2331
2332    #[tokio::test]
2333    async fn multimodal_rrf_returns_top_k() {
2334        let dir = TempDir::new().unwrap();
2335        let dim = 4usize;
2336        write_demo_table(&dir, dim, 4).await;
2337
2338        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2339        let catalog: Arc<dyn CatalogProvider> =
2340            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2341        let table = TableIdent::new("default", "table");
2342
2343        // Two modal queries using the same column (single-column table).
2344        // Different queries to exercise RRF merging.
2345        let q1 = vec![1.0f32, 0.0, 0.0, 0.0];
2346        let q2 = vec![0.0f32, 1.0, 0.0, 0.0];
2347
2348        let queries = vec![
2349            ModalQuery {
2350                column: "embedding",
2351                query: &q1,
2352                weight: 0.7,
2353                dim: dim as u32,
2354            },
2355            ModalQuery {
2356                column: "embedding",
2357                query: &q2,
2358                weight: 0.3,
2359                dim: dim as u32,
2360            },
2361        ];
2362
2363        let config = SearchConfig {
2364            top_k: 2,
2365            ef_search: 50,
2366            pruning_threshold: f32::INFINITY,
2367            rerank_factor: None,
2368            score_fn: None,
2369            partition_filter: None,
2370            hybrid: None,
2371            column_filter: None,
2372        };
2373
2374        let results =
2375            search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf)
2376                .await
2377                .unwrap();
2378
2379        assert_eq!(results.len(), 2);
2380        // RRF score stored as -distance; all should be negative
2381        assert!(results[0].distance <= 0.0);
2382        // Top result should be one of rows 0 or 1 (nearest to q1 or q2)
2383        assert!(results[0].row_id.as_u64() < 4);
2384    }
2385
2386    /// True cross-modal test: two columns with DIFFERENT dims (4 + 2).
2387    /// Verifies that search_multimodal correctly routes to each column's HNSW
2388    /// and that the dim validation in search() handles secondary columns.
2389    #[tokio::test]
2390    async fn multimodal_rrf_cross_modal_different_dims() {
2391        let dir = TempDir::new().unwrap();
2392        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2393        let catalog: Arc<dyn CatalogProvider> =
2394            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2395        let table = TableIdent::new("default", "table");
2396
2397        // Write a 2-column table: "embedding" dim=4, "img_embedding" dim=2
2398        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
2399        let rows = 4usize;
2400        let ids: Vec<i32> = (0..rows as i32).collect();
2401        let batch = RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
2402
2403        let text_embs: Vec<Vec<f32>> = (0..rows)
2404            .map(|i| {
2405                let mut v = vec![0.0f32; 4];
2406                v[i % 4] = 1.0;
2407                v
2408            })
2409            .collect();
2410        let img_embs: Vec<Vec<f32>> = (0..rows)
2411            .map(|i| {
2412                let mut v = vec![0.0f32; 2];
2413                v[i % 2] = 1.0;
2414                v
2415            })
2416            .collect();
2417
2418        let text_policy = make_policy(4);
2419        let img_policy = VectorStoragePolicy {
2420            column_name: "img_embedding".to_string(),
2421            dim: 2,
2422            metric: VectorMetric::Cosine,
2423            precision: VectorPrecision::F16,
2424            pq: None,
2425            keep_raw_for_reranking: true,
2426            pre_normalize: false,
2427            hnsw_m: None,
2428            hnsw_ef_construction: None,
2429            ivf_residual: false,
2430            embedding_model: None,
2431            modality: None,
2432            partition_by: None,
2433            partition_value: None,
2434            partition_column_type: None,
2435            partition_fields: vec![],
2436        };
2437
2438        let mut writer = crate::TableWriter::create_or_open(
2439            catalog.clone(),
2440            store.clone(),
2441            text_policy,
2442            table.clone(),
2443            2,
2444        )
2445        .await
2446        .unwrap();
2447
2448        let batches = [
2449            MultiVectorBatch {
2450                policy: make_policy(4),
2451                embeddings: &text_embs,
2452            },
2453            MultiVectorBatch {
2454                policy: img_policy,
2455                embeddings: &img_embs,
2456            },
2457        ];
2458        writer.write_batch_multi(&batch, &batches).await.unwrap();
2459        writer.commit().await.unwrap();
2460
2461        // Cross-modal search: text query (dim=4) + image query (dim=2).
2462        let q_text = vec![1.0f32, 0.0, 0.0, 0.0];
2463        let q_img = vec![1.0f32, 0.0];
2464
2465        let queries = vec![
2466            ModalQuery {
2467                column: "embedding",
2468                query: &q_text,
2469                weight: 0.6,
2470                dim: 4,
2471            },
2472            ModalQuery {
2473                column: "img_embedding",
2474                query: &q_img,
2475                weight: 0.4,
2476                dim: 2,
2477            },
2478        ];
2479        let config = SearchConfig {
2480            top_k: 2,
2481            ef_search: 50,
2482            pruning_threshold: f32::INFINITY,
2483            rerank_factor: None,
2484            score_fn: None,
2485            partition_filter: None,
2486            hybrid: None,
2487            column_filter: None,
2488        };
2489
2490        let results =
2491            search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf)
2492                .await
2493                .unwrap();
2494
2495        assert!(!results.is_empty(), "should return results");
2496        assert!(results[0].distance <= 0.0, "distance is -rrf_score");
2497        // Row 0 is nearest to both q_text=[1,0,0,0] and q_img=[1,0]
2498        assert_eq!(results[0].row_id.as_u64(), 0, "row 0 should rank first");
2499    }
2500
2501    /// Regression: `fetch_rows` used to build its output schema from whichever file's
2502    /// physical Parquet schema happened to be read first — a file written before a
2503    /// metadata-only `evolve_schema`/`add_column` never physically has the new column,
2504    /// so it was silently absent from the response instead of projected as null.
2505    /// Confirmed live via Spark's `AilakeNative.scan()` and
2506    /// `ailake.search(fetch_data=True)`, both backed by this function.
2507    #[tokio::test]
2508    async fn fetch_rows_projects_evolved_column_as_null() {
2509        use ailake_catalog::schema_evolution::{AddColumnRequest, SchemaEvolution};
2510
2511        let dir = TempDir::new().unwrap();
2512        let dim = 8usize;
2513        write_demo_table(&dir, dim, 8).await;
2514
2515        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2516        let catalog: Arc<dyn CatalogProvider> =
2517            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2518        let table = TableIdent::new("default", "table");
2519
2520        // Metadata-only schema evolution — no data files rewritten, so every existing
2521        // file on disk still physically lacks the "note" column.
2522        catalog
2523            .evolve_schema(
2524                &table,
2525                SchemaEvolution::new().add_column(AddColumnRequest {
2526                    name: "note".to_string(),
2527                    iceberg_type: "string".to_string(),
2528                    required: false,
2529                    initial_default: None,
2530                    write_default: None,
2531                    doc: None,
2532                }),
2533            )
2534            .await
2535            .unwrap();
2536
2537        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2538        let config = SearchConfig {
2539            top_k: 3,
2540            ef_search: 50,
2541            pruning_threshold: f32::INFINITY,
2542            rerank_factor: None,
2543            score_fn: None,
2544            partition_filter: None,
2545            hybrid: None,
2546            column_filter: None,
2547        };
2548        let results = search(
2549            &table,
2550            &query,
2551            config,
2552            "embedding",
2553            dim as u32,
2554            Arc::clone(&catalog),
2555            Arc::clone(&store),
2556        )
2557        .await
2558        .unwrap();
2559        assert_eq!(results.len(), 3);
2560
2561        let table_meta = catalog.load_table(&table).await.unwrap();
2562        let batch = fetch_rows(
2563            &results,
2564            store,
2565            "embedding",
2566            dim as u32,
2567            &table_meta.schema_fields,
2568        )
2569        .await
2570        .unwrap();
2571
2572        let note_col = batch
2573            .column_by_name("note")
2574            .expect("evolved 'note' column must be present, not silently dropped");
2575        assert_eq!(note_col.len(), 3);
2576        assert_eq!(
2577            note_col.null_count(),
2578            3,
2579            "old files predate 'note' — every value must be null, not an error or a missing column"
2580        );
2581    }
2582
2583    /// Regression: the schema-projection fix above (`fetch_rows_projects_evolved_column_as_null`)
2584    /// initially introduced its own bug — `SchemaFiller::fill` was called with the *unfiltered*
2585    /// current-schema field list, which includes the vector column itself. Since
2586    /// `AilakeFileReader::read_parquet()` deliberately returns the vector column out-of-band
2587    /// (as `vectors: Vec<Vec<f32>>`, not as part of the tabular `RecordBatch`), the filler saw
2588    /// it as "missing" and injected a synthetic column for it — wrong-typed (`iceberg_type_to_arrow`'s
2589    /// `Utf8` fallback) and a duplicate of the real decoded vector column appended a few lines
2590    /// later, breaking pandas' arrow→pandas conversion for *every* `fetch_data=True`/`scan()`
2591    /// call, not just evolved-schema ones. Caught immediately by testing against real
2592    /// pandas conversion (not just the Rust arrow API) — this test guards it at the Rust level too.
2593    #[tokio::test]
2594    async fn fetch_rows_does_not_duplicate_vector_column() {
2595        let dir = TempDir::new().unwrap();
2596        let dim = 8usize;
2597        write_demo_table(&dir, dim, 8).await;
2598
2599        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2600        let catalog: Arc<dyn CatalogProvider> =
2601            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2602        let table = TableIdent::new("default", "table");
2603
2604        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2605        let config = SearchConfig {
2606            top_k: 3,
2607            ef_search: 50,
2608            pruning_threshold: f32::INFINITY,
2609            rerank_factor: None,
2610            score_fn: None,
2611            partition_filter: None,
2612            hybrid: None,
2613            column_filter: None,
2614        };
2615        let results = search(
2616            &table,
2617            &query,
2618            config,
2619            "embedding",
2620            dim as u32,
2621            Arc::clone(&catalog),
2622            Arc::clone(&store),
2623        )
2624        .await
2625        .unwrap();
2626
2627        let table_meta = catalog.load_table(&table).await.unwrap();
2628        let batch = fetch_rows(
2629            &results,
2630            store,
2631            "embedding",
2632            dim as u32,
2633            &table_meta.schema_fields,
2634        )
2635        .await
2636        .unwrap();
2637
2638        let batch_schema = batch.schema();
2639        let embedding_fields: Vec<_> = batch_schema
2640            .fields()
2641            .iter()
2642            .filter(|f| f.name() == "embedding")
2643            .collect();
2644        assert_eq!(
2645            embedding_fields.len(),
2646            1,
2647            "exactly one 'embedding' field expected, got: {:?}",
2648            batch_schema
2649        );
2650        assert!(
2651            matches!(embedding_fields[0].data_type(), arrow_schema::DataType::FixedSizeList(_, d) if *d == dim as i32),
2652            "embedding field must be the decoded FixedSizeList<Float32>, got {:?}",
2653            embedding_fields[0].data_type()
2654        );
2655    }
2656
2657    // ── Edge cases: dimension mismatch ───────────────────────────────────
2658
2659    #[tokio::test]
2660    async fn search_dimension_mismatch_rejected() {
2661        let dir = TempDir::new().unwrap();
2662        write_demo_table(&dir, 8, 5).await;
2663
2664        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2665        let catalog: Arc<dyn CatalogProvider> =
2666            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2667        let table = TableIdent::new("default", "table");
2668
2669        // Query with wrong dimension (4 instead of 8)
2670        let query = vec![1.0f32, 0.0, 0.0, 0.0];
2671        let config = SearchConfig::default();
2672        let result = search(
2673            &table,
2674            &query,
2675            config,
2676            "embedding",
2677            8,
2678            Arc::clone(&catalog),
2679            Arc::clone(&store),
2680        )
2681        .await;
2682        assert!(
2683            result.is_err(),
2684            "search with wrong dim should error, got Ok"
2685        );
2686    }
2687
2688    #[tokio::test]
2689    async fn search_top_k_over_cap_rejected() {
2690        // No table written — top_k validation must reject before any catalog/file I/O,
2691        // matching how ailake-jni's C-ABI boundary already validates top_k, but closing
2692        // the same gap for every caller that reaches scanner::search directly (Python
2693        // bindings, CLI, and transitively Go/C++/Airflow via CLI shell-out) instead of
2694        // going through ailake-jni.
2695        let dir = TempDir::new().unwrap();
2696        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2697        let catalog: Arc<dyn CatalogProvider> =
2698            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2699        let table = TableIdent::new("default", "table");
2700
2701        let config = SearchConfig {
2702            top_k: ailake_core::MAX_TOP_K + 1,
2703            ..SearchConfig::default()
2704        };
2705        let result = search(
2706            &table,
2707            &[1.0f32, 0.0, 0.0, 0.0],
2708            config,
2709            "embedding",
2710            4,
2711            catalog,
2712            store,
2713        )
2714        .await;
2715        let err = result.expect_err("top_k over cap should error");
2716        assert!(err.to_string().contains("top_k"), "{err}");
2717    }
2718
2719    #[tokio::test]
2720    async fn search_multimodal_top_k_over_cap_rejected() {
2721        let dir = TempDir::new().unwrap();
2722        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2723        let catalog: Arc<dyn CatalogProvider> =
2724            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2725        let table = TableIdent::new("default", "table");
2726
2727        let config = SearchConfig {
2728            top_k: ailake_core::MAX_TOP_K + 1,
2729            ..SearchConfig::default()
2730        };
2731        let queries = vec![ModalQuery {
2732            column: "embedding",
2733            query: &[1.0f32, 0.0, 0.0, 0.0],
2734            weight: 1.0,
2735            dim: 4,
2736        }];
2737        let result =
2738            search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf).await;
2739        let err = result.expect_err("top_k over cap should error");
2740        assert!(err.to_string().contains("top_k"), "{err}");
2741    }
2742
2743    #[tokio::test]
2744    async fn search_text_top_k_over_cap_rejected() {
2745        let dir = TempDir::new().unwrap();
2746        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2747        let catalog: Arc<dyn CatalogProvider> =
2748            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2749        let table = TableIdent::new("default", "table");
2750
2751        let result = search_text(
2752            &table,
2753            "hello",
2754            &["chunk_text"],
2755            ailake_core::MAX_TOP_K + 1,
2756            catalog,
2757            store,
2758            None,
2759        )
2760        .await;
2761        let err = result.expect_err("top_k over cap should error");
2762        assert!(err.to_string().contains("top_k"), "{err}");
2763    }
2764
2765    // ── Fuzzing: FailStore round-trips ────────────────────────────────
2766
2767    #[cfg(test)]
2768    mod fuzz_tests {
2769        use super::*;
2770        use crate::writer::TableWriter;
2771        use ailake_catalog::{HadoopCatalog, TableIdent, TableProperties};
2772        use ailake_core::{VectorMetric, VectorPrecision, VectorStoragePolicy};
2773        use ailake_store::{fail_store::FailStore, LocalStore};
2774        use arrow_array::{Int32Array, RecordBatch};
2775        use arrow_schema::{DataType, Field, Schema};
2776        use proptest::prelude::*;
2777        use std::sync::Arc;
2778        use tempfile::TempDir;
2779
2780        fn make_fuzz_policy(dim: u32) -> VectorStoragePolicy {
2781            VectorStoragePolicy {
2782                column_name: "embedding".to_string(),
2783                dim,
2784                metric: VectorMetric::Cosine,
2785                precision: VectorPrecision::F16,
2786                pq: None,
2787                keep_raw_for_reranking: true,
2788                pre_normalize: false,
2789                hnsw_m: None,
2790                hnsw_ef_construction: None,
2791                ivf_residual: false,
2792                embedding_model: None,
2793                modality: None,
2794                partition_by: None,
2795                partition_value: None,
2796                partition_column_type: None,
2797                partition_fields: vec![],
2798            }
2799        }
2800
2801        #[derive(Debug, Clone)]
2802        struct FailProfile {
2803            fail_get: bool,
2804            fail_get_range: bool,
2805            fail_put: bool,
2806            fail_list: bool,
2807            fail_file_size: bool,
2808        }
2809
2810        fn arb_fail_profile() -> impl Strategy<Value = FailProfile> {
2811            (
2812                proptest::bool::ANY,
2813                proptest::bool::ANY,
2814                proptest::bool::ANY,
2815                proptest::bool::ANY,
2816                proptest::bool::ANY,
2817            )
2818                .prop_map(|(g, gr, p, l, fs)| FailProfile {
2819                    fail_get: g,
2820                    fail_get_range: gr,
2821                    fail_put: p,
2822                    fail_list: l,
2823                    fail_file_size: fs,
2824                })
2825        }
2826
2827        #[derive(Debug, Clone, PartialEq)]
2828        enum FuzzOutcome {
2829            Ok,
2830            StoreError,
2831            OtherError,
2832        }
2833
2834        fn is_store_err(e: &ailake_core::AilakeError) -> bool {
2835            matches!(e, ailake_core::AilakeError::Store(_))
2836        }
2837
2838        async fn write_search_rt(profile: &FailProfile, rows: usize, dim: u32) -> FuzzOutcome {
2839            let dir = TempDir::new().unwrap();
2840            let inner = LocalStore::new(dir.path());
2841            let fail = FailStore::new(inner)
2842                .with_fail_get(profile.fail_get)
2843                .with_fail_get_range(profile.fail_get_range)
2844                .with_fail_put(profile.fail_put)
2845                .with_fail_list(profile.fail_list)
2846                .with_fail_file_size(profile.fail_file_size);
2847            let store: Arc<dyn Store> = Arc::new(fail);
2848            let catalog: Arc<dyn CatalogProvider> =
2849                Arc::new(HadoopCatalog::new(Arc::clone(&store), "warehouse"));
2850            let table = TableIdent::new("default", "fuzz_test");
2851            let policy = make_fuzz_policy(dim);
2852
2853            if catalog
2854                .create_table(
2855                    &table,
2856                    &TableProperties {
2857                        policy: policy.clone(),
2858                        format_version: 2,
2859                        extra: Default::default(),
2860                        partition_column_type: None,
2861                    },
2862                )
2863                .await
2864                .is_err()
2865            {
2866                return FuzzOutcome::StoreError;
2867            }
2868
2869            let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
2870            let ids: Vec<i32> = (0..rows as i32).collect();
2871            let batch =
2872                RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
2873            let embeddings: Vec<Vec<f32>> = (0..rows)
2874                .map(|i| {
2875                    let mut v = vec![0.0f32; dim as usize];
2876                    v[i % dim as usize] = 1.0;
2877                    v
2878                })
2879                .collect();
2880
2881            let mut writer = match TableWriter::create_or_open(
2882                Arc::clone(&catalog),
2883                Arc::clone(&store),
2884                policy.clone(),
2885                table.clone(),
2886                2,
2887            )
2888            .await
2889            {
2890                Ok(w) => w,
2891                Err(e) => {
2892                    return if is_store_err(&e) {
2893                        FuzzOutcome::StoreError
2894                    } else {
2895                        FuzzOutcome::OtherError
2896                    }
2897                }
2898            };
2899
2900            if let Err(e) = writer.write_batch(&batch, &embeddings).await {
2901                return if is_store_err(&e) {
2902                    FuzzOutcome::StoreError
2903                } else {
2904                    FuzzOutcome::OtherError
2905                };
2906            }
2907
2908            if let Err(e) = writer.commit().await {
2909                return if is_store_err(&e) {
2910                    FuzzOutcome::StoreError
2911                } else {
2912                    FuzzOutcome::OtherError
2913                };
2914            }
2915
2916            let query: Vec<f32> = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2917            let config = SearchConfig {
2918                top_k: 3.min(rows),
2919                ef_search: 50,
2920                pruning_threshold: f32::INFINITY,
2921                ..Default::default()
2922            };
2923
2924            match search(
2925                &table,
2926                &query[..dim as usize],
2927                config,
2928                "embedding",
2929                dim,
2930                Arc::clone(&catalog),
2931                Arc::clone(&store),
2932            )
2933            .await
2934            {
2935                Ok(results) => {
2936                    assert!(results.len() <= rows.min(3), "more results than requested");
2937                    for r in &results {
2938                        assert!(r.distance.is_finite(), "non-finite distance");
2939                        assert!(r.distance >= 0.0, "negative distance");
2940                    }
2941                    FuzzOutcome::Ok
2942                }
2943                Err(e) => {
2944                    if is_store_err(&e) {
2945                        FuzzOutcome::StoreError
2946                    } else {
2947                        panic!("unexpected non-store search error: {:?}", e);
2948                    }
2949                }
2950            }
2951        }
2952
2953        async fn compact_rt(profile: &FailProfile, rows: usize, dim: u32) -> &'static str {
2954            let dir = TempDir::new().unwrap();
2955            let inner = LocalStore::new(dir.path());
2956            let fail = FailStore::new(inner)
2957                .with_fail_get(profile.fail_get)
2958                .with_fail_get_range(profile.fail_get_range)
2959                .with_fail_put(profile.fail_put)
2960                .with_fail_list(profile.fail_list)
2961                .with_fail_file_size(profile.fail_file_size);
2962            let store: Arc<dyn Store> = Arc::new(fail);
2963            let catalog: Arc<dyn CatalogProvider> =
2964                Arc::new(HadoopCatalog::new(Arc::clone(&store), "warehouse"));
2965            let table = TableIdent::new("default", "fuzz_compact");
2966            let policy = make_fuzz_policy(dim);
2967
2968            if catalog
2969                .create_table(
2970                    &table,
2971                    &TableProperties {
2972                        policy: policy.clone(),
2973                        format_version: 2,
2974                        extra: Default::default(),
2975                        partition_column_type: None,
2976                    },
2977                )
2978                .await
2979                .is_err()
2980            {
2981                return "store_err";
2982            }
2983
2984            for i in 0..2 {
2985                let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
2986                let ids: Vec<i32> = (0..rows as i32).collect();
2987                let batch =
2988                    RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
2989                let embeddings: Vec<Vec<f32>> = (0..rows)
2990                    .map(|j| {
2991                        let mut v = vec![0.0f32; dim as usize];
2992                        v[(i * rows + j) % dim as usize] = 1.0;
2993                        v
2994                    })
2995                    .collect();
2996
2997                let mut writer = match TableWriter::create_or_open(
2998                    Arc::clone(&catalog),
2999                    Arc::clone(&store),
3000                    policy.clone(),
3001                    table.clone(),
3002                    2,
3003                )
3004                .await
3005                {
3006                    Ok(w) => w,
3007                    Err(_) => return "store_err",
3008                };
3009                if writer.write_batch(&batch, &embeddings).await.is_err() {
3010                    return "store_err";
3011                }
3012                if writer.commit().await.is_err() {
3013                    return "store_err";
3014                }
3015            }
3016
3017            let files = match catalog.list_files(&table, None).await {
3018                Ok(f) => f,
3019                Err(_) => return "store_err",
3020            };
3021            if files.len() < 2 {
3022                return "too_few";
3023            }
3024
3025            let config = crate::compaction::CompactionConfig {
3026                min_files_to_compact: 2,
3027                target_file_size_bytes: 10 * 1024 * 1024,
3028                ..Default::default()
3029            };
3030            let planner = crate::compaction::CompactionPlanner::new(config);
3031            let executor =
3032                crate::compaction::CompactionExecutor::new(Arc::clone(&store), policy.clone());
3033
3034            match executor
3035                .run(&planner, &table, Arc::clone(&catalog), "data/merged")
3036                .await
3037            {
3038                Ok(Some(_)) => "ok",
3039                Ok(None) => "noop",
3040                Err(e) if is_store_err(&e) => "store_err",
3041                Err(_) => "unexpected_err",
3042            }
3043        }
3044
3045        proptest! {
3046            #[test]
3047            fn prop_write_search_under_failstore(
3048                profile in arb_fail_profile(),
3049                rows in 1usize..8,
3050                dim in 2u32..8,
3051            ) {
3052                let rt = tokio::runtime::Runtime::new().unwrap();
3053                let outcome = rt.block_on(write_search_rt(&profile, rows, dim));
3054                assert_ne!(outcome, FuzzOutcome::OtherError,
3055                    "unexpected non-store error during write+search under FailStore");
3056            }
3057
3058            #[test]
3059            fn prop_compact_under_failstore(
3060                profile in arb_fail_profile(),
3061                rows in 2usize..6,
3062                dim in 2u32..8,
3063            ) {
3064                let rt = tokio::runtime::Runtime::new().unwrap();
3065                let outcome = rt.block_on(compact_rt(&profile, rows, dim));
3066                assert_ne!(outcome, "unexpected_err",
3067                    "compaction failed with non-store error under FailStore");
3068            }
3069        }
3070    }
3071}