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(
721                &batch,
722                row_id.as_u64() as usize,
723                file_entry.sequence_number,
724            ) {
725                continue;
726            }
727            // Predicate pushdown: row survived the file-level check above,
728            // but only rows in the matching set itself pass the filter.
729            if matching_row_ids
730                .as_ref()
731                .is_some_and(|ids| !ids.contains(&row_id.as_u64()))
732            {
733                continue;
734            }
735            if use_hybrid {
736                let text = extract_text_for_row(
737                    &batch,
738                    row_id.as_u64() as usize,
739                    config.hybrid.as_ref().unwrap(),
740                );
741                outcome
742                    .candidates
743                    .push((row_id, distance, file_entry.path.clone(), text));
744            } else {
745                let final_score = apply_score_fn(&config.score_fn, distance, row_id, &batch);
746                outcome.results.push(SearchResult {
747                    row_id,
748                    distance: final_score,
749                    file_path: file_entry.path.clone(),
750                });
751            }
752        }
753        return Ok(outcome);
754    }
755
756    let index = reader.load_any_index_for_column(vector_column)?;
757    let local_results = index.search(query, candidate_k, config.ef_search);
758
759    let parquet_data = if need_parquet {
760        let (raw_batch, raw_vecs) = reader.read_parquet()?;
761        // Phase G: fill missing columns for old files before score_fn / hybrid BM25.
762        let filled = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
763        Some((filled, raw_vecs))
764    } else {
765        None
766    };
767
768    for (row_id, approx_dist) in local_results {
769        // Skip rows marked as deleted by a V3 Deletion Vector.
770        if dv_bitmap
771            .as_ref()
772            .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
773        {
774            continue;
775        }
776        let idx = row_id.as_u64() as usize;
777        // Phase H: skip rows matched by an equality delete predicate.
778        // parquet_data is always loaded when eq_del_filter is non-empty (see need_parquet).
779        if let Some((ref batch, _)) = parquet_data {
780            if eq_del_filter.should_delete_row(batch, idx, file_entry.sequence_number) {
781                continue;
782            }
783        }
784        // Predicate pushdown: row survived the file-level check above,
785        // but only rows in the matching set itself pass the filter.
786        if matching_row_ids
787            .as_ref()
788            .is_some_and(|ids| !ids.contains(&row_id.as_u64()))
789        {
790            continue;
791        }
792
793        let distance = if config.rerank_factor.is_some() {
794            match parquet_data.as_ref().and_then(|(_, vecs)| vecs.get(idx)) {
795                Some(v) => exact_distance(metric, query, v),
796                None => {
797                    error!(
798                        "ailake: invariant violated — row_id {} out of bounds \
799                         (file={}); Parquet and HNSW node count out of sync; \
800                         run compaction to rebuild",
801                        idx, file_entry.path
802                    );
803                    f32::INFINITY
804                }
805            }
806        } else {
807            approx_dist
808        };
809
810        if use_hybrid {
811            let text = parquet_data.as_ref().map_or(String::new(), |(batch, _)| {
812                extract_text_for_row(batch, idx, config.hybrid.as_ref().unwrap())
813            });
814            outcome
815                .candidates
816                .push((row_id, distance, file_entry.path.clone(), text));
817        } else {
818            let final_score = if let Some((ref batch, _)) = parquet_data {
819                apply_score_fn(&config.score_fn, distance, row_id, batch)
820            } else {
821                distance
822            };
823            outcome.results.push(SearchResult {
824                row_id,
825                distance: final_score,
826                file_path: file_entry.path.clone(),
827            });
828        }
829    }
830
831    Ok(outcome)
832}
833
834/// Extract concatenated text from specified columns for a single row.
835fn extract_text_for_row(
836    batch: &RecordBatch,
837    row_idx: usize,
838    hybrid: &crate::bm25::HybridConfig,
839) -> String {
840    use arrow_array::cast::AsArray;
841    hybrid
842        .text_columns
843        .iter()
844        .filter_map(|col| {
845            batch.column_by_name(col).and_then(|arr| {
846                arr.as_string_opt::<i32>().and_then(|sa| {
847                    if row_idx < sa.len() && sa.is_valid(row_idx) {
848                        Some(sa.value(row_idx).to_string())
849                    } else {
850                        None
851                    }
852                })
853            })
854        })
855        .collect::<Vec<_>>()
856        .join(" ")
857}
858
859/// One query arm in a cross-modal search.
860#[derive(Debug, Clone)]
861pub struct ModalQuery<'a> {
862    /// Vector column to search (must exist in the table).
863    pub column: &'a str,
864    /// Query vector for this modality.
865    pub query: &'a [f32],
866    /// Relative weight applied in the RRF formula: `weight / (k + rank)`.
867    /// `1.0` means equal weight across all modalities.
868    pub weight: f32,
869    /// Dimensionality of this column's vectors. `0` = auto-detect from table metadata
870    /// (`ailake.dim-<column>` for secondary columns, `ailake.vector-dim` for primary).
871    pub dim: u32,
872}
873
874/// Fusion method for combining results from multiple vector columns.
875#[derive(Debug, Clone, Copy, PartialEq, Eq)]
876pub enum FusionMethod {
877    /// Reciprocal Rank Fusion: `score(d) = Σ weight_i / (k + rank_i(d))`.
878    /// `k = 60` (standard). Returned `SearchResult.distance` = `-rrf_score`
879    /// so that sort-ascending-by-distance gives the correct RRF ranking.
880    Rrf,
881}
882
883/// Cross-modal search: run independent HNSW searches across N vector columns,
884/// then fuse per-column ranked lists using Reciprocal Rank Fusion.
885///
886/// Each `ModalQuery` specifies a column name, its query vector, RRF weight, and dim.
887/// When `ModalQuery.dim == 0`, the dim is auto-detected from `ailake.dim-<col>` /
888/// `ailake.vector-dim` in table metadata.
889/// Results are de-duplicated by `(file_path, row_id)` and ranked by aggregate
890/// RRF score. `SearchResult.distance` stores `-rrf_score` (lower = better) so
891/// existing sort-ascending callers get the correct ordering.
892pub async fn search_multimodal(
893    table: &TableIdent,
894    queries: &[ModalQuery<'_>],
895    config: SearchConfig,
896    catalog: Arc<dyn CatalogProvider>,
897    store: Arc<dyn Store>,
898    fusion: FusionMethod,
899) -> AilakeResult<Vec<SearchResult>> {
900    use std::collections::HashMap;
901
902    if queries.is_empty() {
903        return Err(AilakeError::InvalidArgument(
904            "search_multimodal requires at least one ModalQuery".into(),
905        ));
906    }
907    if config.top_k > ailake_core::MAX_TOP_K {
908        return Err(AilakeError::InvalidArgument(format!(
909            "top_k {} exceeds maximum supported value ({})",
910            config.top_k,
911            ailake_core::MAX_TOP_K
912        )));
913    }
914
915    // Load table metadata once for dim auto-detection and metric resolution.
916    let table_meta = catalog.load_table(table).await?;
917    let primary_col = table_meta
918        .properties
919        .get("ailake.vector-column")
920        .cloned()
921        .unwrap_or_default();
922    let primary_dim: u32 = table_meta
923        .properties
924        .get("ailake.vector-dim")
925        .and_then(|s| s.parse().ok())
926        .unwrap_or(0);
927
928    // Fetch more candidates per column so RRF has enough to fuse.
929    let per_col_k = (config.top_k * queries.len().max(2)).min(1000);
930
931    let mut per_col_results: Vec<(f32, Vec<SearchResult>)> = Vec::with_capacity(queries.len());
932    for mq in queries {
933        // Resolve dim: caller-supplied > per-column property > primary column dim.
934        let resolved_dim = if mq.dim > 0 {
935            mq.dim
936        } else if mq.column == primary_col {
937            primary_dim
938        } else {
939            table_meta
940                .properties
941                .get(&format!("ailake.dim-{}", mq.column))
942                .and_then(|s| s.parse().ok())
943                .unwrap_or(mq.query.len() as u32)
944        };
945
946        let col_config = SearchConfig {
947            top_k: per_col_k,
948            ef_search: config.ef_search,
949            pruning_threshold: config.pruning_threshold,
950            rerank_factor: config.rerank_factor,
951            score_fn: None,
952            partition_filter: config.partition_filter.clone(),
953            hybrid: None,
954            column_filter: config.column_filter.clone(),
955        };
956        let results = search(
957            table,
958            mq.query,
959            col_config,
960            mq.column,
961            resolved_dim,
962            catalog.clone(),
963            store.clone(),
964        )
965        .await?;
966        per_col_results.push((mq.weight, results));
967    }
968
969    // RRF fusion: accumulate score per (file_path, row_id).
970    const K: f32 = 60.0;
971    let mut scores: HashMap<(String, u64), f32> = HashMap::new();
972
973    for (weight, results) in &per_col_results {
974        for (rank, r) in results.iter().enumerate() {
975            let key = (r.file_path.clone(), r.row_id.as_u64());
976            let rrf = weight / (K + rank as f32 + 1.0);
977            *scores.entry(key).or_insert(0.0) += rrf;
978        }
979    }
980
981    // Build SearchResult list sorted by descending RRF score.
982    // Store `-rrf_score` as `.distance` so callers sorting ascending get correct order.
983    let all_files = catalog.list_files(table, None).await?;
984    let _ = all_files; // centroid not needed for fusion — just need file_path+row_id
985
986    // Collect unique candidates: prefer the row's appearance in the first column's results.
987    let mut seen: HashMap<(String, u64), f32> = HashMap::new();
988    for (_, results) in &per_col_results {
989        for r in results {
990            let key = (r.file_path.clone(), r.row_id.as_u64());
991            let rrf_score = *scores.get(&key).unwrap_or(&0.0);
992            seen.entry(key).or_insert(rrf_score);
993        }
994    }
995
996    let mut fused: Vec<SearchResult> = seen
997        .into_iter()
998        .map(|((file_path, row_id_u64), rrf_score)| SearchResult {
999            row_id: RowId::new(row_id_u64),
1000            distance: -rrf_score,
1001            file_path,
1002        })
1003        .collect();
1004
1005    fused.sort_by(|a, b| {
1006        a.distance
1007            .partial_cmp(&b.distance)
1008            .unwrap_or(std::cmp::Ordering::Equal)
1009    });
1010    fused.truncate(config.top_k);
1011
1012    let _ = fusion; // only RRF implemented; enum is extensible
1013
1014    Ok(fused)
1015}
1016
1017/// Apply `score_fn` to a single result row, or return `distance` unchanged.
1018///
1019/// Slices the batch to a 1-row RecordBatch at `row_id` and calls the fn.
1020/// If `score_fn` is `None` or the row index is out of bounds, returns `distance`.
1021#[inline]
1022fn apply_score_fn(
1023    score_fn: &Option<ScoreFn>,
1024    distance: f32,
1025    row_id: RowId,
1026    batch: &RecordBatch,
1027) -> f32 {
1028    match score_fn {
1029        None => distance,
1030        Some(f) => {
1031            let idx = row_id.as_u64() as usize;
1032            if idx < batch.num_rows() {
1033                f.call(distance, &batch.slice(idx, 1))
1034            } else {
1035                distance
1036            }
1037        }
1038    }
1039}
1040
1041/// Brute-force top-k search over raw vectors. Used for Indexing shards.
1042fn flat_search(
1043    raw: &[Vec<f32>],
1044    query: &[f32],
1045    top_k: usize,
1046    metric: VectorMetric,
1047) -> Vec<(RowId, f32)> {
1048    let mut results: Vec<(RowId, f32)> = raw
1049        .iter()
1050        .enumerate()
1051        .map(|(i, v)| (RowId::new(i as u64), exact_distance(metric, query, v)))
1052        .collect();
1053    results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1054    results.truncate(top_k);
1055    results
1056}
1057
1058fn parse_metric(s: &str) -> VectorMetric {
1059    match s {
1060        "euclidean" => VectorMetric::Euclidean,
1061        "dotproduct" | "dot_product" | "dot" => VectorMetric::DotProduct,
1062        "normalized_cosine" | "normalizedcosine" => VectorMetric::NormalizedCosine,
1063        _ => VectorMetric::Cosine,
1064    }
1065}
1066
1067/// Pre-loaded search session: all HNSW indexes loaded into memory once.
1068///
1069/// Useful for benchmarks and servers that issue many queries against the same
1070/// snapshot. Avoids re-loading and re-deserializing indexes on every call.
1071///
1072/// **Deleted rows are NOT filtered here**: unlike [`search`]/[`search_text`],
1073/// this session does not load deletion vectors or equality delete files —
1074/// rows removed via `delete_rows`/`delete_where` still appear in results.
1075/// Use [`search`] when delete visibility matters; this type trades that for
1076/// raw throughput on static snapshots (its benchmark use case).
1077pub struct SearchSession {
1078    shards: Vec<LoadedShard>,
1079    metric: VectorMetric,
1080}
1081
1082struct LoadedShard {
1083    entry: DataFileEntry,
1084    /// None when the shard is still being indexed (IndexStatus::Indexing).
1085    index: Option<AnyIndex>,
1086    /// Raw F32 vectors: always present for Indexing shards (flat scan), optionally
1087    /// present for Ready shards when `load_raw = true` (reranking).
1088    raw_vectors: Option<Vec<Vec<f32>>>,
1089}
1090
1091impl SearchSession {
1092    /// Load all indexes for the latest snapshot into memory.
1093    ///
1094    /// Pass `load_raw = true` when reranking will be used (`rerank_factor` is
1095    /// `Some`); it reads the full parquet columns so exact distances are
1096    /// available without extra I/O during `search_query`.
1097    pub async fn load(
1098        table: &TableIdent,
1099        vector_column: &str,
1100        dim: u32,
1101        catalog: Arc<dyn CatalogProvider>,
1102        store: Arc<dyn Store>,
1103        load_raw: bool,
1104    ) -> AilakeResult<Self> {
1105        let all_files = catalog.list_files(table, None).await?;
1106        let table_meta = catalog.load_table(table).await?;
1107        let metric = parse_metric(
1108            table_meta
1109                .properties
1110                .get("ailake.vector-metric")
1111                .map(String::as_str)
1112                .unwrap_or("cosine"),
1113        );
1114
1115        let mut shards = Vec::with_capacity(all_files.len());
1116        for entry in all_files {
1117            let file_bytes: Bytes = store.get(&entry.path).await?;
1118            let reader = AilakeFileReader::new(file_bytes, vector_column, dim);
1119
1120            if entry.index_status == IndexStatus::Indexing {
1121                // HNSW not yet built — load raw vectors for flat scan.
1122                let (_, raw_vecs) = reader.read_parquet()?;
1123                shards.push(LoadedShard {
1124                    entry,
1125                    index: None,
1126                    raw_vectors: Some(raw_vecs),
1127                });
1128            } else if reader.is_ailake_file() {
1129                let mut index = reader.load_any_index_for_column(vector_column)?;
1130                let raw_vectors = if load_raw {
1131                    index.quantize_to_f16();
1132                    let (_, vecs) = reader.read_parquet()?;
1133                    Some(vecs)
1134                } else {
1135                    None
1136                };
1137                shards.push(LoadedShard {
1138                    entry,
1139                    index: Some(index),
1140                    raw_vectors,
1141                });
1142            }
1143        }
1144
1145        Ok(Self { shards, metric })
1146    }
1147
1148    /// Number of loaded shards.
1149    pub fn shard_count(&self) -> usize {
1150        self.shards.len()
1151    }
1152
1153    /// Search multiple queries in one call.
1154    ///
1155    /// For shards with raw vectors (Indexing or reranking): dispatches to GPU batch
1156    /// matmul when a CUDA device is available, falling back to CPU flat scan.
1157    /// For indexed shards (HNSW / IVF-PQ): rayon parallel-map over queries — graph
1158    /// traversal is inherently sequential and has no GPU batch path.
1159    ///
1160    /// Returns one `Vec<SearchResult>` per input query, in the same order.
1161    pub fn search_batch(
1162        &self,
1163        queries: &[Vec<f32>],
1164        config: &SearchConfig,
1165    ) -> Vec<Vec<SearchResult>> {
1166        if queries.is_empty() {
1167            return vec![];
1168        }
1169
1170        let n_queries = queries.len();
1171        let candidate_k = match config.rerank_factor {
1172            Some(factor) => config.top_k * factor,
1173            None => config.top_k,
1174        };
1175        let use_nvidia = ailake_index::hardware::detect_cuda();
1176        let use_amd = ailake_index::hardware::detect_rocm();
1177
1178        // Accumulate per-query results across all shards.
1179        let mut all_results: Vec<Vec<SearchResult>> = (0..n_queries).map(|_| Vec::new()).collect();
1180
1181        for shard in &self.shards {
1182            if let Some(raw) = &shard.raw_vectors {
1183                // Flat-scan shard — try GPU batch path (NVIDIA first, then AMD ROCm).
1184                if !raw.is_empty() {
1185                    let dim = raw[0].len();
1186                    let flat: Vec<f32> = raw.iter().flat_map(|v| v.iter().copied()).collect();
1187                    let row_ids: Vec<u64> = (0..raw.len() as u64).collect();
1188                    let q_refs: Vec<&[f32]> = queries.iter().map(|q| q.as_slice()).collect();
1189
1190                    let gpu_batch = if use_nvidia {
1191                        ailake_index::gpu::try_nvidia_search_batch(
1192                            &q_refs,
1193                            &row_ids,
1194                            &flat,
1195                            dim,
1196                            self.metric,
1197                            candidate_k,
1198                        )
1199                    } else if use_amd {
1200                        ailake_index::gpu::try_rocm_search_batch(
1201                            &q_refs,
1202                            &row_ids,
1203                            &flat,
1204                            dim,
1205                            self.metric,
1206                            candidate_k,
1207                        )
1208                    } else {
1209                        None
1210                    };
1211
1212                    if let Some(batch) = gpu_batch {
1213                        for (qi, results) in batch.into_iter().enumerate() {
1214                            for (row_id, distance) in results {
1215                                all_results[qi].push(SearchResult {
1216                                    row_id,
1217                                    distance,
1218                                    file_path: shard.entry.path.clone(),
1219                                });
1220                            }
1221                        }
1222                        continue;
1223                    }
1224                }
1225
1226                // CPU fallback for flat scan.
1227                for (qi, query) in queries.iter().enumerate() {
1228                    for (row_id, distance) in flat_search(raw, query, candidate_k, self.metric) {
1229                        all_results[qi].push(SearchResult {
1230                            row_id,
1231                            distance,
1232                            file_path: shard.entry.path.clone(),
1233                        });
1234                    }
1235                }
1236            } else if let Some(index) = &shard.index {
1237                // Indexed shard — rayon parallel-map over queries.
1238                let shard_results: Vec<Vec<SearchResult>> = queries
1239                    .par_iter()
1240                    .map(|query| {
1241                        index
1242                            .search(query, candidate_k, config.ef_search)
1243                            .into_iter()
1244                            .map(|(row_id, distance)| SearchResult {
1245                                row_id,
1246                                distance,
1247                                file_path: shard.entry.path.clone(),
1248                            })
1249                            .collect()
1250                    })
1251                    .collect();
1252
1253                for (qi, results) in shard_results.into_iter().enumerate() {
1254                    all_results[qi].extend(results);
1255                }
1256            }
1257        }
1258
1259        // Sort + truncate per query.
1260        for results in &mut all_results {
1261            results.sort_by(|a, b| {
1262                a.distance
1263                    .partial_cmp(&b.distance)
1264                    .unwrap_or(std::cmp::Ordering::Equal)
1265            });
1266            results.truncate(config.top_k);
1267        }
1268
1269        all_results
1270    }
1271
1272    /// Search using pre-loaded indexes. No I/O — pure in-memory search.
1273    pub fn search_query(&self, query: &[f32], config: &SearchConfig) -> Vec<SearchResult> {
1274        let candidate_k = match config.rerank_factor {
1275            Some(factor) => config.top_k * factor,
1276            None => config.top_k,
1277        };
1278
1279        let mut all_results: Vec<SearchResult> = self
1280            .shards
1281            .par_iter()
1282            .flat_map(|shard| {
1283                // Geometric pruning per shard.
1284                if let Some(centroid) = ailake_catalog::decode_centroid(&shard.entry, self.metric) {
1285                    let dist = match self.metric {
1286                        VectorMetric::Cosine | VectorMetric::NormalizedCosine => {
1287                            ailake_vec::cosine_distance(query, &centroid.values)
1288                        }
1289                        VectorMetric::Euclidean => {
1290                            ailake_vec::euclidean_distance(query, &centroid.values)
1291                        }
1292                        VectorMetric::DotProduct => {
1293                            -ailake_vec::dot_product(query, &centroid.values)
1294                        }
1295                    };
1296                    if dist - centroid.radius > config.pruning_threshold {
1297                        return vec![];
1298                    }
1299                }
1300
1301                if let Some(index) = &shard.index {
1302                    // Ready shard: HNSW or IVF-PQ search (dispatched by AnyIndex).
1303                    let local_results = index.search(query, candidate_k, config.ef_search);
1304                    if config.rerank_factor.is_some() {
1305                        if let Some(raw) = &shard.raw_vectors {
1306                            local_results
1307                                .into_iter()
1308                                .map(|(row_id, _approx_dist)| {
1309                                    let idx = row_id.as_u64() as usize;
1310                                    let exact_dist = raw
1311                                        .get(idx)
1312                                        .map(|v| exact_distance(self.metric, query, v))
1313                                        .unwrap_or(f32::INFINITY);
1314                                    SearchResult {
1315                                        row_id,
1316                                        distance: exact_dist,
1317                                        file_path: shard.entry.path.clone(),
1318                                    }
1319                                })
1320                                .collect()
1321                        } else {
1322                            local_results
1323                                .into_iter()
1324                                .map(|(row_id, distance)| SearchResult {
1325                                    row_id,
1326                                    distance,
1327                                    file_path: shard.entry.path.clone(),
1328                                })
1329                                .collect()
1330                        }
1331                    } else {
1332                        local_results
1333                            .into_iter()
1334                            .map(|(row_id, distance)| SearchResult {
1335                                row_id,
1336                                distance,
1337                                file_path: shard.entry.path.clone(),
1338                            })
1339                            .collect()
1340                    }
1341                } else if let Some(raw) = &shard.raw_vectors {
1342                    // Indexing shard: exact flat scan.
1343                    flat_search(raw, query, candidate_k, self.metric)
1344                        .into_iter()
1345                        .map(|(row_id, distance)| SearchResult {
1346                            row_id,
1347                            distance,
1348                            file_path: shard.entry.path.clone(),
1349                        })
1350                        .collect()
1351                } else {
1352                    vec![]
1353                }
1354            })
1355            .collect();
1356
1357        all_results.sort_by(|a, b| {
1358            a.distance
1359                .partial_cmp(&b.distance)
1360                .unwrap_or(std::cmp::Ordering::Equal)
1361        });
1362        all_results.truncate(config.top_k);
1363        all_results
1364    }
1365}
1366
1367/// Pure BM25 full-text search across all Parquet files in the table.
1368///
1369/// Scans every surviving file (O(N) complexity), scores each row with BM25 against
1370/// `query_text`, and returns the global top-k by score. IDF stats are loaded from
1371/// `metadata/ailake_bm25_stats.bin` (written by `TableWriter` when `bm25_text_column`
1372/// is configured). If the stats file is absent, IDF defaults to an empty corpus
1373/// (all terms treated as maximally rare — directionally correct but less precise).
1374///
1375/// For pure-lexical search at scale (millions of rows, hundreds of files), consider
1376/// using SQL `LIKE` / `ILIKE` via DuckDB/Trino over the Iceberg-compatible table.
1377/// This function is best suited for small-medium tables or as a lexical complement
1378/// to `search()` for tables where the document count per file is manageable.
1379pub async fn search_text(
1380    table: &TableIdent,
1381    query_text: &str,
1382    text_columns: &[&str],
1383    top_k: usize,
1384    catalog: Arc<dyn CatalogProvider>,
1385    store: Arc<dyn Store>,
1386    partition_filter: Option<&str>,
1387) -> AilakeResult<Vec<SearchResult>> {
1388    use arrow_array::cast::AsArray;
1389
1390    if text_columns.is_empty() {
1391        return Err(AilakeError::InvalidArgument(
1392            "search_text requires at least one text column".into(),
1393        ));
1394    }
1395    if top_k > ailake_core::MAX_TOP_K {
1396        return Err(AilakeError::InvalidArgument(format!(
1397            "top_k {top_k} exceeds maximum supported value ({})",
1398            ailake_core::MAX_TOP_K
1399        )));
1400    }
1401
1402    let all_files = catalog.list_files(table, None).await?;
1403    let table_meta = catalog.load_table(table).await?;
1404
1405    // Partition pruning
1406    let files: Vec<_> = if let Some(pv) = partition_filter {
1407        all_files
1408            .into_iter()
1409            .filter(|f| f.partition_value.as_deref() == Some(pv))
1410            .collect()
1411    } else {
1412        all_files
1413    };
1414
1415    // Load BM25 stats
1416    let stats_path = table_meta
1417        .properties
1418        .get(crate::bm25::BM25_STATS_PATH_PROP)
1419        .map(String::as_str)
1420        .unwrap_or(crate::bm25::BM25_STATS_FILE);
1421    let stats = match store.get(stats_path).await {
1422        Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).unwrap_or_default(),
1423        Err(_) => {
1424            debug!(
1425                "ailake: BM25 stats not found at '{}' — using empty corpus IDF",
1426                stats_path
1427            );
1428            crate::bm25::IdfStats::default()
1429        }
1430    };
1431    let scorer = crate::bm25::BM25Scorer::new(&stats);
1432
1433    // Phase H: equality delete filter for search_text results.
1434    let eq_del_filter = match catalog.list_equality_deletes(table, None).await {
1435        Ok(edfs) if !edfs.is_empty() => {
1436            match EqualityDeleteFilter::from_files(&store, &edfs).await {
1437                Ok(f) => f,
1438                Err(e) => {
1439                    warn!("ailake: equality delete filter build failed in search_text: {e}");
1440                    EqualityDeleteFilter::empty()
1441                }
1442            }
1443        }
1444        _ => EqualityDeleteFilter::empty(),
1445    };
1446
1447    let mut results: Vec<SearchResult> = Vec::new();
1448
1449    for file_entry in &files {
1450        let file_bytes = store.get(&file_entry.path).await?;
1451        // Use dim=0 — we only read the Parquet columns, not the HNSW.
1452        let reader = AilakeFileReader::new(file_bytes.clone(), "", 0);
1453
1454        // Fast path: per-file Tantivy index (O(log N) via inverted index).
1455        // Falls back to BM25 O(N) brute-force for files without an FTS section.
1456        if let Ok(Some(fts_blob)) = reader.load_fts_blob() {
1457            match ailake_fts::FtsSearcher::from_blob(&fts_blob) {
1458                Ok(fts) => {
1459                    let hits = fts.search(query_text, top_k * 3).unwrap_or_default();
1460                    if !hits.is_empty() {
1461                        // Load batch only for equality delete checking (not for scoring).
1462                        let reader2 = AilakeFileReader::new(file_bytes, "", 0);
1463                        let (raw_batch, _) = reader2.read_parquet()?;
1464                        let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
1465                        for hit in hits {
1466                            let row_idx = hit.row_id as usize;
1467                            if row_idx >= batch.num_rows() {
1468                                continue;
1469                            }
1470                            if eq_del_filter.should_delete_row(
1471                                &batch,
1472                                row_idx,
1473                                file_entry.sequence_number,
1474                            ) {
1475                                continue;
1476                            }
1477                            results.push(SearchResult {
1478                                row_id: RowId::new(hit.row_id),
1479                                distance: -hit.score,
1480                                file_path: file_entry.path.clone(),
1481                            });
1482                        }
1483                    }
1484                    continue; // skip O(N) BM25 fallback
1485                }
1486                Err(e) => {
1487                    warn!("ailake: FTS blob corrupt for '{}': {e}", file_entry.path);
1488                    // fall through to BM25 brute-force
1489                }
1490            }
1491        }
1492
1493        // Fallback: O(N) BM25 brute-force — unchanged from pre-Phase-T behaviour.
1494        let reader_fb = AilakeFileReader::new(file_bytes, "", 0);
1495        let (raw_batch, _) = reader_fb.read_parquet()?;
1496        // Phase G: fill missing columns for old files before BM25 text extraction.
1497        let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
1498
1499        for row_idx in 0..batch.num_rows() {
1500            // Phase H: skip rows matched by equality delete predicate.
1501            if eq_del_filter.should_delete_row(&batch, row_idx, file_entry.sequence_number) {
1502                continue;
1503            }
1504            let doc_text: String = text_columns
1505                .iter()
1506                .filter_map(|&col| {
1507                    batch.column_by_name(col).and_then(|arr| {
1508                        arr.as_string_opt::<i32>().and_then(|sa| {
1509                            if sa.is_valid(row_idx) {
1510                                Some(sa.value(row_idx).to_string())
1511                            } else {
1512                                None
1513                            }
1514                        })
1515                    })
1516                })
1517                .collect::<Vec<_>>()
1518                .join(" ");
1519
1520            if doc_text.is_empty() {
1521                continue;
1522            }
1523
1524            let bm25 = scorer.score(query_text, &doc_text);
1525            if bm25 > 0.0 {
1526                // Negate so that sort-ascending = best-first (lower distance = higher BM25).
1527                results.push(SearchResult {
1528                    row_id: RowId::new(row_idx as u64),
1529                    distance: -bm25,
1530                    file_path: file_entry.path.clone(),
1531                });
1532            }
1533        }
1534    }
1535
1536    results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
1537    results.truncate(top_k);
1538    Ok(results)
1539}
1540
1541/// Fetch full row data for a slice of search results.
1542///
1543/// Groups results by Parquet file, reads each file once, extracts the matching rows
1544/// via `arrow_select::take`, then concatenates everything back in original top-k order
1545/// with a `_distance: Float32` column appended.
1546///
1547/// Use this immediately after `search()` to retrieve the actual text / metadata
1548/// columns (e.g. `chunk_text`, `document_title`) alongside the distance scores.
1549pub async fn fetch_rows(
1550    results: &[SearchResult],
1551    store: Arc<dyn Store>,
1552    vector_column: &str,
1553    dim: u32,
1554    schema_fields: &[SchemaField],
1555) -> AilakeResult<RecordBatch> {
1556    use std::collections::HashMap;
1557
1558    use arrow_array::{ArrayRef, Float32Array, UInt32Array};
1559    use arrow_schema::{DataType, Field, Schema};
1560    use arrow_select::{concat::concat_batches, take::take};
1561
1562    if results.is_empty() {
1563        return Ok(RecordBatch::new_empty(Arc::new(Schema::empty())));
1564    }
1565
1566    // Group by file path; preserve original position for re-sorting.
1567    let mut by_file: HashMap<&str, Vec<(u64, f32, usize)>> = HashMap::new();
1568    for (i, r) in results.iter().enumerate() {
1569        by_file
1570            .entry(r.file_path.as_str())
1571            .or_default()
1572            .push((r.row_id.as_u64(), r.distance, i));
1573    }
1574
1575    use arrow_array::FixedSizeListArray;
1576
1577    // `vector_column` is deliberately excluded from the batch AilakeFileReader::read_parquet()
1578    // returns — it's decoded separately into `vectors: Vec<Vec<f32>>` and re-appended below as
1579    // a FixedSizeList<Float32> field. SchemaFiller has no way to know that; left unfiltered it
1580    // treats the vector column as "missing" (it genuinely isn't in the tabular batch) and
1581    // injects a synthetic column for it — wrong type (falls through iceberg_type_to_arrow's
1582    // Utf8 fallback for the vector column's Iceberg type string) and a duplicate field name
1583    // once the real decoded vector column is appended, breaking pandas' arrow->pandas
1584    // conversion (`Unsupported cast from fixed_size_list<...> to large_utf8`). Found writing
1585    // the regression test for the *other* schema-projection bug this function has — filtering
1586    // it out here is required for schema-filling to be correct at all in fetch_rows.
1587    let schema_fields_for_fill: Vec<SchemaField> = schema_fields
1588        .iter()
1589        .filter(|sf| sf.name != vector_column)
1590        .cloned()
1591        .collect();
1592
1593    // (original_index, distance, single-row RecordBatch, decoded F32 vector)
1594    let mut collected: Vec<(usize, f32, RecordBatch, Vec<f32>)> = Vec::with_capacity(results.len());
1595
1596    for (file_path, rows) in &by_file {
1597        let bytes = store.get(file_path).await?;
1598        let reader = AilakeFileReader::new(bytes, vector_column, dim);
1599        let (raw_batch, vectors) = reader.read_parquet()?;
1600        // Project against the table's *current* Iceberg schema — old files written
1601        // before a metadata-only evolve_schema/add_column don't physically have the
1602        // new column. Without this, a file that happens to land first in `collected`
1603        // silently drives `base_schema` below and the new column never appears in the
1604        // response at all (not even as null) — confirmed live via Spark's
1605        // AilakeNative.scan() and ailake.search(fetch_data=True), both of which call
1606        // this function. Same fix SchemaFiller::fill already applies on the
1607        // pointer-search path (search()); this was the one full-row-fetch path it
1608        // never reached.
1609        let batch = SchemaFiller::fill(raw_batch, &schema_fields_for_fill)?;
1610
1611        for &(row_id, distance, pos) in rows {
1612            let idx = row_id as usize;
1613            if idx >= batch.num_rows() {
1614                tracing::warn!(
1615                    "fetch_rows: row_id {} out of bounds (file_rows={}, file={}), skipping",
1616                    idx,
1617                    batch.num_rows(),
1618                    file_path
1619                );
1620                continue;
1621            }
1622
1623            let indices = UInt32Array::from(vec![idx as u32]);
1624            let row_cols: Vec<ArrayRef> = batch
1625                .columns()
1626                .iter()
1627                .map(|col| {
1628                    take(col.as_ref(), &indices, None)
1629                        .map_err(|e| AilakeError::Arrow(e.to_string()))
1630                })
1631                .collect::<AilakeResult<Vec<_>>>()?;
1632
1633            let row_batch = RecordBatch::try_new(batch.schema(), row_cols)
1634                .map_err(|e| AilakeError::Arrow(e.to_string()))?;
1635
1636            // Capture decoded F32 vector for this row (empty vec if not available).
1637            let vec = vectors
1638                .get(idx)
1639                .cloned()
1640                .unwrap_or_else(|| vec![0.0f32; dim as usize]);
1641
1642            collected.push((pos, distance, row_batch, vec));
1643        }
1644    }
1645
1646    if collected.is_empty() {
1647        return Ok(RecordBatch::new_empty(Arc::new(Schema::empty())));
1648    }
1649
1650    // Restore original top-k order from the search results slice.
1651    collected.sort_by_key(|(pos, _, _, _)| *pos);
1652
1653    let distances: Vec<f32> = collected.iter().map(|(_, d, _, _)| *d).collect();
1654    let row_batches: Vec<&RecordBatch> = collected.iter().map(|(_, _, b, _)| b).collect();
1655    let base_schema = collected[0].2.schema();
1656
1657    let combined =
1658        concat_batches(&base_schema, row_batches).map_err(|e| AilakeError::Arrow(e.to_string()))?;
1659
1660    // Build FixedSizeList<Float32> column with decoded vectors (F32, not raw F16 bytes).
1661    let flat_vecs: Vec<f32> = collected
1662        .iter()
1663        .flat_map(|(_, _, _, v)| v.iter().copied())
1664        .collect();
1665    let item_field = Arc::new(Field::new("item", DataType::Float32, false));
1666    let values_arr = Arc::new(Float32Array::from(flat_vecs)) as ArrayRef;
1667    let vec_col = FixedSizeListArray::new(item_field.clone(), dim as i32, values_arr, None);
1668    let vec_field = Arc::new(Field::new(
1669        vector_column,
1670        DataType::FixedSizeList(item_field, dim as i32),
1671        false,
1672    ));
1673
1674    // Schema: tabular cols, then decoded vector col, then _distance.
1675    let mut fields: Vec<Arc<Field>> = base_schema.fields().to_vec();
1676    fields.push(vec_field);
1677    fields.push(Arc::new(Field::new("_distance", DataType::Float32, false)));
1678    let new_schema = Arc::new(Schema::new(fields));
1679
1680    let mut columns: Vec<ArrayRef> = combined.columns().to_vec();
1681    columns.push(Arc::new(vec_col));
1682    columns.push(Arc::new(Float32Array::from(distances)));
1683
1684    RecordBatch::try_new(new_schema, columns).map_err(|e| AilakeError::Arrow(e.to_string()))
1685}
1686
1687/// Load per-file BM25 Bloom filters from the Puffin stats file for the current snapshot.
1688///
1689/// Returns a map of `file_path → BloomFilter`. Empty map = no stats file available
1690/// (V2 table, first write, or fetch failure). The scanner applies Bloom pruning only
1691/// when the map is non-empty.
1692async fn load_bloom_map(
1693    table_meta: &ailake_catalog::TableMetadata,
1694    store: &dyn Store,
1695) -> std::collections::HashMap<String, crate::bloom::BloomFilter> {
1696    let stats_path = match &table_meta.current_statistics_path {
1697        Some(p) => p.clone(),
1698        None => return std::collections::HashMap::new(),
1699    };
1700    let bytes = match store.get(&stats_path).await {
1701        Ok(b) => b,
1702        Err(e) => {
1703            debug!("ailake: Phase F — could not load Puffin stats ({stats_path}): {e}");
1704            return std::collections::HashMap::new();
1705        }
1706    };
1707    let reader = ailake_catalog::AilakePuffinReader::new(&bytes);
1708    let bloom_entries = match reader.read_bm25_blooms() {
1709        Ok(e) => e,
1710        Err(e) => {
1711            warn!("ailake: Phase F — Puffin bloom parse error: {e}");
1712            return std::collections::HashMap::new();
1713        }
1714    };
1715    bloom_entries
1716        .into_iter()
1717        .filter_map(|entry| {
1718            let bf = crate::bloom::BloomFilter::from_bytes(&entry.bloom_bytes)?;
1719            Some((entry.path, bf))
1720        })
1721        .collect()
1722}
1723
1724#[cfg(test)]
1725mod tests {
1726    use super::*;
1727    use crate::writer::MultiVectorBatch;
1728    use ailake_catalog::{HadoopCatalog, TableIdent};
1729    use ailake_core::{VectorMetric, VectorPrecision, VectorStoragePolicy};
1730    use ailake_store::LocalStore;
1731    use arrow_array::{Int32Array, RecordBatch};
1732    use arrow_schema::{DataType, Field, Schema};
1733    use std::sync::Arc;
1734    use tempfile::TempDir;
1735
1736    fn make_policy(dim: u32) -> VectorStoragePolicy {
1737        VectorStoragePolicy {
1738            column_name: "embedding".to_string(),
1739            dim,
1740            metric: VectorMetric::Cosine,
1741            precision: VectorPrecision::F16,
1742            pq: None,
1743            keep_raw_for_reranking: true,
1744            pre_normalize: false,
1745            hnsw_m: None,
1746            hnsw_ef_construction: None,
1747            ivf_residual: false,
1748            embedding_model: None,
1749            modality: None,
1750            partition_by: None,
1751            partition_value: None,
1752            partition_column_type: None,
1753            partition_fields: vec![],
1754        }
1755    }
1756
1757    async fn write_demo_table(dir: &TempDir, dim: usize, rows: usize) {
1758        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1759        let catalog = Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1760        let table = TableIdent::new("default", "table");
1761
1762        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1763        let ids: Vec<i32> = (0..rows as i32).collect();
1764        let batch = RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
1765
1766        // Each row i has embedding with 1.0 at dimension i and 0 elsewhere (unit basis vectors)
1767        let embeddings: Vec<Vec<f32>> = (0..rows)
1768            .map(|i| {
1769                let mut v = vec![0.0f32; dim];
1770                v[i % dim] = 1.0;
1771                v
1772            })
1773            .collect();
1774
1775        let mut writer =
1776            crate::TableWriter::create_or_open(catalog, store, make_policy(dim as u32), table, 2)
1777                .await
1778                .unwrap();
1779        writer.write_batch(&batch, &embeddings).await.unwrap();
1780        writer.commit().await.unwrap();
1781    }
1782
1783    /// Same shape as `write_demo_table`, plus a `category` column ("even"/"odd"
1784    /// by row id) — lets predicate-pushdown tests assert against a real column
1785    /// while still using the same unit-basis-vector embeddings (row `i`'s
1786    /// nearest neighbor for query `i` is unambiguous).
1787    async fn write_demo_table_with_category(dir: &TempDir, dim: usize, rows: usize) {
1788        use arrow_array::StringArray;
1789        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1790        let catalog = Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1791        let table = TableIdent::new("default", "table");
1792
1793        let schema = Arc::new(Schema::new(vec![
1794            Field::new("id", DataType::Int32, false),
1795            Field::new("category", DataType::Utf8, false),
1796        ]));
1797        let ids: Vec<i32> = (0..rows as i32).collect();
1798        let categories: Vec<&str> = (0..rows)
1799            .map(|i| if i % 2 == 0 { "even" } else { "odd" })
1800            .collect();
1801        let batch = RecordBatch::try_new(
1802            schema,
1803            vec![
1804                Arc::new(Int32Array::from(ids)),
1805                Arc::new(StringArray::from(categories)),
1806            ],
1807        )
1808        .unwrap();
1809
1810        let embeddings: Vec<Vec<f32>> = (0..rows)
1811            .map(|i| {
1812                let mut v = vec![0.0f32; dim];
1813                v[i % dim] = 1.0;
1814                v
1815            })
1816            .collect();
1817
1818        let mut writer =
1819            crate::TableWriter::create_or_open(catalog, store, make_policy(dim as u32), table, 2)
1820                .await
1821                .unwrap();
1822        writer.write_batch(&batch, &embeddings).await.unwrap();
1823        writer.commit().await.unwrap();
1824    }
1825
1826    #[tokio::test]
1827    async fn column_filter_preserves_row_identity_on_indexed_path() {
1828        // The critical correctness property: row_ids returned under a
1829        // column_filter must still be the TRUE file-relative row ids (usable
1830        // to fetch the right row), not positions into some internally
1831        // compacted/filtered batch. Every "even" row's embedding is a unit
1832        // basis vector at its own dimension, so searching with that exact
1833        // query and asserting the returned row_id matches would fail loudly
1834        // if row identity got shifted by the pushdown.
1835        let dir = TempDir::new().unwrap();
1836        let dim = 8usize;
1837        write_demo_table_with_category(&dir, dim, 8).await;
1838
1839        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1840        let catalog: Arc<dyn CatalogProvider> =
1841            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1842        let table = TableIdent::new("default", "table");
1843
1844        // Query matches row 4 exactly ("even" — 4 % 2 == 0).
1845        let mut query = vec![0.0f32; dim];
1846        query[4] = 1.0;
1847
1848        let config = SearchConfig {
1849            top_k: 1,
1850            ef_search: 50,
1851            pruning_threshold: f32::INFINITY,
1852            rerank_factor: None,
1853            score_fn: None,
1854            partition_filter: None,
1855            hybrid: None,
1856            column_filter: Some(ailake_core::ColumnFilter::eq(
1857                "category",
1858                ailake_core::FilterValue::Str("even".to_string()),
1859            )),
1860        };
1861
1862        let results = search(
1863            &table,
1864            &query,
1865            config,
1866            "embedding",
1867            dim as u32,
1868            catalog,
1869            store,
1870        )
1871        .await
1872        .unwrap();
1873
1874        assert_eq!(results.len(), 1);
1875        assert_eq!(
1876            results[0].row_id.as_u64(),
1877            4,
1878            "row_id must be the true file-relative position, not a filtered-batch index"
1879        );
1880    }
1881
1882    #[tokio::test]
1883    async fn column_filter_excludes_non_matching_rows() {
1884        let dir = TempDir::new().unwrap();
1885        let dim = 8usize;
1886        write_demo_table_with_category(&dir, dim, 8).await;
1887
1888        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1889        let catalog: Arc<dyn CatalogProvider> =
1890            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1891        let table = TableIdent::new("default", "table");
1892
1893        // Query matches row 3 exactly ("odd"); filtering for "even" must exclude it.
1894        let mut query = vec![0.0f32; dim];
1895        query[3] = 1.0;
1896
1897        let config = SearchConfig {
1898            top_k: 8,
1899            ef_search: 50,
1900            pruning_threshold: f32::INFINITY,
1901            rerank_factor: None,
1902            score_fn: None,
1903            partition_filter: None,
1904            hybrid: None,
1905            column_filter: Some(ailake_core::ColumnFilter::eq(
1906                "category",
1907                ailake_core::FilterValue::Str("even".to_string()),
1908            )),
1909        };
1910
1911        let results = search(
1912            &table,
1913            &query,
1914            config,
1915            "embedding",
1916            dim as u32,
1917            catalog,
1918            store,
1919        )
1920        .await
1921        .unwrap();
1922
1923        assert_eq!(results.len(), 4, "only the 4 even rows should survive");
1924        for r in &results {
1925            assert_eq!(
1926                r.row_id.as_u64() % 2,
1927                0,
1928                "row {} is not even",
1929                r.row_id.as_u64()
1930            );
1931        }
1932    }
1933
1934    #[tokio::test]
1935    async fn column_filter_no_match_returns_empty() {
1936        let dir = TempDir::new().unwrap();
1937        let dim = 8usize;
1938        write_demo_table_with_category(&dir, dim, 8).await;
1939
1940        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1941        let catalog: Arc<dyn CatalogProvider> =
1942            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1943        let table = TableIdent::new("default", "table");
1944
1945        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1946        let config = SearchConfig {
1947            top_k: 8,
1948            ef_search: 50,
1949            pruning_threshold: f32::INFINITY,
1950            rerank_factor: None,
1951            score_fn: None,
1952            partition_filter: None,
1953            hybrid: None,
1954            column_filter: Some(ailake_core::ColumnFilter::eq(
1955                "category",
1956                ailake_core::FilterValue::Str("nonexistent".to_string()),
1957            )),
1958        };
1959
1960        let results = search(
1961            &table,
1962            &query,
1963            config,
1964            "embedding",
1965            dim as u32,
1966            catalog,
1967            store,
1968        )
1969        .await
1970        .unwrap();
1971        assert!(results.is_empty());
1972    }
1973
1974    /// Wraps a `Store`, recording every `get`/`get_range` call's byte range
1975    /// per path — used to prove the Fase 16 range-GET fast path never
1976    /// touches the row-group tabular/vector data section of a file, not just
1977    /// to assert it doesn't crash. A whole-file `get` is recorded as
1978    /// `0..file_size` (its full extent), since that's what it touches even
1979    /// though the `Store` trait doesn't expose a byte offset for it.
1980    struct CountingStore {
1981        inner: Arc<dyn Store>,
1982        ranges_by_path: std::sync::Mutex<std::collections::HashMap<String, Vec<(u64, u64)>>>,
1983    }
1984
1985    impl CountingStore {
1986        fn new(inner: Arc<dyn Store>) -> Self {
1987            Self {
1988                inner,
1989                ranges_by_path: std::sync::Mutex::new(std::collections::HashMap::new()),
1990            }
1991        }
1992
1993        fn record(&self, path: &str, start: u64, end: u64) {
1994            self.ranges_by_path
1995                .lock()
1996                .unwrap()
1997                .entry(path.to_string())
1998                .or_default()
1999                .push((start, end));
2000        }
2001
2002        /// Lowest byte offset fetched for `path` across every recorded call —
2003        /// the precise, ratio-independent proof that the tabular/vector data
2004        /// section (everything before the AILK section) was never touched.
2005        fn min_offset_for(&self, path: &str) -> Option<u64> {
2006            self.ranges_by_path
2007                .lock()
2008                .unwrap()
2009                .get(path)
2010                .and_then(|ranges| ranges.iter().map(|(s, _)| *s).min())
2011        }
2012    }
2013
2014    #[async_trait::async_trait]
2015    impl Store for CountingStore {
2016        async fn get(&self, path: &str) -> AilakeResult<Bytes> {
2017            let b = self.inner.get(path).await?;
2018            self.record(path, 0, b.len() as u64);
2019            Ok(b)
2020        }
2021        async fn get_range(&self, path: &str, range: std::ops::Range<u64>) -> AilakeResult<Bytes> {
2022            let b = self.inner.get_range(path, range.clone()).await?;
2023            self.record(path, range.start, range.end);
2024            Ok(b)
2025        }
2026        async fn put(&self, path: &str, data: Bytes) -> AilakeResult<()> {
2027            self.inner.put(path, data).await
2028        }
2029        async fn list(&self, prefix: &str) -> AilakeResult<Vec<String>> {
2030            self.inner.list(prefix).await
2031        }
2032        async fn file_size(&self, path: &str) -> AilakeResult<u64> {
2033            self.inner.file_size(path).await
2034        }
2035        async fn exists(&self, path: &str) -> AilakeResult<bool> {
2036            self.inner.exists(path).await
2037        }
2038        async fn delete(&self, path: &str) -> AilakeResult<()> {
2039            self.inner.delete(path).await
2040        }
2041    }
2042
2043    #[tokio::test]
2044    async fn range_get_fast_path_never_touches_tabular_data_section() {
2045        let dir = TempDir::new().unwrap();
2046        let dim = 64usize;
2047        write_demo_table(&dir, dim, 400).await;
2048
2049        let local: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2050        let data_files = local.list("data").await.unwrap();
2051        assert_eq!(
2052            data_files.len(),
2053            1,
2054            "expect a single part file for this test"
2055        );
2056        let data_path = data_files[0].clone();
2057
2058        // Ground truth: the exact tabular/data-section boundary, from a plain
2059        // full-file read+parse — independent of the fast path under test.
2060        let full_bytes = local.get(&data_path).await.unwrap();
2061        let ailk_offset = ailake_file::AilakeFileReader::new(full_bytes, "embedding", dim as u32)
2062            .ailk_offset()
2063            .unwrap();
2064
2065        let counting = Arc::new(CountingStore::new(local.clone()));
2066        let store: Arc<dyn Store> = counting.clone();
2067        let catalog: Arc<dyn CatalogProvider> =
2068            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2069        let table = TableIdent::new("default", "table");
2070
2071        let mut query = vec![0.0f32; dim];
2072        query[3] = 1.0;
2073        let config = SearchConfig {
2074            top_k: 5,
2075            ef_search: 50,
2076            pruning_threshold: f32::INFINITY,
2077            rerank_factor: None,
2078            score_fn: None,
2079            partition_filter: None,
2080            hybrid: None,
2081            column_filter: None,
2082        };
2083
2084        let results = search(
2085            &table,
2086            &query,
2087            config,
2088            "embedding",
2089            dim as u32,
2090            catalog,
2091            store,
2092        )
2093        .await
2094        .unwrap();
2095        assert!(!results.is_empty());
2096
2097        // The precise, ratio-independent guarantee this feature exists for:
2098        // regardless of how large the HNSW blob happens to be relative to the
2099        // tabular data (small synthetic tables can skew that ratio either
2100        // way — see `range_get_fast_path_matches_full_file_path_results` for
2101        // the separate correctness-parity proof), the fast path must never
2102        // fetch a single byte positioned before the AILK section starts.
2103        let min_offset = counting
2104            .min_offset_for(&data_path)
2105            .expect("should have fetched something from the data file");
2106        assert!(
2107            min_offset >= ailk_offset,
2108            "fast path fetched bytes from the tabular/vector data section: \
2109             min_offset={min_offset} < ailk_offset={ailk_offset}"
2110        );
2111    }
2112
2113    #[tokio::test]
2114    async fn range_get_fast_path_matches_full_file_path_results() {
2115        // Forces the full-file fallback via a column_filter that matches
2116        // every row (id >= 0) — same semantic query and identical distances,
2117        // just disqualified from the fast path (config.column_filter.is_some()).
2118        // Comparing the two proves the fast path isn't silently returning
2119        // different (or wrong-row-identity) results.
2120        let dir = TempDir::new().unwrap();
2121        let dim = 16usize;
2122        write_demo_table(&dir, dim, 20).await;
2123
2124        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2125        let catalog: Arc<dyn CatalogProvider> =
2126            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2127        let table = TableIdent::new("default", "table");
2128
2129        let mut query = vec![0.0f32; dim];
2130        query[3] = 1.0;
2131
2132        let fast_config = SearchConfig {
2133            top_k: 5,
2134            ef_search: 50,
2135            pruning_threshold: f32::INFINITY,
2136            rerank_factor: None,
2137            score_fn: None,
2138            partition_filter: None,
2139            hybrid: None,
2140            column_filter: None,
2141        };
2142        let fast_results = search(
2143            &table,
2144            &query,
2145            fast_config,
2146            "embedding",
2147            dim as u32,
2148            catalog.clone(),
2149            store.clone(),
2150        )
2151        .await
2152        .unwrap();
2153
2154        let slow_config = SearchConfig {
2155            top_k: 5,
2156            ef_search: 50,
2157            pruning_threshold: f32::INFINITY,
2158            rerank_factor: None,
2159            score_fn: None,
2160            partition_filter: None,
2161            hybrid: None,
2162            column_filter: Some(ailake_core::ColumnFilter::new(
2163                "id",
2164                ailake_core::FilterOp::Gte,
2165                ailake_core::FilterValue::I64(0),
2166            )),
2167        };
2168        let slow_results = search(
2169            &table,
2170            &query,
2171            slow_config,
2172            "embedding",
2173            dim as u32,
2174            catalog,
2175            store,
2176        )
2177        .await
2178        .unwrap();
2179
2180        assert_eq!(fast_results.len(), slow_results.len());
2181        assert!(!fast_results.is_empty());
2182        for (f, s) in fast_results.iter().zip(slow_results.iter()) {
2183            assert_eq!(f.row_id, s.row_id, "fast/slow path row_id mismatch");
2184            assert!(
2185                (f.distance - s.distance).abs() < 1e-4,
2186                "fast/slow path distance mismatch: {} vs {}",
2187                f.distance,
2188                s.distance
2189            );
2190        }
2191    }
2192
2193    #[tokio::test]
2194    async fn rerank_returns_correct_top_k_count() {
2195        let dir = TempDir::new().unwrap();
2196        let dim = 8usize;
2197        write_demo_table(&dir, dim, 8).await;
2198
2199        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2200        let catalog: Arc<dyn CatalogProvider> =
2201            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2202        let table = TableIdent::new("default", "table");
2203
2204        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2205        let config = SearchConfig {
2206            top_k: 3,
2207            ef_search: 50,
2208            pruning_threshold: f32::INFINITY,
2209            rerank_factor: Some(2),
2210            score_fn: None,
2211            partition_filter: None,
2212            hybrid: None,
2213            column_filter: None,
2214        };
2215
2216        let results = search(
2217            &table,
2218            &query,
2219            config,
2220            "embedding",
2221            dim as u32,
2222            catalog,
2223            store,
2224        )
2225        .await
2226        .unwrap();
2227
2228        assert_eq!(results.len(), 3);
2229    }
2230
2231    #[tokio::test]
2232    async fn rerank_nearest_is_exact_match() {
2233        let dir = TempDir::new().unwrap();
2234        let dim = 8usize;
2235        write_demo_table(&dir, dim, 8).await;
2236
2237        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2238        let catalog: Arc<dyn CatalogProvider> =
2239            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2240        let table = TableIdent::new("default", "table");
2241
2242        // Row 0 has [1,0,0,...] — cosine distance to same query is 0
2243        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2244        let config = SearchConfig {
2245            top_k: 1,
2246            ef_search: 50,
2247            pruning_threshold: f32::INFINITY,
2248            rerank_factor: Some(4),
2249            score_fn: None,
2250            partition_filter: None,
2251            hybrid: None,
2252            column_filter: None,
2253        };
2254
2255        let results = search(
2256            &table,
2257            &query,
2258            config,
2259            "embedding",
2260            dim as u32,
2261            catalog,
2262            store,
2263        )
2264        .await
2265        .unwrap();
2266
2267        assert_eq!(results.len(), 1);
2268        // Exact cosine distance between identical unit vectors is ~0 (F16 rounding allowed)
2269        assert!(
2270            results[0].distance < 1e-3,
2271            "distance was {}",
2272            results[0].distance
2273        );
2274        assert_eq!(results[0].row_id, RowId::new(0));
2275    }
2276
2277    #[tokio::test]
2278    async fn no_rerank_matches_default_behavior() {
2279        let dir = TempDir::new().unwrap();
2280        let dim = 4usize;
2281        write_demo_table(&dir, dim, 4).await;
2282
2283        let store_a: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2284        let store_b: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2285        let cat_a: Arc<dyn CatalogProvider> =
2286            Arc::new(HadoopCatalog::new(store_a.clone(), "warehouse"));
2287        let cat_b: Arc<dyn CatalogProvider> =
2288            Arc::new(HadoopCatalog::new(store_b.clone(), "warehouse"));
2289        let table = TableIdent::new("default", "table");
2290
2291        let query = vec![1.0f32, 0.0, 0.0, 0.0];
2292        let cfg_plain = SearchConfig {
2293            top_k: 2,
2294            ef_search: 50,
2295            pruning_threshold: f32::INFINITY,
2296            rerank_factor: None,
2297            score_fn: None,
2298            partition_filter: None,
2299            hybrid: None,
2300            column_filter: None,
2301        };
2302        let cfg_rerank = SearchConfig {
2303            top_k: 2,
2304            ef_search: 50,
2305            pruning_threshold: f32::INFINITY,
2306            rerank_factor: Some(2),
2307            score_fn: None,
2308            partition_filter: None,
2309            hybrid: None,
2310            column_filter: None,
2311        };
2312
2313        let plain = search(
2314            &table,
2315            &query,
2316            cfg_plain,
2317            "embedding",
2318            dim as u32,
2319            cat_a,
2320            store_a,
2321        )
2322        .await
2323        .unwrap();
2324        let reranked = search(
2325            &table,
2326            &query,
2327            cfg_rerank,
2328            "embedding",
2329            dim as u32,
2330            cat_b,
2331            store_b,
2332        )
2333        .await
2334        .unwrap();
2335
2336        // Both should return same top-1 result (row 0, distance ~0)
2337        assert_eq!(plain[0].row_id, reranked[0].row_id);
2338    }
2339
2340    #[tokio::test]
2341    async fn multimodal_rrf_returns_top_k() {
2342        let dir = TempDir::new().unwrap();
2343        let dim = 4usize;
2344        write_demo_table(&dir, dim, 4).await;
2345
2346        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2347        let catalog: Arc<dyn CatalogProvider> =
2348            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2349        let table = TableIdent::new("default", "table");
2350
2351        // Two modal queries using the same column (single-column table).
2352        // Different queries to exercise RRF merging.
2353        let q1 = vec![1.0f32, 0.0, 0.0, 0.0];
2354        let q2 = vec![0.0f32, 1.0, 0.0, 0.0];
2355
2356        let queries = vec![
2357            ModalQuery {
2358                column: "embedding",
2359                query: &q1,
2360                weight: 0.7,
2361                dim: dim as u32,
2362            },
2363            ModalQuery {
2364                column: "embedding",
2365                query: &q2,
2366                weight: 0.3,
2367                dim: dim as u32,
2368            },
2369        ];
2370
2371        let config = SearchConfig {
2372            top_k: 2,
2373            ef_search: 50,
2374            pruning_threshold: f32::INFINITY,
2375            rerank_factor: None,
2376            score_fn: None,
2377            partition_filter: None,
2378            hybrid: None,
2379            column_filter: None,
2380        };
2381
2382        let results =
2383            search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf)
2384                .await
2385                .unwrap();
2386
2387        assert_eq!(results.len(), 2);
2388        // RRF score stored as -distance; all should be negative
2389        assert!(results[0].distance <= 0.0);
2390        // Top result should be one of rows 0 or 1 (nearest to q1 or q2)
2391        assert!(results[0].row_id.as_u64() < 4);
2392    }
2393
2394    /// True cross-modal test: two columns with DIFFERENT dims (4 + 2).
2395    /// Verifies that search_multimodal correctly routes to each column's HNSW
2396    /// and that the dim validation in search() handles secondary columns.
2397    #[tokio::test]
2398    async fn multimodal_rrf_cross_modal_different_dims() {
2399        let dir = TempDir::new().unwrap();
2400        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2401        let catalog: Arc<dyn CatalogProvider> =
2402            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2403        let table = TableIdent::new("default", "table");
2404
2405        // Write a 2-column table: "embedding" dim=4, "img_embedding" dim=2
2406        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
2407        let rows = 4usize;
2408        let ids: Vec<i32> = (0..rows as i32).collect();
2409        let batch = RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
2410
2411        let text_embs: Vec<Vec<f32>> = (0..rows)
2412            .map(|i| {
2413                let mut v = vec![0.0f32; 4];
2414                v[i % 4] = 1.0;
2415                v
2416            })
2417            .collect();
2418        let img_embs: Vec<Vec<f32>> = (0..rows)
2419            .map(|i| {
2420                let mut v = vec![0.0f32; 2];
2421                v[i % 2] = 1.0;
2422                v
2423            })
2424            .collect();
2425
2426        let text_policy = make_policy(4);
2427        let img_policy = VectorStoragePolicy {
2428            column_name: "img_embedding".to_string(),
2429            dim: 2,
2430            metric: VectorMetric::Cosine,
2431            precision: VectorPrecision::F16,
2432            pq: None,
2433            keep_raw_for_reranking: true,
2434            pre_normalize: false,
2435            hnsw_m: None,
2436            hnsw_ef_construction: None,
2437            ivf_residual: false,
2438            embedding_model: None,
2439            modality: None,
2440            partition_by: None,
2441            partition_value: None,
2442            partition_column_type: None,
2443            partition_fields: vec![],
2444        };
2445
2446        let mut writer = crate::TableWriter::create_or_open(
2447            catalog.clone(),
2448            store.clone(),
2449            text_policy,
2450            table.clone(),
2451            2,
2452        )
2453        .await
2454        .unwrap();
2455
2456        let batches = [
2457            MultiVectorBatch {
2458                policy: make_policy(4),
2459                embeddings: &text_embs,
2460            },
2461            MultiVectorBatch {
2462                policy: img_policy,
2463                embeddings: &img_embs,
2464            },
2465        ];
2466        writer.write_batch_multi(&batch, &batches).await.unwrap();
2467        writer.commit().await.unwrap();
2468
2469        // Cross-modal search: text query (dim=4) + image query (dim=2).
2470        let q_text = vec![1.0f32, 0.0, 0.0, 0.0];
2471        let q_img = vec![1.0f32, 0.0];
2472
2473        let queries = vec![
2474            ModalQuery {
2475                column: "embedding",
2476                query: &q_text,
2477                weight: 0.6,
2478                dim: 4,
2479            },
2480            ModalQuery {
2481                column: "img_embedding",
2482                query: &q_img,
2483                weight: 0.4,
2484                dim: 2,
2485            },
2486        ];
2487        let config = SearchConfig {
2488            top_k: 2,
2489            ef_search: 50,
2490            pruning_threshold: f32::INFINITY,
2491            rerank_factor: None,
2492            score_fn: None,
2493            partition_filter: None,
2494            hybrid: None,
2495            column_filter: None,
2496        };
2497
2498        let results =
2499            search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf)
2500                .await
2501                .unwrap();
2502
2503        assert!(!results.is_empty(), "should return results");
2504        assert!(results[0].distance <= 0.0, "distance is -rrf_score");
2505        // Row 0 is nearest to both q_text=[1,0,0,0] and q_img=[1,0]
2506        assert_eq!(results[0].row_id.as_u64(), 0, "row 0 should rank first");
2507    }
2508
2509    /// Regression: `fetch_rows` used to build its output schema from whichever file's
2510    /// physical Parquet schema happened to be read first — a file written before a
2511    /// metadata-only `evolve_schema`/`add_column` never physically has the new column,
2512    /// so it was silently absent from the response instead of projected as null.
2513    /// Confirmed live via Spark's `AilakeNative.scan()` and
2514    /// `ailake.search(fetch_data=True)`, both backed by this function.
2515    #[tokio::test]
2516    async fn fetch_rows_projects_evolved_column_as_null() {
2517        use ailake_catalog::schema_evolution::{AddColumnRequest, SchemaEvolution};
2518
2519        let dir = TempDir::new().unwrap();
2520        let dim = 8usize;
2521        write_demo_table(&dir, dim, 8).await;
2522
2523        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2524        let catalog: Arc<dyn CatalogProvider> =
2525            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2526        let table = TableIdent::new("default", "table");
2527
2528        // Metadata-only schema evolution — no data files rewritten, so every existing
2529        // file on disk still physically lacks the "note" column.
2530        catalog
2531            .evolve_schema(
2532                &table,
2533                SchemaEvolution::new().add_column(AddColumnRequest {
2534                    name: "note".to_string(),
2535                    iceberg_type: "string".to_string(),
2536                    required: false,
2537                    initial_default: None,
2538                    write_default: None,
2539                    doc: None,
2540                }),
2541            )
2542            .await
2543            .unwrap();
2544
2545        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2546        let config = SearchConfig {
2547            top_k: 3,
2548            ef_search: 50,
2549            pruning_threshold: f32::INFINITY,
2550            rerank_factor: None,
2551            score_fn: None,
2552            partition_filter: None,
2553            hybrid: None,
2554            column_filter: None,
2555        };
2556        let results = search(
2557            &table,
2558            &query,
2559            config,
2560            "embedding",
2561            dim as u32,
2562            Arc::clone(&catalog),
2563            Arc::clone(&store),
2564        )
2565        .await
2566        .unwrap();
2567        assert_eq!(results.len(), 3);
2568
2569        let table_meta = catalog.load_table(&table).await.unwrap();
2570        let batch = fetch_rows(
2571            &results,
2572            store,
2573            "embedding",
2574            dim as u32,
2575            &table_meta.schema_fields,
2576        )
2577        .await
2578        .unwrap();
2579
2580        let note_col = batch
2581            .column_by_name("note")
2582            .expect("evolved 'note' column must be present, not silently dropped");
2583        assert_eq!(note_col.len(), 3);
2584        assert_eq!(
2585            note_col.null_count(),
2586            3,
2587            "old files predate 'note' — every value must be null, not an error or a missing column"
2588        );
2589    }
2590
2591    /// Regression: the schema-projection fix above (`fetch_rows_projects_evolved_column_as_null`)
2592    /// initially introduced its own bug — `SchemaFiller::fill` was called with the *unfiltered*
2593    /// current-schema field list, which includes the vector column itself. Since
2594    /// `AilakeFileReader::read_parquet()` deliberately returns the vector column out-of-band
2595    /// (as `vectors: Vec<Vec<f32>>`, not as part of the tabular `RecordBatch`), the filler saw
2596    /// it as "missing" and injected a synthetic column for it — wrong-typed (`iceberg_type_to_arrow`'s
2597    /// `Utf8` fallback) and a duplicate of the real decoded vector column appended a few lines
2598    /// later, breaking pandas' arrow→pandas conversion for *every* `fetch_data=True`/`scan()`
2599    /// call, not just evolved-schema ones. Caught immediately by testing against real
2600    /// pandas conversion (not just the Rust arrow API) — this test guards it at the Rust level too.
2601    #[tokio::test]
2602    async fn fetch_rows_does_not_duplicate_vector_column() {
2603        let dir = TempDir::new().unwrap();
2604        let dim = 8usize;
2605        write_demo_table(&dir, dim, 8).await;
2606
2607        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2608        let catalog: Arc<dyn CatalogProvider> =
2609            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2610        let table = TableIdent::new("default", "table");
2611
2612        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2613        let config = SearchConfig {
2614            top_k: 3,
2615            ef_search: 50,
2616            pruning_threshold: f32::INFINITY,
2617            rerank_factor: None,
2618            score_fn: None,
2619            partition_filter: None,
2620            hybrid: None,
2621            column_filter: None,
2622        };
2623        let results = search(
2624            &table,
2625            &query,
2626            config,
2627            "embedding",
2628            dim as u32,
2629            Arc::clone(&catalog),
2630            Arc::clone(&store),
2631        )
2632        .await
2633        .unwrap();
2634
2635        let table_meta = catalog.load_table(&table).await.unwrap();
2636        let batch = fetch_rows(
2637            &results,
2638            store,
2639            "embedding",
2640            dim as u32,
2641            &table_meta.schema_fields,
2642        )
2643        .await
2644        .unwrap();
2645
2646        let batch_schema = batch.schema();
2647        let embedding_fields: Vec<_> = batch_schema
2648            .fields()
2649            .iter()
2650            .filter(|f| f.name() == "embedding")
2651            .collect();
2652        assert_eq!(
2653            embedding_fields.len(),
2654            1,
2655            "exactly one 'embedding' field expected, got: {:?}",
2656            batch_schema
2657        );
2658        assert!(
2659            matches!(embedding_fields[0].data_type(), arrow_schema::DataType::FixedSizeList(_, d) if *d == dim as i32),
2660            "embedding field must be the decoded FixedSizeList<Float32>, got {:?}",
2661            embedding_fields[0].data_type()
2662        );
2663    }
2664
2665    // ── Edge cases: dimension mismatch ───────────────────────────────────
2666
2667    #[tokio::test]
2668    async fn search_dimension_mismatch_rejected() {
2669        let dir = TempDir::new().unwrap();
2670        write_demo_table(&dir, 8, 5).await;
2671
2672        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2673        let catalog: Arc<dyn CatalogProvider> =
2674            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2675        let table = TableIdent::new("default", "table");
2676
2677        // Query with wrong dimension (4 instead of 8)
2678        let query = vec![1.0f32, 0.0, 0.0, 0.0];
2679        let config = SearchConfig::default();
2680        let result = search(
2681            &table,
2682            &query,
2683            config,
2684            "embedding",
2685            8,
2686            Arc::clone(&catalog),
2687            Arc::clone(&store),
2688        )
2689        .await;
2690        assert!(
2691            result.is_err(),
2692            "search with wrong dim should error, got Ok"
2693        );
2694    }
2695
2696    #[tokio::test]
2697    async fn search_top_k_over_cap_rejected() {
2698        // No table written — top_k validation must reject before any catalog/file I/O,
2699        // matching how ailake-jni's C-ABI boundary already validates top_k, but closing
2700        // the same gap for every caller that reaches scanner::search directly (Python
2701        // bindings, CLI, and transitively Go/C++/Airflow via CLI shell-out) instead of
2702        // going through ailake-jni.
2703        let dir = TempDir::new().unwrap();
2704        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2705        let catalog: Arc<dyn CatalogProvider> =
2706            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2707        let table = TableIdent::new("default", "table");
2708
2709        let config = SearchConfig {
2710            top_k: ailake_core::MAX_TOP_K + 1,
2711            ..SearchConfig::default()
2712        };
2713        let result = search(
2714            &table,
2715            &[1.0f32, 0.0, 0.0, 0.0],
2716            config,
2717            "embedding",
2718            4,
2719            catalog,
2720            store,
2721        )
2722        .await;
2723        let err = result.expect_err("top_k over cap should error");
2724        assert!(err.to_string().contains("top_k"), "{err}");
2725    }
2726
2727    #[tokio::test]
2728    async fn search_multimodal_top_k_over_cap_rejected() {
2729        let dir = TempDir::new().unwrap();
2730        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2731        let catalog: Arc<dyn CatalogProvider> =
2732            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2733        let table = TableIdent::new("default", "table");
2734
2735        let config = SearchConfig {
2736            top_k: ailake_core::MAX_TOP_K + 1,
2737            ..SearchConfig::default()
2738        };
2739        let queries = vec![ModalQuery {
2740            column: "embedding",
2741            query: &[1.0f32, 0.0, 0.0, 0.0],
2742            weight: 1.0,
2743            dim: 4,
2744        }];
2745        let result =
2746            search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf).await;
2747        let err = result.expect_err("top_k over cap should error");
2748        assert!(err.to_string().contains("top_k"), "{err}");
2749    }
2750
2751    #[tokio::test]
2752    async fn search_text_top_k_over_cap_rejected() {
2753        let dir = TempDir::new().unwrap();
2754        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2755        let catalog: Arc<dyn CatalogProvider> =
2756            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2757        let table = TableIdent::new("default", "table");
2758
2759        let result = search_text(
2760            &table,
2761            "hello",
2762            &["chunk_text"],
2763            ailake_core::MAX_TOP_K + 1,
2764            catalog,
2765            store,
2766            None,
2767        )
2768        .await;
2769        let err = result.expect_err("top_k over cap should error");
2770        assert!(err.to_string().contains("top_k"), "{err}");
2771    }
2772
2773    // ── Fuzzing: FailStore round-trips ────────────────────────────────
2774
2775    #[cfg(test)]
2776    mod fuzz_tests {
2777        use super::*;
2778        use crate::writer::TableWriter;
2779        use ailake_catalog::{HadoopCatalog, TableIdent, TableProperties};
2780        use ailake_core::{VectorMetric, VectorPrecision, VectorStoragePolicy};
2781        use ailake_store::{fail_store::FailStore, LocalStore};
2782        use arrow_array::{Int32Array, RecordBatch};
2783        use arrow_schema::{DataType, Field, Schema};
2784        use proptest::prelude::*;
2785        use std::sync::Arc;
2786        use tempfile::TempDir;
2787
2788        fn make_fuzz_policy(dim: u32) -> VectorStoragePolicy {
2789            VectorStoragePolicy {
2790                column_name: "embedding".to_string(),
2791                dim,
2792                metric: VectorMetric::Cosine,
2793                precision: VectorPrecision::F16,
2794                pq: None,
2795                keep_raw_for_reranking: true,
2796                pre_normalize: false,
2797                hnsw_m: None,
2798                hnsw_ef_construction: None,
2799                ivf_residual: false,
2800                embedding_model: None,
2801                modality: None,
2802                partition_by: None,
2803                partition_value: None,
2804                partition_column_type: None,
2805                partition_fields: vec![],
2806            }
2807        }
2808
2809        #[derive(Debug, Clone)]
2810        struct FailProfile {
2811            fail_get: bool,
2812            fail_get_range: bool,
2813            fail_put: bool,
2814            fail_list: bool,
2815            fail_file_size: bool,
2816        }
2817
2818        fn arb_fail_profile() -> impl Strategy<Value = FailProfile> {
2819            (
2820                proptest::bool::ANY,
2821                proptest::bool::ANY,
2822                proptest::bool::ANY,
2823                proptest::bool::ANY,
2824                proptest::bool::ANY,
2825            )
2826                .prop_map(|(g, gr, p, l, fs)| FailProfile {
2827                    fail_get: g,
2828                    fail_get_range: gr,
2829                    fail_put: p,
2830                    fail_list: l,
2831                    fail_file_size: fs,
2832                })
2833        }
2834
2835        #[derive(Debug, Clone, PartialEq)]
2836        enum FuzzOutcome {
2837            Ok,
2838            StoreError,
2839            OtherError,
2840        }
2841
2842        fn is_store_err(e: &ailake_core::AilakeError) -> bool {
2843            matches!(e, ailake_core::AilakeError::Store(_))
2844        }
2845
2846        async fn write_search_rt(profile: &FailProfile, rows: usize, dim: u32) -> FuzzOutcome {
2847            let dir = TempDir::new().unwrap();
2848            let inner = LocalStore::new(dir.path());
2849            let fail = FailStore::new(inner)
2850                .with_fail_get(profile.fail_get)
2851                .with_fail_get_range(profile.fail_get_range)
2852                .with_fail_put(profile.fail_put)
2853                .with_fail_list(profile.fail_list)
2854                .with_fail_file_size(profile.fail_file_size);
2855            let store: Arc<dyn Store> = Arc::new(fail);
2856            let catalog: Arc<dyn CatalogProvider> =
2857                Arc::new(HadoopCatalog::new(Arc::clone(&store), "warehouse"));
2858            let table = TableIdent::new("default", "fuzz_test");
2859            let policy = make_fuzz_policy(dim);
2860
2861            if catalog
2862                .create_table(
2863                    &table,
2864                    &TableProperties {
2865                        policy: policy.clone(),
2866                        format_version: 2,
2867                        extra: Default::default(),
2868                        partition_column_type: None,
2869                    },
2870                )
2871                .await
2872                .is_err()
2873            {
2874                return FuzzOutcome::StoreError;
2875            }
2876
2877            let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
2878            let ids: Vec<i32> = (0..rows as i32).collect();
2879            let batch =
2880                RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
2881            let embeddings: Vec<Vec<f32>> = (0..rows)
2882                .map(|i| {
2883                    let mut v = vec![0.0f32; dim as usize];
2884                    v[i % dim as usize] = 1.0;
2885                    v
2886                })
2887                .collect();
2888
2889            let mut writer = match TableWriter::create_or_open(
2890                Arc::clone(&catalog),
2891                Arc::clone(&store),
2892                policy.clone(),
2893                table.clone(),
2894                2,
2895            )
2896            .await
2897            {
2898                Ok(w) => w,
2899                Err(e) => {
2900                    return if is_store_err(&e) {
2901                        FuzzOutcome::StoreError
2902                    } else {
2903                        FuzzOutcome::OtherError
2904                    }
2905                }
2906            };
2907
2908            if let Err(e) = writer.write_batch(&batch, &embeddings).await {
2909                return if is_store_err(&e) {
2910                    FuzzOutcome::StoreError
2911                } else {
2912                    FuzzOutcome::OtherError
2913                };
2914            }
2915
2916            if let Err(e) = writer.commit().await {
2917                return if is_store_err(&e) {
2918                    FuzzOutcome::StoreError
2919                } else {
2920                    FuzzOutcome::OtherError
2921                };
2922            }
2923
2924            let query: Vec<f32> = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2925            let config = SearchConfig {
2926                top_k: 3.min(rows),
2927                ef_search: 50,
2928                pruning_threshold: f32::INFINITY,
2929                ..Default::default()
2930            };
2931
2932            match search(
2933                &table,
2934                &query[..dim as usize],
2935                config,
2936                "embedding",
2937                dim,
2938                Arc::clone(&catalog),
2939                Arc::clone(&store),
2940            )
2941            .await
2942            {
2943                Ok(results) => {
2944                    assert!(results.len() <= rows.min(3), "more results than requested");
2945                    for r in &results {
2946                        assert!(r.distance.is_finite(), "non-finite distance");
2947                        assert!(r.distance >= 0.0, "negative distance");
2948                    }
2949                    FuzzOutcome::Ok
2950                }
2951                Err(e) => {
2952                    if is_store_err(&e) {
2953                        FuzzOutcome::StoreError
2954                    } else {
2955                        panic!("unexpected non-store search error: {:?}", e);
2956                    }
2957                }
2958            }
2959        }
2960
2961        async fn compact_rt(profile: &FailProfile, rows: usize, dim: u32) -> &'static str {
2962            let dir = TempDir::new().unwrap();
2963            let inner = LocalStore::new(dir.path());
2964            let fail = FailStore::new(inner)
2965                .with_fail_get(profile.fail_get)
2966                .with_fail_get_range(profile.fail_get_range)
2967                .with_fail_put(profile.fail_put)
2968                .with_fail_list(profile.fail_list)
2969                .with_fail_file_size(profile.fail_file_size);
2970            let store: Arc<dyn Store> = Arc::new(fail);
2971            let catalog: Arc<dyn CatalogProvider> =
2972                Arc::new(HadoopCatalog::new(Arc::clone(&store), "warehouse"));
2973            let table = TableIdent::new("default", "fuzz_compact");
2974            let policy = make_fuzz_policy(dim);
2975
2976            if catalog
2977                .create_table(
2978                    &table,
2979                    &TableProperties {
2980                        policy: policy.clone(),
2981                        format_version: 2,
2982                        extra: Default::default(),
2983                        partition_column_type: None,
2984                    },
2985                )
2986                .await
2987                .is_err()
2988            {
2989                return "store_err";
2990            }
2991
2992            for i in 0..2 {
2993                let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
2994                let ids: Vec<i32> = (0..rows as i32).collect();
2995                let batch =
2996                    RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
2997                let embeddings: Vec<Vec<f32>> = (0..rows)
2998                    .map(|j| {
2999                        let mut v = vec![0.0f32; dim as usize];
3000                        v[(i * rows + j) % dim as usize] = 1.0;
3001                        v
3002                    })
3003                    .collect();
3004
3005                let mut writer = match TableWriter::create_or_open(
3006                    Arc::clone(&catalog),
3007                    Arc::clone(&store),
3008                    policy.clone(),
3009                    table.clone(),
3010                    2,
3011                )
3012                .await
3013                {
3014                    Ok(w) => w,
3015                    Err(_) => return "store_err",
3016                };
3017                if writer.write_batch(&batch, &embeddings).await.is_err() {
3018                    return "store_err";
3019                }
3020                if writer.commit().await.is_err() {
3021                    return "store_err";
3022                }
3023            }
3024
3025            let files = match catalog.list_files(&table, None).await {
3026                Ok(f) => f,
3027                Err(_) => return "store_err",
3028            };
3029            if files.len() < 2 {
3030                return "too_few";
3031            }
3032
3033            let config = crate::compaction::CompactionConfig {
3034                min_files_to_compact: 2,
3035                target_file_size_bytes: 10 * 1024 * 1024,
3036                ..Default::default()
3037            };
3038            let planner = crate::compaction::CompactionPlanner::new(config);
3039            let executor =
3040                crate::compaction::CompactionExecutor::new(Arc::clone(&store), policy.clone());
3041
3042            match executor
3043                .run(&planner, &table, Arc::clone(&catalog), "data/merged")
3044                .await
3045            {
3046                Ok(Some(_)) => "ok",
3047                Ok(None) => "noop",
3048                Err(e) if is_store_err(&e) => "store_err",
3049                Err(_) => "unexpected_err",
3050            }
3051        }
3052
3053        proptest! {
3054            #[test]
3055            fn prop_write_search_under_failstore(
3056                profile in arb_fail_profile(),
3057                rows in 1usize..8,
3058                dim in 2u32..8,
3059            ) {
3060                let rt = tokio::runtime::Runtime::new().unwrap();
3061                let outcome = rt.block_on(write_search_rt(&profile, rows, dim));
3062                assert_ne!(outcome, FuzzOutcome::OtherError,
3063                    "unexpected non-store error during write+search under FailStore");
3064            }
3065
3066            #[test]
3067            fn prop_compact_under_failstore(
3068                profile in arb_fail_profile(),
3069                rows in 2usize..6,
3070                dim in 2u32..8,
3071            ) {
3072                let rt = tokio::runtime::Runtime::new().unwrap();
3073                let outcome = rt.block_on(compact_rt(&profile, rows, dim));
3074                assert_ne!(outcome, "unexpected_err",
3075                    "compaction failed with non-store error under FailStore");
3076            }
3077        }
3078    }
3079}