Skip to main content

lance_index/scalar/
fmindex.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! FM-Index for exact substring search (following the Infini-gram Mini paper)
5//!
6//! The FM-Index is a compressed full-text index based on the Burrows-Wheeler Transform (BWT).
7//! It supports exact substring matching via backward search and returns exact row ids.
8//!
9//! Architecture (matching the paper):
10//!   - Huffman-shaped Wavelet Tree over BWT for entropy-compressed rank queries (~0.26N)
11//!   - Sampled Suffix Array every D-th position for locate (~N/D × 8 bytes)
12//!   - doc_start_positions for mapping text positions to documents (tiny)
13//!   - No doc_array — documents are resolved via SA sampling + LF-mapping + binary search
14//!
15//! Total index size: ~0.44N (matching paper's claim)
16//!
17//! Storage layout (v10 - blocked, partitioned):
18//!   - BWT wavelet tree bitvectors in blocks of BLOCK_WORDS (32KB each)
19//!   - SA samples stored as packed binary blocks after wavelet blocks
20//!   - Row IDs and doc_start_positions in metadata
21//!   - File metadata: c_table, huffman_codes, tree topology
22
23use std::cmp::Reverse;
24use std::collections::{BinaryHeap, HashMap};
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::sync::{Arc, OnceLock};
27
28use arrow_array::RecordBatch;
29use arrow_schema::{DataType, Field};
30use async_trait::async_trait;
31use datafusion::execution::SendableRecordBatchStream;
32use futures::{StreamExt, TryStreamExt};
33use lance_core::cache::LanceCache;
34use lance_core::deepsize::DeepSizeOf;
35use lance_core::utils::row_addr_remap::RowAddrRemap;
36use lance_core::utils::tokio::{get_num_compute_intensive_cpus, spawn_cpu};
37use lance_core::{Error, ROW_ADDR, Result};
38use roaring::RoaringBitmap;
39
40use crate::metrics::MetricsCollector;
41use crate::pb;
42use crate::scalar::expression::{ScalarQueryParser, TextQueryParser};
43use crate::scalar::registry::{
44    BasicTrainer, DefaultTrainingRequest, ScalarIndexPlugin, TrainingCriteria, TrainingOrdering,
45    TrainingRequest, VALUE_COLUMN_NAME,
46};
47use crate::scalar::{
48    AnyQuery, BuiltinIndexType, CreatedIndex, IndexFile, IndexStore, OldIndexDataFilter,
49    RowIdRemapper, ScalarIndex, ScalarIndexParams, SearchResult, TextQuery, UpdateCriteria,
50};
51use crate::{Index, IndexType};
52
53const FMINDEX_INDEX_VERSION: u32 = 10;
54const BLOCK_WORDS: usize = 4096;
55const PARTITION_SIZE: usize = 10_000;
56const DEFAULT_PARTITION_SIZE_BYTES: usize = 16 * 1024 * 1024;
57const DEFAULT_DEMAND_PAGE_TARGET_BYTES: usize = 512 * 1024;
58const DEFAULT_PREWARM_CHUNK_TARGET_BYTES: usize = 8 * 1024 * 1024;
59const FMINDEX_PARTITION_FINGERPRINT_KEY: &str = "partition_fingerprint";
60const SENTINEL_BYTE: u8 = 0xFF;
61
62/// SA sampling rate. Store every D-th SA entry. Locate walks at most D LF steps.
63const SA_SAMPLE_RATE: usize = 32;
64
65static LANCE_FMINDEX_NUM_WORKERS: std::sync::LazyLock<usize> = std::sync::LazyLock::new(|| {
66    std::env::var("LANCE_FMINDEX_NUM_WORKERS")
67        .unwrap_or_else(|_| get_num_compute_intensive_cpus().to_string())
68        .parse()
69        .expect("failed to parse LANCE_FMINDEX_NUM_WORKERS")
70});
71static LANCE_FMINDEX_PARTITION_ROWS: std::sync::LazyLock<usize> = std::sync::LazyLock::new(|| {
72    std::env::var("LANCE_FMINDEX_PARTITION_ROWS")
73        .unwrap_or_else(|_| PARTITION_SIZE.to_string())
74        .parse()
75        .expect("failed to parse LANCE_FMINDEX_PARTITION_ROWS")
76});
77static LANCE_FMINDEX_PARTITION_BYTES: std::sync::LazyLock<usize> = std::sync::LazyLock::new(|| {
78    std::env::var("LANCE_FMINDEX_PARTITION_BYTES")
79        .unwrap_or_else(|_| DEFAULT_PARTITION_SIZE_BYTES.to_string())
80        .parse()
81        .expect("failed to parse LANCE_FMINDEX_PARTITION_BYTES")
82});
83static LANCE_FMINDEX_WRITE_QUEUE_SIZE: std::sync::LazyLock<usize> =
84    std::sync::LazyLock::new(|| {
85        std::env::var("LANCE_FMINDEX_WRITE_QUEUE_SIZE")
86            .unwrap_or_else(|_| "1".to_string())
87            .parse()
88            .expect("failed to parse LANCE_FMINDEX_WRITE_QUEUE_SIZE")
89    });
90static LANCE_FMINDEX_RESUME_EXISTING_PARTITIONS: std::sync::LazyLock<bool> =
91    std::sync::LazyLock::new(|| {
92        std::env::var("LANCE_FMINDEX_RESUME_EXISTING_PARTITIONS")
93            .map(|value| {
94                matches!(
95                    value.as_str(),
96                    "1" | "true" | "TRUE" | "True" | "yes" | "YES"
97                )
98            })
99            .unwrap_or(false)
100    });
101static LANCE_FMINDEX_PREWARM_CHUNK_BYTES: std::sync::LazyLock<usize> =
102    std::sync::LazyLock::new(|| {
103        std::env::var("LANCE_FMINDEX_PREWARM_CHUNK_BYTES")
104            .unwrap_or_else(|_| DEFAULT_PREWARM_CHUNK_TARGET_BYTES.to_string())
105            .parse()
106            .expect("failed to parse LANCE_FMINDEX_PREWARM_CHUNK_BYTES")
107    });
108static LANCE_FMINDEX_DEMAND_PAGE_BYTES: std::sync::LazyLock<usize> =
109    std::sync::LazyLock::new(|| {
110        std::env::var("LANCE_FMINDEX_DEMAND_PAGE_BYTES")
111            .unwrap_or_else(|_| DEFAULT_DEMAND_PAGE_TARGET_BYTES.to_string())
112            .parse()
113            .expect("failed to parse LANCE_FMINDEX_DEMAND_PAGE_BYTES")
114    });
115static LANCE_FMINDEX_PREWARM_CHUNK_CONCURRENCY: std::sync::LazyLock<usize> =
116    std::sync::LazyLock::new(|| {
117        std::env::var("LANCE_FMINDEX_PREWARM_CHUNK_CONCURRENCY")
118            .unwrap_or_else(|_| "1".to_string())
119            .parse()
120            .expect("failed to parse LANCE_FMINDEX_PREWARM_CHUNK_CONCURRENCY")
121    });
122
123fn fmindex_partition_path(partition_id: u64) -> String {
124    format!("part_{partition_id}_fm.lance")
125}
126
127fn fmindex_partition_id_from_path(path: &str) -> Option<u64> {
128    path.strip_prefix("part_")
129        .and_then(|r| r.strip_suffix("_fm.lance"))
130        .and_then(|s| s.parse::<u64>().ok())
131}
132
133fn fmindex_num_workers() -> usize {
134    (*LANCE_FMINDEX_NUM_WORKERS).max(1)
135}
136
137fn fmindex_partition_rows() -> usize {
138    (*LANCE_FMINDEX_PARTITION_ROWS).max(1)
139}
140
141fn fmindex_partition_bytes() -> usize {
142    (*LANCE_FMINDEX_PARTITION_BYTES).max(1)
143}
144
145fn fmindex_write_queue_size() -> usize {
146    (*LANCE_FMINDEX_WRITE_QUEUE_SIZE).max(1)
147}
148
149fn fmindex_resume_existing_partitions() -> bool {
150    *LANCE_FMINDEX_RESUME_EXISTING_PARTITIONS
151}
152
153fn fmindex_prewarm_chunk_bytes() -> usize {
154    (*LANCE_FMINDEX_PREWARM_CHUNK_BYTES).max(BLOCK_WORDS * 8)
155}
156
157fn fmindex_demand_page_bytes() -> usize {
158    (*LANCE_FMINDEX_DEMAND_PAGE_BYTES).max(BLOCK_WORDS * 8)
159}
160
161fn fmindex_demand_page_rows() -> usize {
162    (fmindex_demand_page_bytes() / (BLOCK_WORDS * 8)).max(1)
163}
164
165fn fmindex_prewarm_chunk_concurrency() -> usize {
166    (*LANCE_FMINDEX_PREWARM_CHUNK_CONCURRENCY).max(1)
167}
168
169// ── Bitvector with O(1) rank ─────────────────────────────────────────────────
170
171const SUPERBLOCK_BITS: usize = 512;
172const WORDS_PER_SUPERBLOCK: usize = SUPERBLOCK_BITS / 64;
173
174#[derive(Debug, Clone)]
175struct RankBitVec {
176    words: Vec<u64>,
177    superblocks: Vec<u32>,
178    len: usize,
179}
180
181impl RankBitVec {
182    fn new(len: usize) -> Self {
183        Self {
184            words: vec![0u64; len.div_ceil(64)],
185            superblocks: Vec::new(),
186            len,
187        }
188    }
189
190    #[inline]
191    fn set(&mut self, pos: usize) {
192        self.words[pos / 64] |= 1u64 << (pos % 64);
193    }
194
195    fn build_rank_index(&mut self) {
196        let num_sb = self.words.len().div_ceil(WORDS_PER_SUPERBLOCK) + 1;
197        self.superblocks = Vec::with_capacity(num_sb);
198        let mut cum = 0u32;
199        for (i, chunk) in self.words.chunks(WORDS_PER_SUPERBLOCK).enumerate() {
200            self.superblocks.push(if i == 0 { 0 } else { cum });
201            for &w in chunk {
202                cum += w.count_ones();
203            }
204        }
205        self.superblocks.push(cum);
206    }
207
208    fn deep_size(&self) -> usize {
209        self.words.len() * 8 + self.superblocks.len() * 4
210    }
211}
212
213// ── Huffman-shaped Wavelet Tree ──────────────────────────────────────────────
214
215#[derive(Debug, Clone, Default)]
216struct HuffmanCode {
217    bits: u32,
218    length: u8,
219    node_path: Vec<usize>,
220}
221
222#[derive(Debug, Clone)]
223enum WaveletChild {
224    Node(usize),
225    Leaf(u8),
226}
227
228#[derive(Debug, Clone)]
229struct HuffmanWaveletTree {
230    nodes: Vec<RankBitVec>,
231    codes: [HuffmanCode; 256],
232    children: Vec<(WaveletChild, WaveletChild)>,
233    len: usize,
234}
235
236#[derive(Debug)]
237enum HuffNode {
238    Leaf(u8),
239    Internal { left: Box<Self>, right: Box<Self> },
240}
241
242impl PartialEq for HuffNode {
243    fn eq(&self, _: &Self) -> bool {
244        true
245    }
246}
247impl Eq for HuffNode {}
248impl PartialOrd for HuffNode {
249    fn partial_cmp(&self, o: &Self) -> Option<std::cmp::Ordering> {
250        Some(self.cmp(o))
251    }
252}
253impl Ord for HuffNode {
254    fn cmp(&self, _: &Self) -> std::cmp::Ordering {
255        std::cmp::Ordering::Equal
256    }
257}
258
259impl HuffmanWaveletTree {
260    fn build(data: &[u8]) -> Self {
261        let n = data.len();
262        if n == 0 {
263            return Self {
264                nodes: Vec::new(),
265                codes: std::array::from_fn(|_| HuffmanCode::default()),
266                children: Vec::new(),
267                len: 0,
268            };
269        }
270
271        let mut freq = [0u64; 256];
272        for &b in data {
273            freq[b as usize] += 1;
274        }
275
276        let mut heap: BinaryHeap<(Reverse<u64>, Reverse<usize>, Box<HuffNode>)> = BinaryHeap::new();
277        let mut tie = 0;
278        for (v, &f) in freq.iter().enumerate() {
279            if f > 0 {
280                heap.push((Reverse(f), Reverse(tie), Box::new(HuffNode::Leaf(v as u8))));
281                tie += 1;
282            }
283        }
284        if heap.len() == 1 {
285            let (f, _, node) = heap.pop().unwrap();
286            heap.push((Reverse(0), Reverse(tie), Box::new(HuffNode::Leaf(255))));
287            tie += 1;
288            heap.push((f, Reverse(tie), node));
289            tie += 1;
290        }
291        while heap.len() > 1 {
292            let (Reverse(f1), _, l) = heap.pop().unwrap();
293            let (Reverse(f2), _, r) = heap.pop().unwrap();
294            heap.push((
295                Reverse(f1 + f2),
296                Reverse(tie),
297                Box::new(HuffNode::Internal { left: l, right: r }),
298            ));
299            tie += 1;
300        }
301        let root = heap.pop().unwrap().2;
302
303        let mut codes: [HuffmanCode; 256] = std::array::from_fn(|_| HuffmanCode::default());
304        let mut node_count = 0;
305        let mut children_map: Vec<(WaveletChild, WaveletChild)> = Vec::new();
306
307        fn assign(
308            node: &HuffNode,
309            bits: u32,
310            len: u8,
311            path: &mut Vec<usize>,
312            nid: &mut usize,
313            codes: &mut [HuffmanCode; 256],
314            cm: &mut Vec<(WaveletChild, WaveletChild)>,
315        ) -> WaveletChild {
316            match node {
317                HuffNode::Leaf(b) => {
318                    codes[*b as usize] = HuffmanCode {
319                        bits,
320                        length: len,
321                        node_path: path.clone(),
322                    };
323                    WaveletChild::Leaf(*b)
324                }
325                HuffNode::Internal { left, right } => {
326                    let my = *nid;
327                    *nid += 1;
328                    path.push(my);
329                    cm.push((WaveletChild::Leaf(0), WaveletChild::Leaf(0)));
330                    let lc = assign(left, bits << 1, len + 1, path, nid, codes, cm);
331                    let rc = assign(right, (bits << 1) | 1, len + 1, path, nid, codes, cm);
332                    cm[my] = (lc, rc);
333                    path.pop();
334                    WaveletChild::Node(my)
335                }
336            }
337        }
338        assign(
339            &root,
340            0,
341            0,
342            &mut Vec::new(),
343            &mut node_count,
344            &mut codes,
345            &mut children_map,
346        );
347
348        let mut node_sizes = vec![0usize; node_count];
349        for &b in data {
350            for &nid in &codes[b as usize].node_path {
351                node_sizes[nid] += 1;
352            }
353        }
354        let mut nodes: Vec<RankBitVec> = node_sizes.iter().map(|&sz| RankBitVec::new(sz)).collect();
355        let mut cursors = vec![0usize; node_count];
356        for &b in data {
357            let code = &codes[b as usize];
358            for (level, &nid) in code.node_path.iter().enumerate() {
359                if (code.bits >> (code.length - 1 - level as u8)) & 1 == 1 {
360                    nodes[nid].set(cursors[nid]);
361                }
362                cursors[nid] += 1;
363            }
364        }
365        for n in &mut nodes {
366            n.build_rank_index();
367        }
368        Self {
369            nodes,
370            codes,
371            children: children_map,
372            len: n,
373        }
374    }
375
376    fn deep_size(&self) -> usize {
377        self.nodes.iter().map(|n| n.deep_size()).sum::<usize>()
378            + self
379                .codes
380                .iter()
381                .map(|c| c.node_path.len() * 8)
382                .sum::<usize>()
383            + self.children.len() * 24
384    }
385}
386
387// ── Suffix Array ─────────────────────────────────────────────────────────────
388
389fn build_suffix_array(text: &[u8]) -> Vec<usize> {
390    let n = text.len();
391    if n == 0 {
392        return Vec::new();
393    }
394    if n > i32::MAX as usize {
395        let mut sa = vec![0i64; n];
396        assert_eq!(libsais_rs::libsais64(text, &mut sa, 0, None), 0);
397        sa.iter().map(|&x| x as usize).collect()
398    } else {
399        let mut sa = vec![0i32; n];
400        assert_eq!(libsais_rs::libsais(text, &mut sa, 0, None), 0);
401        sa.iter().map(|&x| x as usize).collect()
402    }
403}
404
405// ── Lazy Block Loading ───────────────────────────────────────────────────────
406
407const BLOCK_BITS: usize = BLOCK_WORDS * 64;
408
409struct LazyRankBitVec {
410    prefix_ranks: Vec<u64>,
411    blocks: Vec<OnceLock<Vec<u64>>>,
412    reader: Arc<dyn crate::scalar::IndexReader>,
413    block_row_offset: usize,
414    len: usize,
415}
416
417impl std::fmt::Debug for LazyRankBitVec {
418    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
419        f.debug_struct("LazyRankBitVec")
420            .field("len", &self.len)
421            .finish()
422    }
423}
424
425impl LazyRankBitVec {
426    fn new(
427        prefix_ranks: Vec<u64>,
428        num_blocks: usize,
429        reader: Arc<dyn crate::scalar::IndexReader>,
430        offset: usize,
431        len: usize,
432    ) -> Self {
433        Self {
434            prefix_ranks,
435            blocks: (0..num_blocks).map(|_| OnceLock::new()).collect(),
436            reader,
437            block_row_offset: offset,
438            len,
439        }
440    }
441
442    async fn load_block_if_needed(&self, idx: usize) -> Result<()> {
443        if idx >= self.blocks.len() {
444            return Err(Error::index(format!(
445                "FM-Index block {idx} is out of range for {} blocks",
446                self.blocks.len()
447            )));
448        }
449        if self.blocks[idx].get().is_none() {
450            self.load_page_containing(idx).await?;
451        }
452        Ok(())
453    }
454
455    async fn load_page_containing(&self, idx: usize) -> Result<()> {
456        let page_rows = fmindex_demand_page_rows();
457        let start = (idx / page_rows) * page_rows;
458        let end = (start + page_rows).min(self.blocks.len());
459        if self.blocks[start..end]
460            .iter()
461            .all(|block| block.get().is_some())
462        {
463            return Ok(());
464        }
465
466        let batch = self
467            .reader
468            .read_range(
469                self.block_row_offset + start..self.block_row_offset + end,
470                Some(&["words"]),
471            )
472            .await?;
473        if batch.num_rows() != end - start {
474            return Err(Error::index(format!(
475                "expected {} FM-Index block rows, got {}",
476                end - start,
477                batch.num_rows()
478            )));
479        }
480
481        let col = Self::words_column(&batch)?;
482        for row in 0..batch.num_rows() {
483            let block_idx = start + row;
484            if self.blocks[block_idx].get().is_none() {
485                let words = Self::decode_words(col.value(row));
486                let _ = self.blocks[block_idx].set(words);
487            }
488        }
489        Ok(())
490    }
491
492    async fn load_block_for_rank(&self, pos: usize) -> Result<()> {
493        if pos > self.len {
494            return Err(Error::invalid_input(format!(
495                "FM-Index rank position {pos} exceeds bitvector length {}",
496                self.len
497            )));
498        }
499        if pos == 0 {
500            return Ok(());
501        }
502        if pos == self.len && pos.is_multiple_of(BLOCK_BITS) {
503            if let Some(last_idx) = self.blocks.len().checked_sub(1) {
504                return self.load_block_if_needed(last_idx).await;
505            }
506            return Ok(());
507        }
508        if pos.is_multiple_of(BLOCK_BITS) {
509            return Ok(());
510        }
511        self.load_block_if_needed(pos / BLOCK_BITS).await
512    }
513
514    async fn load_block_for_access(&self, pos: usize) -> Result<()> {
515        self.load_block_if_needed(pos / BLOCK_BITS).await
516    }
517
518    #[inline]
519    fn ensure_block(&self, idx: usize) -> &[u64] {
520        self.blocks[idx].get_or_init(|| {
521            tokio::task::block_in_place(|| {
522                tokio::runtime::Handle::current().block_on(self.load_block(idx))
523            })
524            .unwrap_or_else(|e| panic!("FM-Index block load failed: {e}"))
525        })
526    }
527
528    async fn load_block(&self, idx: usize) -> Result<Vec<u64>> {
529        let row = self.block_row_offset + idx;
530        let batch = self
531            .reader
532            .read_range(row..row + 1, Some(&["words"]))
533            .await?;
534        let col = Self::words_column(&batch)?;
535        Ok(Self::decode_words(col.value(0)))
536    }
537
538    fn words_column(batch: &RecordBatch) -> Result<&arrow_array::LargeBinaryArray> {
539        batch
540            .column(0)
541            .as_any()
542            .downcast_ref::<arrow_array::LargeBinaryArray>()
543            .ok_or_else(|| Error::invalid_input("expected LargeBinary words column"))
544    }
545
546    fn decode_words(raw: &[u8]) -> Vec<u64> {
547        raw.chunks_exact(8)
548            .map(|c| u64::from_le_bytes(c.try_into().unwrap()))
549            .collect()
550    }
551
552    fn terminal_rank1(&self) -> usize {
553        if self.len == 0 {
554            return 0;
555        }
556        let Some(last_idx) = self.blocks.len().checked_sub(1) else {
557            return 0;
558        };
559        let mut count = self.prefix_ranks.get(last_idx).copied().unwrap_or(0) as usize;
560        let block = self.ensure_block(last_idx);
561        let local = self.len - last_idx * BLOCK_BITS;
562        let full_words = local / 64;
563        let trailing_bits = local % 64;
564        for w in &block[..full_words] {
565            count += w.count_ones() as usize;
566        }
567        if trailing_bits > 0 {
568            count += (block[full_words] & ((1u64 << trailing_bits) - 1)).count_ones() as usize;
569        }
570        count
571    }
572
573    #[inline]
574    fn rank1(&self, pos: usize) -> usize {
575        debug_assert!(pos <= self.len);
576        if pos == 0 {
577            return 0;
578        }
579        let bi = pos / BLOCK_BITS;
580        let local = pos % BLOCK_BITS;
581        if local == 0 {
582            if let Some(prefix_rank) = self.prefix_ranks.get(bi) {
583                return *prefix_rank as usize;
584            }
585            if pos == self.len {
586                return self.terminal_rank1();
587            }
588        }
589        let mut count = self.prefix_ranks[bi] as usize;
590        let block = self.ensure_block(bi);
591        let wi = local / 64;
592        let bit = local % 64;
593        for w in &block[..wi] {
594            count += w.count_ones() as usize;
595        }
596        if bit > 0 {
597            count += (block[wi] & ((1u64 << bit) - 1)).count_ones() as usize;
598        }
599        count
600    }
601
602    #[inline]
603    fn rank0(&self, pos: usize) -> usize {
604        pos - self.rank1(pos)
605    }
606
607    #[inline]
608    fn get(&self, pos: usize) -> bool {
609        debug_assert!(pos < self.len);
610        let bi = pos / BLOCK_BITS;
611        let local = pos % BLOCK_BITS;
612        let block = self.ensure_block(bi);
613        (block[local / 64] >> (local % 64)) & 1 != 0
614    }
615
616    fn deep_size(&self) -> usize {
617        let loaded: usize = self
618            .blocks
619            .iter()
620            .filter_map(|b| b.get())
621            .map(|w| w.len() * 8)
622            .sum();
623        self.prefix_ranks.len() * 8 + loaded
624    }
625}
626
627struct LazyHuffmanWaveletTree {
628    nodes: Vec<LazyRankBitVec>,
629    codes: [HuffmanCode; 256],
630    children: Vec<(WaveletChild, WaveletChild)>,
631    len: usize,
632}
633
634impl std::fmt::Debug for LazyHuffmanWaveletTree {
635    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
636        f.debug_struct("LazyHuffmanWaveletTree")
637            .field("len", &self.len)
638            .finish()
639    }
640}
641
642impl LazyHuffmanWaveletTree {
643    /// Pre-load all wavelet tree blocks into memory.
644    async fn load_all(&self) -> Result<()> {
645        if self.nodes.is_empty() {
646            return Ok(());
647        }
648
649        #[derive(Clone, Copy)]
650        struct RowRange {
651            start: usize,
652            end: usize,
653            node_idx: usize,
654        }
655
656        let mut ranges = self
657            .nodes
658            .iter()
659            .enumerate()
660            .filter(|(_, node)| !node.blocks.is_empty())
661            .map(|(node_idx, node)| RowRange {
662                start: node.block_row_offset,
663                end: node.block_row_offset + node.blocks.len(),
664                node_idx,
665            })
666            .collect::<Vec<_>>();
667        ranges.sort_by_key(|range| range.start);
668        let Some(total_rows) = ranges.iter().map(|range| range.end).max() else {
669            return Ok(());
670        };
671        let ranges = Arc::new(ranges);
672
673        let chunk_loaded = |start: usize, end: usize| {
674            ranges.iter().all(|range| {
675                let overlap_start = start.max(range.start);
676                let overlap_end = end.min(range.end);
677                if overlap_start >= overlap_end {
678                    return true;
679                }
680                let node = &self.nodes[range.node_idx];
681                node.blocks[overlap_start - range.start..overlap_end - range.start]
682                    .iter()
683                    .all(|block| block.get().is_some())
684            })
685        };
686
687        let chunk_rows = (fmindex_prewarm_chunk_bytes() / (BLOCK_WORDS * 8)).max(1);
688        let reader = Arc::clone(&self.nodes[0].reader);
689        let chunks = (0..total_rows)
690            .step_by(chunk_rows)
691            .map(|start| {
692                let end = (start + chunk_rows).min(total_rows);
693                (start, end)
694            })
695            .filter(|(start, end)| !chunk_loaded(*start, *end))
696            .collect::<Vec<_>>();
697
698        futures::stream::iter(chunks)
699            .map(|(start, end)| {
700                let reader = Arc::clone(&reader);
701                async move {
702                    let batch = reader.read_range(start..end, Some(&["words"])).await?;
703                    Result::<_>::Ok((start, end, batch))
704                }
705            })
706            .buffer_unordered(fmindex_prewarm_chunk_concurrency())
707            .try_for_each(|(start, end, batch)| {
708                let ranges = Arc::clone(&ranges);
709                let nodes = &self.nodes;
710                async move {
711                    if batch.num_rows() != end - start {
712                        return Err(Error::index(format!(
713                            "expected {} FM-Index block rows, got {}",
714                            end - start,
715                            batch.num_rows()
716                        )));
717                    }
718
719                    let col = LazyRankBitVec::words_column(&batch)?;
720                    let mut range_idx = ranges.partition_point(|range| range.end <= start);
721                    for row in 0..batch.num_rows() {
722                        let absolute_row = start + row;
723                        while range_idx < ranges.len() && absolute_row >= ranges[range_idx].end {
724                            range_idx += 1;
725                        }
726                        if range_idx == ranges.len() || absolute_row < ranges[range_idx].start {
727                            continue;
728                        }
729
730                        let range = ranges[range_idx];
731                        let block_idx = absolute_row - range.start;
732                        let node = &nodes[range.node_idx];
733                        if node.blocks[block_idx].get().is_none() {
734                            let words = LazyRankBitVec::decode_words(col.value(row));
735                            let _ = node.blocks[block_idx].set(words);
736                        }
737                    }
738
739                    Ok(())
740                }
741            })
742            .await?;
743
744        Ok(())
745    }
746
747    #[inline]
748    fn access(&self, mut pos: usize) -> u8 {
749        if self.nodes.is_empty() {
750            return 0;
751        }
752        let mut node_idx = 0;
753        loop {
754            let bit = self.nodes[node_idx].get(pos);
755            let (ref left, ref right) = self.children[node_idx];
756            if bit {
757                pos = self.nodes[node_idx].rank1(pos);
758                match right {
759                    WaveletChild::Leaf(b) => return *b,
760                    WaveletChild::Node(next) => node_idx = *next,
761                }
762            } else {
763                pos = self.nodes[node_idx].rank0(pos);
764                match left {
765                    WaveletChild::Leaf(b) => return *b,
766                    WaveletChild::Node(next) => node_idx = *next,
767                }
768            }
769        }
770    }
771
772    async fn access_async(&self, mut pos: usize) -> Result<u8> {
773        if self.nodes.is_empty() {
774            return Ok(0);
775        }
776        let mut node_idx = 0;
777        loop {
778            self.nodes[node_idx].load_block_for_access(pos).await?;
779            let bit = self.nodes[node_idx].get(pos);
780            let (ref left, ref right) = self.children[node_idx];
781            if bit {
782                pos = self.nodes[node_idx].rank1(pos);
783                match right {
784                    WaveletChild::Leaf(b) => return Ok(*b),
785                    WaveletChild::Node(next) => node_idx = *next,
786                }
787            } else {
788                pos = self.nodes[node_idx].rank0(pos);
789                match left {
790                    WaveletChild::Leaf(b) => return Ok(*b),
791                    WaveletChild::Node(next) => node_idx = *next,
792                }
793            }
794        }
795    }
796
797    #[inline]
798    fn rank(&self, c: u8, pos: usize) -> usize {
799        let code = &self.codes[c as usize];
800        if code.length == 0 {
801            return 0;
802        }
803        let (mut lo, mut hi) = (0, pos);
804        for (level, &nid) in code.node_path.iter().enumerate() {
805            if (code.bits >> (code.length - 1 - level as u8)) & 1 == 0 {
806                lo = self.nodes[nid].rank0(lo);
807                hi = self.nodes[nid].rank0(hi);
808            } else {
809                lo = self.nodes[nid].rank1(lo);
810                hi = self.nodes[nid].rank1(hi);
811            }
812        }
813        hi - lo
814    }
815
816    async fn rank_async(&self, c: u8, pos: usize) -> Result<usize> {
817        let code = &self.codes[c as usize];
818        if code.length == 0 {
819            return Ok(0);
820        }
821        let (mut lo, mut hi) = (0, pos);
822        for (level, &nid) in code.node_path.iter().enumerate() {
823            self.nodes[nid].load_block_for_rank(lo).await?;
824            self.nodes[nid].load_block_for_rank(hi).await?;
825            if (code.bits >> (code.length - 1 - level as u8)) & 1 == 0 {
826                lo = self.nodes[nid].rank0(lo);
827                hi = self.nodes[nid].rank0(hi);
828            } else {
829                lo = self.nodes[nid].rank1(lo);
830                hi = self.nodes[nid].rank1(hi);
831            }
832        }
833        Ok(hi - lo)
834    }
835
836    #[inline]
837    fn rank_pair(&self, c: u8, lo: usize, hi: usize) -> (usize, usize) {
838        let code = &self.codes[c as usize];
839        if code.length == 0 {
840            return (0, 0);
841        }
842        let (mut s, mut l, mut h) = (0, lo, hi);
843        for (level, &nid) in code.node_path.iter().enumerate() {
844            if (code.bits >> (code.length - 1 - level as u8)) & 1 == 0 {
845                s = self.nodes[nid].rank0(s);
846                l = self.nodes[nid].rank0(l);
847                h = self.nodes[nid].rank0(h);
848            } else {
849                s = self.nodes[nid].rank1(s);
850                l = self.nodes[nid].rank1(l);
851                h = self.nodes[nid].rank1(h);
852            }
853        }
854        (l - s, h - s)
855    }
856
857    async fn rank_pair_async(&self, c: u8, lo: usize, hi: usize) -> Result<(usize, usize)> {
858        let code = &self.codes[c as usize];
859        if code.length == 0 {
860            return Ok((0, 0));
861        }
862        let (mut s, mut l, mut h) = (0, lo, hi);
863        for (level, &nid) in code.node_path.iter().enumerate() {
864            self.nodes[nid].load_block_for_rank(s).await?;
865            self.nodes[nid].load_block_for_rank(l).await?;
866            self.nodes[nid].load_block_for_rank(h).await?;
867            if (code.bits >> (code.length - 1 - level as u8)) & 1 == 0 {
868                s = self.nodes[nid].rank0(s);
869                l = self.nodes[nid].rank0(l);
870                h = self.nodes[nid].rank0(h);
871            } else {
872                s = self.nodes[nid].rank1(s);
873                l = self.nodes[nid].rank1(l);
874                h = self.nodes[nid].rank1(h);
875            }
876        }
877        Ok((l - s, h - s))
878    }
879
880    fn deep_size(&self) -> usize {
881        self.nodes.iter().map(|n| n.deep_size()).sum::<usize>()
882            + self
883                .codes
884                .iter()
885                .map(|c| c.node_path.len() * 8)
886                .sum::<usize>()
887    }
888}
889
890// ── FM-Index (in-memory, build-time) ─────────────────────────────────────────
891
892#[derive(Debug, Clone)]
893pub struct FMIndex {
894    wavelet: HuffmanWaveletTree,
895    row_ids: Vec<u64>,
896    /// Sampled SA: sa_samples[i] = SA[i * SA_SAMPLE_RATE]. Size: N/D × 8 bytes.
897    sa_samples: Vec<u64>,
898    /// Starting byte offset of each document in the concatenated text.
899    doc_start_positions: Vec<u64>,
900    c_table: Vec<usize>,
901    alphabet_size: usize,
902}
903
904impl DeepSizeOf for FMIndex {
905    fn deep_size_of_children(&self, _context: &mut lance_core::deepsize::Context) -> usize {
906        self.wavelet.deep_size()
907            + self.row_ids.len() * 8
908            + self.sa_samples.len() * 8
909            + self.doc_start_positions.len() * 8
910            + self.c_table.len() * std::mem::size_of::<usize>()
911    }
912}
913
914impl FMIndex {
915    fn build(texts: &[(u64, &[u8])]) -> Result<Self> {
916        if texts.is_empty() {
917            return Ok(Self {
918                wavelet: HuffmanWaveletTree {
919                    nodes: Vec::new(),
920                    codes: std::array::from_fn(|_| HuffmanCode::default()),
921                    children: Vec::new(),
922                    len: 0,
923                },
924                row_ids: Vec::new(),
925                sa_samples: Vec::new(),
926                doc_start_positions: Vec::new(),
927                c_table: vec![0; 257],
928                alphabet_size: 256,
929            });
930        }
931
932        let mut concat = Vec::new();
933        let mut doc_row_ids = Vec::new();
934        let mut doc_starts: Vec<u64> = Vec::new();
935        for (row_id, text) in texts {
936            doc_starts.push(concat.len() as u64);
937            doc_row_ids.push(*row_id);
938            concat.extend_from_slice(text);
939            concat.push(SENTINEL_BYTE); // \xFF separator between documents
940        }
941        // Append unique terminator \x00 so SA-IS produces a proper suffix array
942        // with a single-cycle LF-mapping permutation.
943        concat.push(0x00);
944        let n = concat.len();
945        let sa = build_suffix_array(&concat);
946
947        let bwt: Vec<u8> = sa
948            .iter()
949            .map(|&pos| {
950                if pos == 0 {
951                    concat[n - 1]
952                } else {
953                    concat[pos - 1]
954                }
955            })
956            .collect();
957
958        let mut counts = vec![0usize; 257];
959        for &b in &concat {
960            counts[b as usize + 1] += 1;
961        }
962        for i in 1..257 {
963            counts[i] += counts[i - 1];
964        }
965
966        // Sampled SA: store every D-th entry
967        let sa_samples: Vec<u64> = sa
968            .iter()
969            .step_by(SA_SAMPLE_RATE)
970            .map(|&pos| pos as u64)
971            .collect();
972
973        let wavelet = HuffmanWaveletTree::build(&bwt);
974
975        Ok(Self {
976            wavelet,
977            row_ids: doc_row_ids,
978            sa_samples,
979            doc_start_positions: doc_starts,
980            c_table: counts,
981            alphabet_size: 256,
982        })
983    }
984
985    fn serialize_huffman_codes(&self) -> Vec<u8> {
986        let mut buf = Vec::new();
987        for code in &self.wavelet.codes {
988            buf.extend_from_slice(&code.bits.to_le_bytes());
989            buf.push(code.length);
990            buf.extend_from_slice(&(code.node_path.len() as u16).to_le_bytes());
991            for &nid in &code.node_path {
992                buf.extend_from_slice(&(nid as u32).to_le_bytes());
993            }
994        }
995        buf
996    }
997
998    fn deserialize_huffman_codes(data: &[u8]) -> [HuffmanCode; 256] {
999        let mut codes: [HuffmanCode; 256] = std::array::from_fn(|_| HuffmanCode::default());
1000        let mut cur = 0;
1001        for code in &mut codes {
1002            let bits = u32::from_le_bytes(data[cur..cur + 4].try_into().unwrap());
1003            cur += 4;
1004            let length = data[cur];
1005            cur += 1;
1006            let plen = u16::from_le_bytes(data[cur..cur + 2].try_into().unwrap()) as usize;
1007            cur += 2;
1008            let mut node_path = Vec::with_capacity(plen);
1009            for _ in 0..plen {
1010                node_path.push(u32::from_le_bytes(data[cur..cur + 4].try_into().unwrap()) as usize);
1011                cur += 4;
1012            }
1013            *code = HuffmanCode {
1014                bits,
1015                length,
1016                node_path,
1017            };
1018        }
1019        codes
1020    }
1021
1022    fn serialize_tree_topology(&self) -> Vec<u8> {
1023        let mut buf = Vec::new();
1024        buf.extend_from_slice(&(self.wavelet.children.len() as u32).to_le_bytes());
1025        for (left, right) in &self.wavelet.children {
1026            for child in [left, right] {
1027                match child {
1028                    WaveletChild::Node(id) => {
1029                        buf.push(0);
1030                        buf.extend_from_slice(&(*id as u32).to_le_bytes());
1031                    }
1032                    WaveletChild::Leaf(b) => {
1033                        buf.push(1);
1034                        buf.extend_from_slice(&(*b as u32).to_le_bytes());
1035                    }
1036                }
1037            }
1038        }
1039        buf
1040    }
1041
1042    fn deserialize_tree_topology(data: &[u8]) -> Vec<(WaveletChild, WaveletChild)> {
1043        let mut cur = 0;
1044        let count = u32::from_le_bytes(data[cur..cur + 4].try_into().unwrap()) as usize;
1045        cur += 4;
1046        let mut children = Vec::with_capacity(count);
1047        for _ in 0..count {
1048            let mut read_child = || {
1049                let t = data[cur];
1050                cur += 1;
1051                let v = u32::from_le_bytes(data[cur..cur + 4].try_into().unwrap());
1052                cur += 4;
1053                if t == 0 {
1054                    WaveletChild::Node(v as usize)
1055                } else {
1056                    WaveletChild::Leaf(v as u8)
1057                }
1058            };
1059            let l = read_child();
1060            let r = read_child();
1061            children.push((l, r));
1062        }
1063        children
1064    }
1065
1066    fn serialize_c_table(&self) -> Vec<u8> {
1067        self.c_table
1068            .iter()
1069            .flat_map(|&v| (v as u64).to_le_bytes())
1070            .collect()
1071    }
1072
1073    fn deserialize_c_table(data: &[u8]) -> Vec<usize> {
1074        data.chunks_exact(8)
1075            .map(|c| u64::from_le_bytes(c.try_into().unwrap()) as usize)
1076            .collect()
1077    }
1078
1079    fn u64_to_bytes(data: &[u64]) -> Vec<u8> {
1080        data.iter().flat_map(|v| v.to_le_bytes()).collect()
1081    }
1082
1083    fn build_wavelet_batch(&self) -> Result<RecordBatch> {
1084        use arrow_array::{LargeBinaryArray, UInt32Array, UInt64Array};
1085        let mut nid_b = Vec::new();
1086        let mut bid_b = Vec::new();
1087        let mut words_b: Vec<Vec<u8>> = Vec::new();
1088        let mut pr_b = Vec::new();
1089        let mut bl_b = Vec::new();
1090
1091        for (i, node) in self.wavelet.nodes.iter().enumerate() {
1092            let mut pr: u64 = 0;
1093            if node.words.is_empty() {
1094                nid_b.push(i as u32);
1095                bid_b.push(0u32);
1096                words_b.push(Vec::new());
1097                pr_b.push(0u64);
1098                bl_b.push(node.len as u64);
1099            } else {
1100                for (bi, chunk) in node.words.chunks(BLOCK_WORDS).enumerate() {
1101                    nid_b.push(i as u32);
1102                    bid_b.push(bi as u32);
1103                    words_b.push(Self::u64_to_bytes(chunk));
1104                    pr_b.push(pr);
1105                    bl_b.push(node.len as u64);
1106                    pr += chunk.iter().map(|w| w.count_ones() as u64).sum::<u64>();
1107                }
1108            }
1109        }
1110        let refs: Vec<&[u8]> = words_b.iter().map(|v| v.as_slice()).collect();
1111        let schema = Arc::new(Self::block_schema());
1112        Ok(RecordBatch::try_new(
1113            schema,
1114            vec![
1115                Arc::new(UInt32Array::from(nid_b)),
1116                Arc::new(UInt32Array::from(bid_b)),
1117                Arc::new(LargeBinaryArray::from(refs)),
1118                Arc::new(UInt64Array::from(pr_b)),
1119                Arc::new(UInt64Array::from(bl_b)),
1120            ],
1121        )?)
1122    }
1123
1124    fn block_schema() -> arrow_schema::Schema {
1125        arrow_schema::Schema::new(vec![
1126            Field::new("node_id", DataType::UInt32, false),
1127            Field::new("block_id", DataType::UInt32, false),
1128            Field::new("words", DataType::LargeBinary, false),
1129            Field::new("prefix_rank", DataType::UInt64, false),
1130            Field::new("bit_len", DataType::UInt64, false),
1131        ])
1132    }
1133}
1134
1135// ── Lazy FM-Index ────────────────────────────────────────────────────────────
1136
1137#[derive(Debug)]
1138struct LazyFMIndex {
1139    wavelet: LazyHuffmanWaveletTree,
1140    row_ids: Vec<u64>,
1141    sa_samples: Vec<u64>,
1142    doc_start_positions: Vec<u64>,
1143    c_table: Vec<usize>,
1144    fully_prewarmed: AtomicBool,
1145}
1146
1147impl LazyFMIndex {
1148    /// Pre-load all wavelet tree blocks before sync search operations.
1149    async fn prewarm(&self) -> Result<()> {
1150        if self.fully_prewarmed.load(Ordering::Acquire) {
1151            return Ok(());
1152        }
1153        self.wavelet.load_all().await.inspect(|_| {
1154            self.fully_prewarmed.store(true, Ordering::Release);
1155        })
1156    }
1157
1158    fn backward_search(&self, pattern: &[u8]) -> (usize, usize) {
1159        if pattern.is_empty() || self.wavelet.len == 0 {
1160            return (0, 0);
1161        }
1162        let (mut lo, mut hi) = (0, self.wavelet.len);
1163        for &b in pattern.iter().rev() {
1164            let c = self.c_table[b as usize];
1165            let (occ_lo, occ_hi) = self.wavelet.rank_pair(b, lo, hi);
1166            lo = c + occ_lo;
1167            hi = c + occ_hi;
1168            if lo >= hi {
1169                return (0, 0);
1170            }
1171        }
1172        (lo, hi)
1173    }
1174
1175    async fn backward_search_async(&self, pattern: &[u8]) -> Result<(usize, usize)> {
1176        if pattern.is_empty() || self.wavelet.len == 0 {
1177            return Ok((0, 0));
1178        }
1179        let (mut lo, mut hi) = (0, self.wavelet.len);
1180        for &b in pattern.iter().rev() {
1181            let c = self.c_table[b as usize];
1182            let (occ_lo, occ_hi) = self.wavelet.rank_pair_async(b, lo, hi).await?;
1183            lo = c + occ_lo;
1184            hi = c + occ_hi;
1185            if lo >= hi {
1186                return Ok((0, 0));
1187            }
1188        }
1189        Ok((lo, hi))
1190    }
1191
1192    #[inline]
1193    fn locate(&self, mut pos: usize) -> usize {
1194        let mut steps = 0;
1195        let n = self.wavelet.len;
1196        loop {
1197            if pos.is_multiple_of(SA_SAMPLE_RATE) && (pos / SA_SAMPLE_RATE) < self.sa_samples.len()
1198            {
1199                return (self.sa_samples[pos / SA_SAMPLE_RATE] as usize + steps) % n;
1200            }
1201            let c = self.wavelet.access(pos);
1202            pos = self.c_table[c as usize] + self.wavelet.rank(c, pos);
1203            steps += 1;
1204            if steps >= n {
1205                log::warn!("FM-Index SA locate exceeded {n} steps, possible index corruption");
1206                return 0;
1207            }
1208        }
1209    }
1210
1211    async fn locate_async(&self, mut pos: usize) -> Result<usize> {
1212        let mut steps = 0;
1213        let n = self.wavelet.len;
1214        loop {
1215            if pos.is_multiple_of(SA_SAMPLE_RATE) && (pos / SA_SAMPLE_RATE) < self.sa_samples.len()
1216            {
1217                return Ok((self.sa_samples[pos / SA_SAMPLE_RATE] as usize + steps) % n);
1218            }
1219            let c = self.wavelet.access_async(pos).await?;
1220            pos = self.c_table[c as usize] + self.wavelet.rank_async(c, pos).await?;
1221            steps += 1;
1222            if steps >= n {
1223                log::warn!("FM-Index SA locate exceeded {n} steps, possible index corruption");
1224                return Ok(0);
1225            }
1226        }
1227    }
1228
1229    #[inline]
1230    fn doc_for_position(&self, text_pos: usize) -> usize {
1231        let tp = text_pos as u64;
1232        match self.doc_start_positions.binary_search(&tp) {
1233            Ok(idx) => idx,
1234            Err(idx) => idx - 1,
1235        }
1236    }
1237
1238    #[cfg(test)]
1239    fn search(&self, pattern: &[u8]) -> RoaringBitmap {
1240        let (lo, hi) = self.backward_search(pattern);
1241        if lo >= hi {
1242            return RoaringBitmap::new();
1243        }
1244        let mut result = RoaringBitmap::new();
1245        for i in lo..hi {
1246            let text_pos = self.locate(i);
1247            let doc_idx = self.doc_for_position(text_pos);
1248            result.insert(self.row_ids[doc_idx] as u32);
1249        }
1250        result
1251    }
1252
1253    /// Search returning full u64 row addresses (preserving fragment ID in upper bits).
1254    fn search_row_addrs(&self, pattern: &[u8]) -> Vec<u64> {
1255        let (lo, hi) = self.backward_search(pattern);
1256        if lo >= hi {
1257            return Vec::new();
1258        }
1259        let mut seen = std::collections::HashSet::new();
1260        let mut result = Vec::new();
1261        for i in lo..hi {
1262            let text_pos = self.locate(i);
1263            let doc_idx = self.doc_for_position(text_pos);
1264            let row_addr = self.row_ids[doc_idx];
1265            if seen.insert(row_addr) {
1266                result.push(row_addr);
1267            }
1268        }
1269        result
1270    }
1271
1272    async fn search_row_addrs_async(&self, pattern: &[u8]) -> Result<Vec<u64>> {
1273        let (lo, hi) = self.backward_search_async(pattern).await?;
1274        if lo >= hi {
1275            return Ok(Vec::new());
1276        }
1277        let mut seen = std::collections::HashSet::new();
1278        let mut result = Vec::new();
1279        for i in lo..hi {
1280            let text_pos = self.locate_async(i).await?;
1281            let doc_idx = self.doc_for_position(text_pos);
1282            let row_addr = self.row_ids[doc_idx];
1283            if seen.insert(row_addr) {
1284                result.push(row_addr);
1285            }
1286        }
1287        Ok(result)
1288    }
1289
1290    #[allow(clippy::too_many_arguments)]
1291    async fn from_reader(
1292        reader: Arc<dyn crate::scalar::IndexReader>,
1293        num_bwt_nodes: usize,
1294        huffman_codes: [HuffmanCode; 256],
1295        children: Vec<(WaveletChild, WaveletChild)>,
1296        c_table: Vec<usize>,
1297        bwt_len: usize,
1298        total_wavelet_rows: usize,
1299        num_sa_blocks: usize,
1300        sa_samples_len: usize,
1301        row_ids: Vec<u64>,
1302        doc_start_positions: Vec<u64>,
1303    ) -> Result<Self> {
1304        use arrow_array::UInt64Array;
1305
1306        let meta = reader
1307            .read_range(
1308                0..total_wavelet_rows,
1309                Some(&["node_id", "prefix_rank", "bit_len"]),
1310            )
1311            .await?;
1312        let nid_col = meta
1313            .column_by_name("node_id")
1314            .unwrap()
1315            .as_any()
1316            .downcast_ref::<arrow_array::UInt32Array>()
1317            .unwrap();
1318        let pr_col = meta
1319            .column_by_name("prefix_rank")
1320            .unwrap()
1321            .as_any()
1322            .downcast_ref::<UInt64Array>()
1323            .unwrap();
1324        let bl_col = meta
1325            .column_by_name("bit_len")
1326            .unwrap()
1327            .as_any()
1328            .downcast_ref::<UInt64Array>()
1329            .unwrap();
1330
1331        struct NM {
1332            prs: Vec<u64>,
1333            offset: usize,
1334            blen: usize,
1335        }
1336        let mut nms: Vec<NM> = (0..num_bwt_nodes)
1337            .map(|_| NM {
1338                prs: Vec::new(),
1339                offset: 0,
1340                blen: 0,
1341            })
1342            .collect();
1343        for row in 0..meta.num_rows() {
1344            let nid = nid_col.value(row) as usize;
1345            if nid >= num_bwt_nodes {
1346                continue;
1347            }
1348            let nm = &mut nms[nid];
1349            if nm.prs.is_empty() {
1350                nm.offset = row;
1351            }
1352            nm.prs.push(pr_col.value(row));
1353            nm.blen = bl_col.value(row) as usize;
1354        }
1355
1356        let mut bwt_nodes = Vec::with_capacity(num_bwt_nodes);
1357        for nm in &nms {
1358            bwt_nodes.push(LazyRankBitVec::new(
1359                nm.prs.clone(),
1360                nm.prs.len(),
1361                reader.clone(),
1362                nm.offset,
1363                nm.blen,
1364            ));
1365        }
1366        let wavelet = LazyHuffmanWaveletTree {
1367            nodes: bwt_nodes,
1368            codes: huffman_codes,
1369            children,
1370            len: bwt_len,
1371        };
1372
1373        // Read SA samples from packed binary blocks
1374        let mut sa_samples = Vec::with_capacity(sa_samples_len);
1375        let sa_batch = reader
1376            .read_range(
1377                total_wavelet_rows..total_wavelet_rows + num_sa_blocks,
1378                Some(&["words"]),
1379            )
1380            .await?;
1381        let words_col = sa_batch
1382            .column_by_name("words")
1383            .unwrap()
1384            .as_any()
1385            .downcast_ref::<arrow_array::LargeBinaryArray>()
1386            .unwrap();
1387        for i in 0..sa_batch.num_rows() {
1388            let raw = words_col.value(i);
1389            for chunk in raw.chunks_exact(8) {
1390                sa_samples.push(u64::from_le_bytes(chunk.try_into().unwrap()));
1391            }
1392        }
1393        sa_samples.truncate(sa_samples_len);
1394
1395        Ok(Self {
1396            wavelet,
1397            row_ids,
1398            sa_samples,
1399            doc_start_positions,
1400            c_table,
1401            fully_prewarmed: AtomicBool::new(false),
1402        })
1403    }
1404
1405    fn deep_size(&self) -> usize {
1406        self.wavelet.deep_size()
1407            + self.row_ids.len() * 8
1408            + self.sa_samples.len() * 8
1409            + self.doc_start_positions.len() * 8
1410            + self.c_table.len() * std::mem::size_of::<usize>()
1411    }
1412}
1413
1414// ── FMIndexScalarIndex ───────────────────────────────────────────────────────
1415
1416#[derive(Debug)]
1417struct FMIndexPartition {
1418    #[allow(dead_code)]
1419    id: u64,
1420    fm: LazyFMIndex,
1421}
1422
1423#[derive(Debug)]
1424pub struct FMIndexScalarIndex {
1425    partitions: Vec<Arc<FMIndexPartition>>,
1426    io_parallelism: usize,
1427}
1428
1429impl DeepSizeOf for FMIndexScalarIndex {
1430    fn deep_size_of_children(&self, _ctx: &mut lance_core::deepsize::Context) -> usize {
1431        self.partitions.iter().map(|p| p.fm.deep_size()).sum()
1432    }
1433}
1434
1435impl FMIndexScalarIndex {
1436    async fn load_partition(
1437        store: &dyn IndexStore,
1438        filename: &str,
1439        pid: u64,
1440    ) -> Result<FMIndexPartition> {
1441        let reader = store.open_index_file(filename).await?;
1442        let md = &reader.schema().metadata;
1443
1444        let parse = |key: &str| -> Result<usize> {
1445            md.get(key)
1446                .ok_or_else(|| Error::invalid_input(format!("missing {key}")))?
1447                .parse()
1448                .map_err(|e| Error::invalid_input(format!("invalid {key}: {e}")))
1449        };
1450
1451        let num_bwt_nodes = parse("num_bwt_nodes")?;
1452        let bwt_len = parse("bwt_len")?;
1453        let num_sa_blocks = parse("num_sa_blocks")?;
1454        let sa_samples_len = parse("sa_samples_len")?;
1455        let total_wavelet_rows = parse("total_wavelet_rows")?;
1456
1457        let c_table = FMIndex::deserialize_c_table(&hex_decode(
1458            md.get("c_table")
1459                .ok_or_else(|| Error::invalid_input("missing c_table"))?,
1460        )?);
1461        let huffman_codes = FMIndex::deserialize_huffman_codes(&hex_decode(
1462            md.get("huffman_codes")
1463                .ok_or_else(|| Error::invalid_input("missing huffman_codes"))?,
1464        )?);
1465        let children = FMIndex::deserialize_tree_topology(&hex_decode(
1466            md.get("tree_topology")
1467                .ok_or_else(|| Error::invalid_input("missing tree_topology"))?,
1468        )?);
1469
1470        // row_ids and doc_start_positions stored in metadata (small)
1471        let row_ids_hex = md
1472            .get("row_ids")
1473            .ok_or_else(|| Error::invalid_input("missing row_ids"))?;
1474        let row_ids_bytes = hex_decode(row_ids_hex)?;
1475        let row_ids: Vec<u64> = row_ids_bytes
1476            .chunks_exact(8)
1477            .map(|c| u64::from_le_bytes(c.try_into().unwrap()))
1478            .collect();
1479
1480        let doc_starts_hex = md
1481            .get("doc_start_positions")
1482            .ok_or_else(|| Error::invalid_input("missing doc_start_positions"))?;
1483        let doc_starts_bytes = hex_decode(doc_starts_hex)?;
1484        let doc_start_positions: Vec<u64> = doc_starts_bytes
1485            .chunks_exact(8)
1486            .map(|c| u64::from_le_bytes(c.try_into().unwrap()))
1487            .collect();
1488
1489        let fm = Box::pin(LazyFMIndex::from_reader(
1490            reader,
1491            num_bwt_nodes,
1492            huffman_codes,
1493            children,
1494            c_table,
1495            bwt_len,
1496            total_wavelet_rows,
1497            num_sa_blocks,
1498            sa_samples_len,
1499            row_ids,
1500            doc_start_positions,
1501        ))
1502        .await?;
1503        Ok(FMIndexPartition { id: pid, fm })
1504    }
1505
1506    async fn load(
1507        store: Arc<dyn IndexStore>,
1508        _fri: Option<Arc<dyn RowIdRemapper>>,
1509        _cache: &LanceCache,
1510    ) -> Result<Arc<Self>> {
1511        let files = store.list_files_with_sizes().await?;
1512        let mut pfiles: Vec<(u64, String)> = Vec::new();
1513        for f in &files {
1514            if let Some(id) = fmindex_partition_id_from_path(&f.path) {
1515                pfiles.push((id, f.path.clone()));
1516            }
1517        }
1518        if pfiles.is_empty() {
1519            return Err(Error::invalid_input("no FM-Index partition files found"));
1520        }
1521        pfiles.sort_by_key(|(id, _)| *id);
1522        let io_parallelism = store.io_parallelism().max(1);
1523        let mut parts = futures::stream::iter(pfiles)
1524            .map(|(id, name)| {
1525                let store = Arc::clone(&store);
1526                async move {
1527                    let partition = Self::load_partition(store.as_ref(), &name, id).await?;
1528                    Result::<_>::Ok((id, Arc::new(partition)))
1529                }
1530            })
1531            .buffer_unordered(io_parallelism)
1532            .try_collect::<Vec<_>>()
1533            .await?;
1534        parts.sort_by_key(|(id, _)| *id);
1535        let parts = parts
1536            .into_iter()
1537            .map(|(_, partition)| partition)
1538            .collect::<Vec<_>>();
1539        Ok(Arc::new(Self {
1540            partitions: parts,
1541            io_parallelism,
1542        }))
1543    }
1544
1545    fn partition_parallelism(&self) -> usize {
1546        self.io_parallelism.max(1).min(self.partitions.len().max(1))
1547    }
1548
1549    async fn prewarm_partitions(&self) -> Result<()> {
1550        futures::stream::iter(self.partitions.iter().cloned())
1551            .map(|partition| async move { partition.fm.prewarm().await })
1552            .buffer_unordered(self.partition_parallelism())
1553            .try_collect::<Vec<_>>()
1554            .await?;
1555        Ok(())
1556    }
1557
1558    async fn search_string_contains(&self, pattern: &[u8]) -> Result<SearchResult> {
1559        use lance_select::RowAddrTreeMap;
1560
1561        let pattern: Arc<[u8]> = Arc::from(pattern);
1562        let tree = futures::stream::iter(self.partitions.iter().cloned())
1563            .map(|partition| {
1564                let pattern = Arc::clone(&pattern);
1565                async move {
1566                    if partition.fm.fully_prewarmed.load(Ordering::Acquire) {
1567                        spawn_cpu(move || {
1568                            Result::<Vec<u64>>::Ok(partition.fm.search_row_addrs(pattern.as_ref()))
1569                        })
1570                        .await
1571                    } else {
1572                        partition.fm.search_row_addrs_async(pattern.as_ref()).await
1573                    }
1574                }
1575            })
1576            .buffer_unordered(self.partition_parallelism())
1577            .try_fold(RowAddrTreeMap::new(), |mut tree, row_addrs| async move {
1578                for row_addr in row_addrs {
1579                    tree.insert(row_addr);
1580                }
1581                Result::Ok(tree)
1582            })
1583            .await?;
1584
1585        Ok(SearchResult::Exact(lance_select::NullableRowAddrSet::new(
1586            tree,
1587            Default::default(),
1588        )))
1589    }
1590}
1591
1592#[async_trait]
1593impl Index for FMIndexScalarIndex {
1594    fn as_any(&self) -> &dyn std::any::Any {
1595        self
1596    }
1597    fn as_index(self: Arc<Self>) -> Arc<dyn Index> {
1598        self
1599    }
1600    async fn prewarm(&self) -> Result<()> {
1601        self.prewarm_partitions().await
1602    }
1603    fn statistics(&self) -> Result<serde_json::Value> {
1604        Ok(serde_json::json!({
1605            "type": "Fm",
1606            "num_partitions": self.partitions.len(),
1607            "total_bwt_len": self.partitions.iter().map(|p| p.fm.wavelet.len).sum::<usize>(),
1608            "total_docs": self.partitions.iter().map(|p| p.fm.row_ids.len()).sum::<usize>(),
1609        }))
1610    }
1611    fn index_type(&self) -> IndexType {
1612        IndexType::Fm
1613    }
1614    async fn calculate_included_frags(&self) -> Result<RoaringBitmap> {
1615        let mut frags = RoaringBitmap::new();
1616        for p in &self.partitions {
1617            for &rid in &p.fm.row_ids {
1618                frags.insert((rid >> 32) as u32);
1619            }
1620        }
1621        Ok(frags)
1622    }
1623}
1624
1625#[async_trait]
1626impl ScalarIndex for FMIndexScalarIndex {
1627    async fn search(
1628        &self,
1629        query: &dyn AnyQuery,
1630        _metrics: &dyn MetricsCollector,
1631    ) -> Result<SearchResult> {
1632        let tq = query
1633            .as_any()
1634            .downcast_ref::<TextQuery>()
1635            .ok_or_else(|| Error::invalid_input("Fm only supports TextQuery"))?;
1636        match tq {
1637            TextQuery::StringContains(pattern) => {
1638                self.search_string_contains(pattern.as_bytes()).await
1639            }
1640            // Regex queries are routed only to the ngram index (the FM-index's
1641            // query parser advertises `supports_regex = false`), so this is
1642            // unreachable in practice; reject it explicitly rather than silently.
1643            TextQuery::Regex(_) => Err(Error::invalid_input(
1644                "FMIndex does not support regular expression queries",
1645            )),
1646        }
1647    }
1648    fn can_remap(&self) -> bool {
1649        false
1650    }
1651    async fn remap(&self, _: &RowAddrRemap, _: &dyn IndexStore) -> Result<CreatedIndex> {
1652        Err(Error::not_supported("Fm does not support remap"))
1653    }
1654    async fn update(
1655        &self,
1656        new_data: SendableRecordBatchStream,
1657        dest: &dyn IndexStore,
1658        _old_data_filter: Option<OldIndexDataFilter>,
1659    ) -> Result<CreatedIndex> {
1660        let files = write_partitioned_fmindex_stream(new_data, dest).await?;
1661        Ok(CreatedIndex {
1662            index_details: prost_types::Any::from_msg(&pb::FmIndexDetails {}).unwrap(),
1663            index_version: FMINDEX_INDEX_VERSION,
1664            files,
1665        })
1666    }
1667    fn update_criteria(&self) -> UpdateCriteria {
1668        UpdateCriteria::requires_old_data(
1669            TrainingCriteria::new(TrainingOrdering::None).with_row_addr(),
1670        )
1671    }
1672    fn derive_index_params(&self) -> Result<ScalarIndexParams> {
1673        Ok(ScalarIndexParams::for_builtin(BuiltinIndexType::Fm))
1674    }
1675}
1676
1677// ── Helpers ──────────────────────────────────────────────────────────────────
1678
1679#[derive(Debug)]
1680struct FMIndexPartitionJob {
1681    partition_id: u64,
1682    texts: Vec<(u64, Vec<u8>)>,
1683}
1684
1685#[derive(Debug, Clone, Copy)]
1686struct FMIndexPartitionConfig {
1687    num_workers: usize,
1688    max_rows: usize,
1689    max_bytes: usize,
1690    queue_size: usize,
1691    resume_existing: bool,
1692}
1693
1694impl FMIndexPartitionConfig {
1695    fn from_env() -> Self {
1696        Self {
1697            num_workers: fmindex_num_workers(),
1698            max_rows: fmindex_partition_rows(),
1699            max_bytes: fmindex_partition_bytes(),
1700            queue_size: fmindex_write_queue_size(),
1701            resume_existing: fmindex_resume_existing_partitions(),
1702        }
1703    }
1704
1705    fn normalized(self) -> Self {
1706        Self {
1707            num_workers: self.num_workers.max(1),
1708            max_rows: self.max_rows.max(1),
1709            max_bytes: self.max_bytes.max(1),
1710            queue_size: self.queue_size.max(1),
1711            resume_existing: self.resume_existing,
1712        }
1713    }
1714}
1715
1716async fn write_partitioned_fmindex_stream(
1717    stream: SendableRecordBatchStream,
1718    store: &dyn IndexStore,
1719) -> Result<Vec<IndexFile>> {
1720    write_partitioned_fmindex_stream_with_config(stream, store, FMIndexPartitionConfig::from_env())
1721        .await
1722}
1723
1724async fn write_partitioned_fmindex_stream_with_config(
1725    mut stream: SendableRecordBatchStream,
1726    store: &dyn IndexStore,
1727    config: FMIndexPartitionConfig,
1728) -> Result<Vec<IndexFile>> {
1729    let config = config.normalized();
1730    log::info!(
1731        "building FMIndex with {} workers, partition rows {}, partition bytes {}",
1732        config.num_workers,
1733        config.max_rows,
1734        config.max_bytes
1735    );
1736
1737    let (sender, receiver): (
1738        async_channel::Sender<FMIndexPartitionJob>,
1739        async_channel::Receiver<FMIndexPartitionJob>,
1740    ) = async_channel::bounded(config.queue_size);
1741    let store = store.clone_arc();
1742    let mut completed_files = if config.resume_existing {
1743        store
1744            .list_files_with_sizes()
1745            .await?
1746            .into_iter()
1747            .filter_map(|file| fmindex_partition_id_from_path(&file.path).map(|id| (id, file)))
1748            .collect::<HashMap<_, _>>()
1749    } else {
1750        HashMap::new()
1751    };
1752    if !completed_files.is_empty() {
1753        log::info!(
1754            "resuming FMIndex build with {} existing partition files",
1755            completed_files.len()
1756        );
1757    }
1758    let mut files = Vec::new();
1759    let mut worker_tasks = Vec::with_capacity(config.num_workers);
1760    for _ in 0..config.num_workers {
1761        let receiver = receiver.clone();
1762        let store = store.clone();
1763        worker_tasks.push(tokio::task::spawn(async move {
1764            let mut files = Vec::new();
1765            while let Ok(job) = receiver.recv().await {
1766                files.push(
1767                    write_fmindex_partition_owned(job.texts, store.clone(), job.partition_id)
1768                        .await?,
1769                );
1770            }
1771            Result::Ok(files)
1772        }));
1773    }
1774    drop(receiver);
1775
1776    let producer_result = async {
1777        let mut partition = Vec::with_capacity(config.max_rows.min(PARTITION_SIZE));
1778        let mut partition_bytes = 0usize;
1779        let mut partition_id = 0;
1780
1781        while let Some(batch) = stream.next().await {
1782            let batch = batch?;
1783            // Prefer _rowaddr (global row address) over _rowid to ensure stable,
1784            // globally unique identifiers across segments.
1785            let row_addrs: &arrow_array::UInt64Array = batch
1786                .column_by_name(ROW_ADDR)
1787                .or_else(|| batch.column_by_name("_rowid"))
1788                .and_then(|c| c.as_any().downcast_ref())
1789                .ok_or_else(|| {
1790                    Error::invalid_input("Fm training data must include _rowaddr or _rowid column")
1791                })?;
1792            // Use the named value column; fall back to column(0) for legacy streams
1793            let value_col = batch
1794                .column_by_name(VALUE_COLUMN_NAME)
1795                .unwrap_or_else(|| batch.column(0));
1796            for i in 0..batch.num_rows() {
1797                let rid = row_addrs.value(i);
1798                if let Some(bytes) = extract_sanitized_text_bytes(value_col.as_ref(), i)? {
1799                    partition_bytes = partition_bytes.saturating_add(bytes.len().saturating_add(1));
1800                    partition.push((rid, bytes));
1801                    if fmindex_partition_limit_reached(
1802                        partition.len(),
1803                        partition_bytes,
1804                        config.max_rows,
1805                        config.max_bytes,
1806                    ) {
1807                        finish_fmindex_partition(
1808                            &sender,
1809                            store.as_ref(),
1810                            &mut partition,
1811                            &mut partition_bytes,
1812                            partition_id,
1813                            config.max_rows,
1814                            &mut FMIndexPartitionResumeState {
1815                                completed_files: &mut completed_files,
1816                                output_files: &mut files,
1817                            },
1818                        )
1819                        .await?;
1820                        partition_id += 1;
1821                    }
1822                }
1823            }
1824        }
1825
1826        if !partition.is_empty() {
1827            finish_fmindex_partition(
1828                &sender,
1829                store.as_ref(),
1830                &mut partition,
1831                &mut partition_bytes,
1832                partition_id,
1833                config.max_rows,
1834                &mut FMIndexPartitionResumeState {
1835                    completed_files: &mut completed_files,
1836                    output_files: &mut files,
1837                },
1838            )
1839            .await?;
1840        }
1841
1842        Result::Ok(())
1843    }
1844    .await;
1845    drop(sender);
1846
1847    let mut worker_error = None;
1848    for worker_task in worker_tasks {
1849        match worker_task.await {
1850            Ok(Ok(worker_files)) => files.extend(worker_files),
1851            Ok(Err(err)) => {
1852                if worker_error.is_none() {
1853                    worker_error = Some(err);
1854                }
1855            }
1856            Err(err) => {
1857                if worker_error.is_none() {
1858                    worker_error = Some(Error::execution(format!(
1859                        "FMIndex partition worker failed: {err}"
1860                    )));
1861                }
1862            }
1863        }
1864    }
1865    if let Some(err) = worker_error {
1866        return Err(err);
1867    }
1868    producer_result?;
1869
1870    for (partition_id, file) in completed_files.drain() {
1871        log::info!(
1872            "deleting stale FMIndex partition {partition_id} from previous build: {}",
1873            file.path
1874        );
1875        store.delete_index_file(&file.path).await?;
1876    }
1877    if files.is_empty() {
1878        return Ok(vec![write_empty_fmindex_partition(store.as_ref()).await?]);
1879    }
1880
1881    files.sort_unstable_by_key(|(partition_id, _)| *partition_id);
1882    let files = files.into_iter().map(|(_, file)| file).collect();
1883
1884    Ok(files)
1885}
1886
1887fn fmindex_partition_limit_reached(
1888    rows: usize,
1889    bytes: usize,
1890    max_rows: usize,
1891    max_bytes: usize,
1892) -> bool {
1893    // Byte limits are soft for a single oversized document because FMIndex
1894    // partitions cannot split a document without changing search semantics.
1895    rows >= max_rows || bytes >= max_bytes
1896}
1897
1898struct FMIndexPartitionResumeState<'a> {
1899    completed_files: &'a mut HashMap<u64, IndexFile>,
1900    output_files: &'a mut Vec<(u64, IndexFile)>,
1901}
1902
1903async fn finish_fmindex_partition(
1904    sender: &async_channel::Sender<FMIndexPartitionJob>,
1905    store: &dyn IndexStore,
1906    partition: &mut Vec<(u64, Vec<u8>)>,
1907    partition_bytes: &mut usize,
1908    partition_id: u64,
1909    max_rows: usize,
1910    resume_state: &mut FMIndexPartitionResumeState<'_>,
1911) -> Result<()> {
1912    let texts = std::mem::replace(partition, Vec::with_capacity(max_rows.min(PARTITION_SIZE)));
1913    *partition_bytes = 0;
1914    if let Some(file) = resume_state.completed_files.remove(&partition_id) {
1915        let fingerprint = fmindex_partition_fingerprint(&texts);
1916        if fmindex_partition_matches(store, &file, &fingerprint).await? {
1917            resume_state.output_files.push((partition_id, file));
1918            return Ok(());
1919        }
1920        log::info!(
1921            "rebuilding stale FMIndex partition {partition_id}: {}",
1922            file.path
1923        );
1924        store.delete_index_file(&file.path).await?;
1925    }
1926    sender
1927        .send(FMIndexPartitionJob {
1928            partition_id,
1929            texts,
1930        })
1931        .await
1932        .map_err(|err| {
1933            Error::execution(format!(
1934                "failed to schedule FMIndex partition {partition_id}: {err}"
1935            ))
1936        })
1937}
1938
1939fn fmindex_partition_fingerprint(texts: &[(u64, Vec<u8>)]) -> String {
1940    const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
1941    const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
1942
1943    fn update(hash: &mut u64, bytes: &[u8]) {
1944        for byte in bytes {
1945            *hash ^= *byte as u64;
1946            *hash = hash.wrapping_mul(FNV_PRIME);
1947        }
1948    }
1949
1950    let mut hash = FNV_OFFSET;
1951    update(&mut hash, b"lance-fmindex-partition-v1");
1952    update(&mut hash, &(texts.len() as u64).to_le_bytes());
1953    for (row_id, bytes) in texts {
1954        update(&mut hash, &row_id.to_le_bytes());
1955        update(&mut hash, &(bytes.len() as u64).to_le_bytes());
1956        update(&mut hash, bytes);
1957    }
1958    format!("{hash:016x}")
1959}
1960
1961async fn fmindex_partition_matches(
1962    store: &dyn IndexStore,
1963    file: &IndexFile,
1964    expected_fingerprint: &str,
1965) -> Result<bool> {
1966    let reader = store.open_index_file(&file.path).await?;
1967    Ok(reader
1968        .schema()
1969        .metadata
1970        .get(FMINDEX_PARTITION_FINGERPRINT_KEY)
1971        .is_some_and(|fingerprint| fingerprint == expected_fingerprint))
1972}
1973
1974fn sanitize_text_bytes(bytes: &[u8]) -> Vec<u8> {
1975    bytes
1976        .iter()
1977        .map(|&b| {
1978            if b == SENTINEL_BYTE || b == 0x00 {
1979                b' '
1980            } else {
1981                b
1982            }
1983        })
1984        .collect()
1985}
1986
1987fn extract_sanitized_text_bytes(
1988    array: &dyn arrow_array::Array,
1989    index: usize,
1990) -> Result<Option<Vec<u8>>> {
1991    if array.is_null(index) {
1992        return Ok(None);
1993    }
1994    match array.data_type() {
1995        DataType::Utf8 => Ok(Some(sanitize_text_bytes(
1996            array
1997                .as_any()
1998                .downcast_ref::<arrow_array::StringArray>()
1999                .unwrap()
2000                .value(index)
2001                .as_bytes(),
2002        ))),
2003        DataType::LargeUtf8 => Ok(Some(sanitize_text_bytes(
2004            array
2005                .as_any()
2006                .downcast_ref::<arrow_array::LargeStringArray>()
2007                .unwrap()
2008                .value(index)
2009                .as_bytes(),
2010        ))),
2011        DataType::Binary => Ok(Some(sanitize_text_bytes(
2012            array
2013                .as_any()
2014                .downcast_ref::<arrow_array::BinaryArray>()
2015                .unwrap()
2016                .value(index),
2017        ))),
2018        DataType::LargeBinary => Ok(Some(sanitize_text_bytes(
2019            array
2020                .as_any()
2021                .downcast_ref::<arrow_array::LargeBinaryArray>()
2022                .unwrap()
2023                .value(index),
2024        ))),
2025        _ => Err(Error::invalid_input(format!(
2026            "Fm does not support data type: {:?}",
2027            array.data_type()
2028        ))),
2029    }
2030}
2031
2032#[cfg(test)]
2033fn extract_text_bytes(array: &dyn arrow_array::Array, index: usize) -> Result<Option<Vec<u8>>> {
2034    if array.is_null(index) {
2035        return Ok(None);
2036    }
2037    match array.data_type() {
2038        DataType::Utf8 => Ok(Some(
2039            array
2040                .as_any()
2041                .downcast_ref::<arrow_array::StringArray>()
2042                .unwrap()
2043                .value(index)
2044                .as_bytes()
2045                .to_vec(),
2046        )),
2047        DataType::LargeUtf8 => Ok(Some(
2048            array
2049                .as_any()
2050                .downcast_ref::<arrow_array::LargeStringArray>()
2051                .unwrap()
2052                .value(index)
2053                .as_bytes()
2054                .to_vec(),
2055        )),
2056        DataType::Binary => Ok(Some(
2057            array
2058                .as_any()
2059                .downcast_ref::<arrow_array::BinaryArray>()
2060                .unwrap()
2061                .value(index)
2062                .to_vec(),
2063        )),
2064        DataType::LargeBinary => Ok(Some(
2065            array
2066                .as_any()
2067                .downcast_ref::<arrow_array::LargeBinaryArray>()
2068                .unwrap()
2069                .value(index)
2070                .to_vec(),
2071        )),
2072        _ => Err(Error::invalid_input(format!(
2073            "Fm does not support data type: {:?}",
2074            array.data_type()
2075        ))),
2076    }
2077}
2078
2079fn hex_encode(data: &[u8]) -> String {
2080    data.iter().map(|b| format!("{b:02x}")).collect()
2081}
2082fn hex_decode(s: &str) -> Result<Vec<u8>> {
2083    if !s.len().is_multiple_of(2) {
2084        return Err(Error::invalid_input("invalid hex length"));
2085    }
2086    (0..s.len())
2087        .step_by(2)
2088        .map(|i| {
2089            u8::from_str_radix(&s[i..i + 2], 16)
2090                .map_err(|e| Error::invalid_input(format!("invalid hex: {e}")))
2091        })
2092        .collect()
2093}
2094
2095/// Write an FM-Index partition to storage.
2096///
2097/// Layout:
2098///   - Wavelet block rows (BWT nodes)
2099///   - SA sample blocks (packed u64 in LargeBinary)
2100///   - Metadata: c_table, huffman_codes, tree_topology, row_ids, doc_start_positions
2101async fn write_fmindex(
2102    fm: &FMIndex,
2103    store: &dyn IndexStore,
2104    filename: &str,
2105    partition_fingerprint: Option<&str>,
2106) -> Result<IndexFile> {
2107    let schema = Arc::new(FMIndex::block_schema());
2108
2109    let mut writer = store.new_index_file(filename, schema.clone()).await?;
2110
2111    // 1. Wavelet blocks
2112    let wb = fm.build_wavelet_batch()?;
2113    let nw = wb.num_rows();
2114    writer.write_record_batch(wb).await?;
2115
2116    // 2. SA samples packed as binary blocks
2117    let u64s_per_block = BLOCK_WORDS; // 4096 u64s per block = 32KB
2118    let mut sa_nid = Vec::new();
2119    let mut sa_bid = Vec::new();
2120    let mut sa_words: Vec<Vec<u8>> = Vec::new();
2121    let mut sa_pr = Vec::new();
2122    let mut sa_bl = Vec::new();
2123    for (bi, chunk) in fm.sa_samples.chunks(u64s_per_block).enumerate() {
2124        sa_nid.push(u32::MAX);
2125        sa_bid.push(bi as u32);
2126        sa_words.push(FMIndex::u64_to_bytes(chunk));
2127        sa_pr.push(0u64);
2128        sa_bl.push(fm.sa_samples.len() as u64);
2129    }
2130    let num_sa_blocks = sa_nid.len();
2131    if num_sa_blocks > 0 {
2132        let refs: Vec<&[u8]> = sa_words.iter().map(|v| v.as_slice()).collect();
2133        writer
2134            .write_record_batch(RecordBatch::try_new(
2135                schema.clone(),
2136                vec![
2137                    Arc::new(arrow_array::UInt32Array::from(sa_nid)),
2138                    Arc::new(arrow_array::UInt32Array::from(sa_bid)),
2139                    Arc::new(arrow_array::LargeBinaryArray::from(refs)),
2140                    Arc::new(arrow_array::UInt64Array::from(sa_pr)),
2141                    Arc::new(arrow_array::UInt64Array::from(sa_bl)),
2142                ],
2143            )?)
2144            .await?;
2145    }
2146
2147    // Metadata
2148    let mut metadata = HashMap::new();
2149    metadata.insert("num_bwt_nodes".into(), fm.wavelet.nodes.len().to_string());
2150    metadata.insert("bwt_len".into(), fm.wavelet.len.to_string());
2151    metadata.insert("num_sa_blocks".into(), num_sa_blocks.to_string());
2152    metadata.insert("sa_samples_len".into(), fm.sa_samples.len().to_string());
2153    metadata.insert("total_wavelet_rows".into(), nw.to_string());
2154    metadata.insert("sa_sample_rate".into(), SA_SAMPLE_RATE.to_string());
2155    metadata.insert("alphabet_size".into(), fm.alphabet_size.to_string());
2156    if let Some(fingerprint) = partition_fingerprint {
2157        metadata.insert(
2158            FMINDEX_PARTITION_FINGERPRINT_KEY.into(),
2159            fingerprint.to_string(),
2160        );
2161    }
2162    metadata.insert("c_table".into(), hex_encode(&fm.serialize_c_table()));
2163    metadata.insert(
2164        "huffman_codes".into(),
2165        hex_encode(&fm.serialize_huffman_codes()),
2166    );
2167    metadata.insert(
2168        "tree_topology".into(),
2169        hex_encode(&fm.serialize_tree_topology()),
2170    );
2171    // row_ids in metadata (10K × 8 = 80KB per partition — small)
2172    let row_ids_bytes: Vec<u8> = fm.row_ids.iter().flat_map(|&v| v.to_le_bytes()).collect();
2173    metadata.insert("row_ids".into(), hex_encode(&row_ids_bytes));
2174    // doc_start_positions in metadata (10K × 8 = 80KB per partition — small)
2175    let doc_starts_bytes: Vec<u8> = fm
2176        .doc_start_positions
2177        .iter()
2178        .flat_map(|&v| v.to_le_bytes())
2179        .collect();
2180    metadata.insert("doc_start_positions".into(), hex_encode(&doc_starts_bytes));
2181
2182    writer.finish_with_metadata(metadata).await
2183}
2184
2185#[cfg(test)]
2186async fn write_partitioned_fmindex(
2187    texts: &[(u64, Vec<u8>)],
2188    store: &dyn IndexStore,
2189) -> Result<Vec<IndexFile>> {
2190    if texts.is_empty() {
2191        return Ok(vec![write_empty_fmindex_partition(store).await?]);
2192    }
2193    let mut files = Vec::new();
2194    for (pid, chunk) in texts.chunks(PARTITION_SIZE).enumerate() {
2195        files.push(write_fmindex_partition(chunk, store, pid as u64).await?);
2196    }
2197    Ok(files)
2198}
2199
2200#[cfg(test)]
2201async fn write_fmindex_partition(
2202    texts: &[(u64, Vec<u8>)],
2203    store: &dyn IndexStore,
2204    partition_id: u64,
2205) -> Result<IndexFile> {
2206    let fingerprint = fmindex_partition_fingerprint(texts);
2207    let refs: Vec<(u64, &[u8])> = texts.iter().map(|(id, t)| (*id, t.as_slice())).collect();
2208    let fm = FMIndex::build(&refs)?;
2209    write_fmindex(
2210        &fm,
2211        store,
2212        &fmindex_partition_path(partition_id),
2213        Some(&fingerprint),
2214    )
2215    .await
2216}
2217
2218async fn write_fmindex_partition_owned(
2219    texts: Vec<(u64, Vec<u8>)>,
2220    store: Arc<dyn IndexStore>,
2221    partition_id: u64,
2222) -> Result<(u64, IndexFile)> {
2223    let fingerprint = fmindex_partition_fingerprint(&texts);
2224    let fm = spawn_cpu(move || {
2225        let refs: Vec<(u64, &[u8])> = texts.iter().map(|(id, t)| (*id, t.as_slice())).collect();
2226        FMIndex::build(&refs)
2227    })
2228    .await?;
2229    let file = write_fmindex(
2230        &fm,
2231        store.as_ref(),
2232        &fmindex_partition_path(partition_id),
2233        Some(&fingerprint),
2234    )
2235    .await?;
2236    Ok((partition_id, file))
2237}
2238
2239async fn write_empty_fmindex_partition(store: &dyn IndexStore) -> Result<IndexFile> {
2240    let fm = FMIndex::build(&[])?;
2241    let fingerprint = fmindex_partition_fingerprint(&[]);
2242    write_fmindex(&fm, store, &fmindex_partition_path(0), Some(&fingerprint)).await
2243}
2244
2245// ── Plugin ───────────────────────────────────────────────────────────────────
2246
2247#[derive(Debug, Default)]
2248pub struct FMIndexPlugin;
2249
2250#[async_trait]
2251impl BasicTrainer for FMIndexPlugin {
2252    fn new_training_request(
2253        &self,
2254        _params: &str,
2255        field: &Field,
2256    ) -> Result<Box<dyn TrainingRequest>> {
2257        match field.data_type() {
2258            DataType::Utf8 | DataType::LargeUtf8 | DataType::Binary | DataType::LargeBinary => {}
2259            _ => {
2260                return Err(Error::invalid_input(format!(
2261                    "FM-Index does not support {:?}",
2262                    field.data_type()
2263                )));
2264            }
2265        }
2266        Ok(Box::new(DefaultTrainingRequest::new(
2267            TrainingCriteria::new(TrainingOrdering::None).with_row_addr(),
2268        )))
2269    }
2270    async fn train_index(
2271        &self,
2272        data: SendableRecordBatchStream,
2273        store: &dyn IndexStore,
2274        _req: Box<dyn TrainingRequest>,
2275        _fids: Option<Vec<u32>>,
2276        _progress: Arc<dyn crate::progress::IndexBuildProgress>,
2277    ) -> Result<CreatedIndex> {
2278        let files = write_partitioned_fmindex_stream(data, store).await?;
2279        Ok(CreatedIndex {
2280            index_details: prost_types::Any::from_msg(&pb::FmIndexDetails {}).unwrap(),
2281            index_version: FMINDEX_INDEX_VERSION,
2282            files,
2283        })
2284    }
2285}
2286
2287#[async_trait]
2288impl ScalarIndexPlugin for FMIndexPlugin {
2289    fn basic_trainer(&self) -> Option<&dyn BasicTrainer> {
2290        Some(self)
2291    }
2292
2293    fn name(&self) -> &str {
2294        "Fm"
2295    }
2296    fn provides_exact_answer(&self) -> bool {
2297        true
2298    }
2299    fn version(&self) -> u32 {
2300        FMINDEX_INDEX_VERSION
2301    }
2302    fn new_query_parser(
2303        &self,
2304        index_name: String,
2305        _details: &prost_types::Any,
2306    ) -> Option<Box<dyn ScalarQueryParser>> {
2307        Some(Box::new(TextQueryParser::new(
2308            index_name,
2309            self.name().to_string(),
2310            // needs_recheck: the FM-index returns exact substring matches.
2311            false,
2312            // supports_regex: regex acceleration is only implemented for ngram.
2313            false,
2314        )))
2315    }
2316    async fn load_index(
2317        &self,
2318        store: Arc<dyn IndexStore>,
2319        details: &prost_types::Any,
2320        fri: Option<Arc<dyn RowIdRemapper>>,
2321        cache: &LanceCache,
2322    ) -> Result<Arc<dyn ScalarIndex>> {
2323        let _ = details.to_msg::<pb::FmIndexDetails>().unwrap_or_default();
2324        Ok(FMIndexScalarIndex::load(store, fri, cache).await? as Arc<dyn ScalarIndex>)
2325    }
2326    async fn load_statistics(
2327        &self,
2328        _: Arc<dyn IndexStore>,
2329        _: &prost_types::Any,
2330    ) -> Result<Option<serde_json::Value>> {
2331        Ok(None)
2332    }
2333}
2334
2335#[cfg(test)]
2336mod tests {
2337    use super::*;
2338    use arrow_array::{BinaryArray, LargeBinaryArray, LargeStringArray, StringArray, UInt64Array};
2339    use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
2340    use futures::stream;
2341    use lance_core::{ROW_ADDR, cache::LanceCache};
2342    use lance_io::object_store::ObjectStore;
2343    use object_store::path::Path;
2344    use std::sync::Arc;
2345
2346    use crate::scalar::lance_format::LanceIndexStore;
2347    use crate::scalar::registry::BasicTrainer;
2348
2349    #[derive(Debug, Clone)]
2350    struct FailNewFileStore {
2351        inner: Arc<dyn IndexStore>,
2352    }
2353
2354    impl DeepSizeOf for FailNewFileStore {
2355        fn deep_size_of_children(&self, _context: &mut lance_core::deepsize::Context) -> usize {
2356            0
2357        }
2358    }
2359
2360    #[async_trait::async_trait]
2361    impl IndexStore for FailNewFileStore {
2362        fn as_any(&self) -> &dyn std::any::Any {
2363            self
2364        }
2365
2366        fn clone_arc(&self) -> Arc<dyn IndexStore> {
2367            Arc::new(self.clone())
2368        }
2369
2370        fn io_parallelism(&self) -> usize {
2371            self.inner.io_parallelism()
2372        }
2373
2374        fn with_io_priority(&self, io_priority: u64) -> Arc<dyn IndexStore> {
2375            Arc::new(Self {
2376                inner: self.inner.with_io_priority(io_priority),
2377            })
2378        }
2379
2380        async fn new_index_file(
2381            &self,
2382            _name: &str,
2383            _schema: Arc<arrow_schema::Schema>,
2384        ) -> Result<Box<dyn crate::scalar::IndexWriter>> {
2385            Err(Error::execution(
2386                "injected FMIndex write failure".to_string(),
2387            ))
2388        }
2389
2390        async fn open_index_file(&self, name: &str) -> Result<Arc<dyn crate::scalar::IndexReader>> {
2391            self.inner.open_index_file(name).await
2392        }
2393
2394        async fn copy_index_file(
2395            &self,
2396            name: &str,
2397            dest_store: &dyn IndexStore,
2398        ) -> Result<IndexFile> {
2399            self.inner.copy_index_file(name, dest_store).await
2400        }
2401
2402        async fn rename_index_file(&self, name: &str, new_name: &str) -> Result<IndexFile> {
2403            self.inner.rename_index_file(name, new_name).await
2404        }
2405
2406        async fn delete_index_file(&self, name: &str) -> Result<()> {
2407            self.inner.delete_index_file(name).await
2408        }
2409
2410        async fn list_files_with_sizes(&self) -> Result<Vec<IndexFile>> {
2411            self.inner.list_files_with_sizes().await
2412        }
2413    }
2414
2415    fn loaded_wavelet_blocks(index: &FMIndexScalarIndex) -> usize {
2416        index
2417            .partitions
2418            .iter()
2419            .flat_map(|partition| partition.fm.wavelet.nodes.iter())
2420            .map(|node| {
2421                node.blocks
2422                    .iter()
2423                    .filter(|block| block.get().is_some())
2424                    .count()
2425            })
2426            .sum()
2427    }
2428
2429    fn total_wavelet_blocks(index: &FMIndexScalarIndex) -> usize {
2430        index
2431            .partitions
2432            .iter()
2433            .flat_map(|partition| partition.fm.wavelet.nodes.iter())
2434            .map(|node| node.blocks.len())
2435            .sum()
2436    }
2437
2438    #[test]
2439    fn test_index_size_ratio() {
2440        let docs: Vec<Vec<u8>> = (0..200)
2441            .map(|i| {
2442                format!(
2443                    "document {} with enough text to test size ratio properly end",
2444                    i
2445                )
2446                .into_bytes()
2447            })
2448            .collect();
2449        let texts: Vec<(u64, &[u8])> = docs
2450            .iter()
2451            .enumerate()
2452            .map(|(i, d)| (i as u64, d.as_slice()))
2453            .collect();
2454        let fm = FMIndex::build(&texts).unwrap();
2455
2456        let text_size: usize = docs.iter().map(|d| d.len()).sum();
2457        let wavelet_size = fm.wavelet.deep_size();
2458        let sa_size = fm.sa_samples.len() * 8;
2459        let total = wavelet_size + sa_size;
2460
2461        let ratio = total as f64 / text_size as f64;
2462        assert!(
2463            ratio < 1.5,
2464            "index should be much smaller than text, got ratio={ratio:.2}"
2465        );
2466    }
2467
2468    #[test]
2469    fn test_serialization_roundtrip() {
2470        let texts: Vec<(u64, &[u8])> = vec![
2471            (10, b"alpha beta gamma"),
2472            (20, b"beta gamma delta"),
2473            (30, b"gamma delta epsilon"),
2474        ];
2475        let fm = FMIndex::build(&texts).unwrap();
2476
2477        // Test huffman codes roundtrip
2478        let hc_bytes = fm.serialize_huffman_codes();
2479        let hc = FMIndex::deserialize_huffman_codes(&hc_bytes);
2480        for (i, (loaded, original)) in hc.iter().zip(fm.wavelet.codes.iter()).enumerate() {
2481            assert_eq!(loaded.bits, original.bits, "bits mismatch at {i}");
2482            assert_eq!(loaded.length, original.length, "length mismatch at {i}");
2483            assert_eq!(loaded.node_path, original.node_path, "path mismatch at {i}");
2484        }
2485
2486        // Test tree topology roundtrip
2487        let topo_bytes = fm.serialize_tree_topology();
2488        let topo = FMIndex::deserialize_tree_topology(&topo_bytes);
2489        assert_eq!(topo.len(), fm.wavelet.children.len());
2490
2491        // Test c_table roundtrip
2492        let ct_bytes = fm.serialize_c_table();
2493        let ct = FMIndex::deserialize_c_table(&ct_bytes);
2494        assert_eq!(ct, fm.c_table);
2495    }
2496
2497    #[test]
2498    fn test_hex_roundtrip() {
2499        let data = vec![0u8, 1, 127, 255, 42];
2500        let encoded = hex_encode(&data);
2501        let decoded = hex_decode(&encoded).unwrap();
2502        assert_eq!(data, decoded);
2503    }
2504
2505    #[test]
2506    fn test_partition_limit_reached_by_rows_or_bytes() {
2507        assert!(fmindex_partition_limit_reached(10, 5, 10, 100));
2508        assert!(fmindex_partition_limit_reached(3, 100, 10, 100));
2509        assert!(!fmindex_partition_limit_reached(3, 99, 10, 100));
2510    }
2511
2512    #[test]
2513    fn test_large_sa_sampling() {
2514        // Test with enough documents to have multiple SA sample points
2515        let docs: Vec<Vec<u8>> = (0..50)
2516            .map(|i| {
2517                format!(
2518                    "document number {} with lots of text to ensure we have enough bytes for multiple SA samples across the suffix array positions",
2519                    i
2520                )
2521                .into_bytes()
2522            })
2523            .collect();
2524        let texts: Vec<(u64, &[u8])> = docs
2525            .iter()
2526            .enumerate()
2527            .map(|(i, d)| (i as u64, d.as_slice()))
2528            .collect();
2529        let fm = FMIndex::build(&texts).unwrap();
2530
2531        assert!(fm.sa_samples.len() > 1, "should have multiple SA samples");
2532    }
2533
2534    #[tokio::test(flavor = "multi_thread")]
2535    async fn test_write_and_load_roundtrip() {
2536        let texts: Vec<(u64, &[u8])> = vec![
2537            (0, b"hello world foo bar"),
2538            (1, b"hello rust baz qux"),
2539            (2, b"goodbye world quux"),
2540        ];
2541        let fm = FMIndex::build(&texts).unwrap();
2542
2543        let tempdir = tempfile::tempdir().unwrap();
2544        let index_dir = Path::from_filesystem_path(tempdir.path()).unwrap();
2545        let store = Arc::new(LanceIndexStore::new(
2546            Arc::new(ObjectStore::local()),
2547            index_dir,
2548            Arc::new(LanceCache::no_cache()),
2549        ));
2550
2551        // Write
2552        write_fmindex(&fm, store.as_ref(), &fmindex_partition_path(0), None)
2553            .await
2554            .unwrap();
2555
2556        // Load
2557        let part =
2558            FMIndexScalarIndex::load_partition(store.as_ref(), &fmindex_partition_path(0), 0)
2559                .await
2560                .unwrap();
2561
2562        // Verify search results match
2563        let r = part.fm.search(b"hello");
2564        assert!(r.contains(0));
2565        assert!(r.contains(1));
2566        assert!(!r.contains(2));
2567
2568        let r = part.fm.search(b"world");
2569        assert!(r.contains(0));
2570        assert!(!r.contains(1));
2571        assert!(r.contains(2));
2572
2573        assert!(part.fm.search(b"xyz").is_empty());
2574    }
2575
2576    #[tokio::test(flavor = "multi_thread")]
2577    async fn test_partitioned_write_and_load() {
2578        let docs: Vec<Vec<u8>> = (0..30)
2579            .map(|i| format!("document {i} hello world test data").into_bytes())
2580            .collect();
2581        let texts: Vec<(u64, Vec<u8>)> = docs
2582            .into_iter()
2583            .enumerate()
2584            .map(|(i, d)| (i as u64, d))
2585            .collect();
2586
2587        let tempdir = tempfile::tempdir().unwrap();
2588        let index_dir = Path::from_filesystem_path(tempdir.path()).unwrap();
2589        let store = Arc::new(LanceIndexStore::new(
2590            Arc::new(ObjectStore::local()),
2591            index_dir,
2592            Arc::new(LanceCache::no_cache()),
2593        ));
2594
2595        write_partitioned_fmindex(&texts, store.as_ref())
2596            .await
2597            .unwrap();
2598
2599        let index = FMIndexScalarIndex::load(store, None, &LanceCache::no_cache())
2600            .await
2601            .unwrap();
2602
2603        // Search across partitions
2604        let r = index
2605            .search(
2606                &TextQuery::StringContains("hello world".to_string()),
2607                &crate::metrics::NoOpMetricsCollector,
2608            )
2609            .await
2610            .unwrap();
2611        match r {
2612            SearchResult::Exact(set) => {
2613                assert_eq!(set.len(), Some(30));
2614            }
2615            _ => panic!("expected exact result"),
2616        }
2617
2618        let r = index
2619            .search(
2620                &TextQuery::StringContains("document 15".to_string()),
2621                &crate::metrics::NoOpMetricsCollector,
2622            )
2623            .await
2624            .unwrap();
2625        match r {
2626            SearchResult::Exact(set) => {
2627                assert_eq!(set.len(), Some(1));
2628            }
2629            _ => panic!("expected exact result"),
2630        }
2631
2632        let r = index
2633            .search(
2634                &TextQuery::StringContains("nonexistent".to_string()),
2635                &crate::metrics::NoOpMetricsCollector,
2636            )
2637            .await
2638            .unwrap();
2639        match r {
2640            SearchResult::Exact(set) => {
2641                assert_eq!(set.len(), Some(0));
2642            }
2643            _ => panic!("expected exact result"),
2644        }
2645    }
2646
2647    #[tokio::test(flavor = "multi_thread")]
2648    async fn test_public_prewarm_loads_lazy_blocks() {
2649        let docs: Vec<Vec<u8>> = (0..30)
2650            .map(|i| format!("document {i} hello world test data").into_bytes())
2651            .collect();
2652        let texts: Vec<(u64, Vec<u8>)> = docs
2653            .into_iter()
2654            .enumerate()
2655            .map(|(i, d)| (i as u64, d))
2656            .collect();
2657
2658        let tempdir = tempfile::tempdir().unwrap();
2659        let index_dir = Path::from_filesystem_path(tempdir.path()).unwrap();
2660        let store = Arc::new(LanceIndexStore::new(
2661            Arc::new(ObjectStore::local()),
2662            index_dir,
2663            Arc::new(LanceCache::no_cache()),
2664        ));
2665
2666        write_partitioned_fmindex(&texts, store.as_ref())
2667            .await
2668            .unwrap();
2669
2670        let index = FMIndexScalarIndex::load(store, None, &LanceCache::no_cache())
2671            .await
2672            .unwrap();
2673        let total_blocks = total_wavelet_blocks(index.as_ref());
2674        assert!(total_blocks > 0);
2675        assert_eq!(loaded_wavelet_blocks(index.as_ref()), 0);
2676
2677        index.prewarm().await.unwrap();
2678        assert_eq!(loaded_wavelet_blocks(index.as_ref()), total_blocks);
2679
2680        let r = index
2681            .search(
2682                &TextQuery::StringContains("hello world".to_string()),
2683                &crate::metrics::NoOpMetricsCollector,
2684            )
2685            .await
2686            .unwrap();
2687        match r {
2688            SearchResult::Exact(set) => {
2689                assert_eq!(set.len(), Some(30));
2690            }
2691            _ => panic!("expected exact result"),
2692        }
2693        assert_eq!(loaded_wavelet_blocks(index.as_ref()), total_blocks);
2694    }
2695
2696    #[tokio::test(flavor = "multi_thread")]
2697    async fn test_lazy_rank_terminal_block_boundary() {
2698        let tempdir = tempfile::tempdir().unwrap();
2699        let index_dir = Path::from_filesystem_path(tempdir.path()).unwrap();
2700        let store = Arc::new(LanceIndexStore::new(
2701            Arc::new(ObjectStore::local()),
2702            index_dir,
2703            Arc::new(LanceCache::no_cache()),
2704        ));
2705        let schema = Arc::new(arrow_schema::Schema::new(vec![Field::new(
2706            "words",
2707            DataType::LargeBinary,
2708            false,
2709        )]));
2710        let words = vec![u64::MAX; BLOCK_WORDS];
2711        let bytes = FMIndex::u64_to_bytes(&words);
2712        let batch = RecordBatch::try_new(
2713            schema.clone(),
2714            vec![Arc::new(LargeBinaryArray::from(vec![bytes.as_slice()]))],
2715        )
2716        .unwrap();
2717        let mut writer = store.new_index_file("rank.lance", schema).await.unwrap();
2718        writer.write_record_batch(batch).await.unwrap();
2719        writer.finish().await.unwrap();
2720
2721        let reader = store.open_index_file("rank.lance").await.unwrap();
2722        let bitvec = LazyRankBitVec::new(vec![0], 1, reader, 0, BLOCK_BITS);
2723        bitvec.load_block_for_rank(BLOCK_BITS).await.unwrap();
2724
2725        assert_eq!(bitvec.rank1(BLOCK_BITS), BLOCK_BITS);
2726        assert_eq!(bitvec.rank0(BLOCK_BITS), 0);
2727    }
2728
2729    #[tokio::test(flavor = "multi_thread")]
2730    async fn test_contains_search_does_not_full_prewarm_partitions() {
2731        let docs: Vec<Vec<u8>> = (0..30)
2732            .map(|i| format!("document {i} hello world test data").into_bytes())
2733            .collect();
2734        let texts: Vec<(u64, Vec<u8>)> = docs
2735            .into_iter()
2736            .enumerate()
2737            .map(|(i, d)| (i as u64, d))
2738            .collect();
2739
2740        let tempdir = tempfile::tempdir().unwrap();
2741        let index_dir = Path::from_filesystem_path(tempdir.path()).unwrap();
2742        let store = Arc::new(LanceIndexStore::new(
2743            Arc::new(ObjectStore::local()),
2744            index_dir,
2745            Arc::new(LanceCache::no_cache()),
2746        ));
2747
2748        write_partitioned_fmindex(&texts, store.as_ref())
2749            .await
2750            .unwrap();
2751
2752        let index = FMIndexScalarIndex::load(store, None, &LanceCache::no_cache())
2753            .await
2754            .unwrap();
2755        assert_eq!(loaded_wavelet_blocks(index.as_ref()), 0);
2756        assert!(
2757            index
2758                .partitions
2759                .iter()
2760                .all(|partition| !partition.fm.fully_prewarmed.load(Ordering::Acquire))
2761        );
2762
2763        let r = index
2764            .search(
2765                &TextQuery::StringContains("document 15".to_string()),
2766                &crate::metrics::NoOpMetricsCollector,
2767            )
2768            .await
2769            .unwrap();
2770        match r {
2771            SearchResult::Exact(set) => {
2772                assert_eq!(set.len(), Some(1));
2773            }
2774            _ => panic!("expected exact result"),
2775        }
2776
2777        assert!(
2778            index
2779                .partitions
2780                .iter()
2781                .all(|partition| !partition.fm.fully_prewarmed.load(Ordering::Acquire))
2782        );
2783    }
2784
2785    #[tokio::test(flavor = "multi_thread")]
2786    async fn test_plugin_train_and_load() {
2787        let docs = vec!["hello world", "hello rust", "goodbye world"];
2788        let row_addrs: Vec<u64> = vec![0, 1, 2];
2789        let schema = Arc::new(arrow_schema::Schema::new(vec![
2790            arrow_schema::Field::new(
2791                crate::scalar::registry::VALUE_COLUMN_NAME,
2792                DataType::Utf8,
2793                false,
2794            ),
2795            arrow_schema::Field::new(ROW_ADDR, DataType::UInt64, false),
2796        ]));
2797        let batch = RecordBatch::try_new(
2798            schema.clone(),
2799            vec![
2800                Arc::new(StringArray::from(docs)),
2801                Arc::new(UInt64Array::from(row_addrs)),
2802            ],
2803        )
2804        .unwrap();
2805
2806        let tempdir = tempfile::tempdir().unwrap();
2807        let index_dir = Path::from_filesystem_path(tempdir.path()).unwrap();
2808        let store = Arc::new(LanceIndexStore::new(
2809            Arc::new(ObjectStore::local()),
2810            index_dir,
2811            Arc::new(LanceCache::no_cache()),
2812        ));
2813
2814        let stream = RecordBatchStreamAdapter::new(batch.schema(), stream::iter(vec![Ok(batch)]));
2815        let req = FMIndexPlugin
2816            .new_training_request("", &arrow_schema::Field::new("val", DataType::Utf8, false))
2817            .unwrap();
2818        let created = FMIndexPlugin
2819            .train_index(
2820                Box::pin(stream),
2821                store.as_ref(),
2822                req,
2823                None,
2824                Arc::new(crate::progress::NoopIndexBuildProgress),
2825            )
2826            .await
2827            .unwrap();
2828
2829        let index = FMIndexPlugin
2830            .load_index(store, &created.index_details, None, &LanceCache::no_cache())
2831            .await
2832            .unwrap();
2833
2834        let r = index
2835            .search(
2836                &TextQuery::StringContains("hello".to_string()),
2837                &crate::metrics::NoOpMetricsCollector,
2838            )
2839            .await
2840            .unwrap();
2841        match r {
2842            SearchResult::Exact(set) => {
2843                assert_eq!(set.len(), Some(2));
2844            }
2845            _ => panic!("expected exact result"),
2846        }
2847    }
2848
2849    #[tokio::test(flavor = "multi_thread")]
2850    async fn test_stream_train_splits_partition_by_bytes() {
2851        let docs = vec!["abcd", "efgh", "ijkl"];
2852        let row_addrs: Vec<u64> = vec![0, 1, 2];
2853        let schema = Arc::new(arrow_schema::Schema::new(vec![
2854            arrow_schema::Field::new(
2855                crate::scalar::registry::VALUE_COLUMN_NAME,
2856                DataType::Utf8,
2857                false,
2858            ),
2859            arrow_schema::Field::new(ROW_ADDR, DataType::UInt64, false),
2860        ]));
2861        let batch = RecordBatch::try_new(
2862            schema.clone(),
2863            vec![
2864                Arc::new(StringArray::from(docs)),
2865                Arc::new(UInt64Array::from(row_addrs)),
2866            ],
2867        )
2868        .unwrap();
2869
2870        let tempdir = tempfile::tempdir().unwrap();
2871        let index_dir = Path::from_filesystem_path(tempdir.path()).unwrap();
2872        let store = Arc::new(LanceIndexStore::new(
2873            Arc::new(ObjectStore::local()),
2874            index_dir,
2875            Arc::new(LanceCache::no_cache()),
2876        ));
2877
2878        let stream = RecordBatchStreamAdapter::new(schema, stream::iter(vec![Ok(batch)]));
2879        let files = write_partitioned_fmindex_stream_with_config(
2880            Box::pin(stream),
2881            store.as_ref(),
2882            FMIndexPartitionConfig {
2883                num_workers: 2,
2884                max_rows: 100,
2885                max_bytes: 5,
2886                queue_size: 1,
2887                resume_existing: false,
2888            },
2889        )
2890        .await
2891        .unwrap();
2892
2893        assert_eq!(files.len(), 3);
2894        assert_eq!(files[0].path, fmindex_partition_path(0));
2895        assert_eq!(files[1].path, fmindex_partition_path(1));
2896        assert_eq!(files[2].path, fmindex_partition_path(2));
2897
2898        let index = FMIndexScalarIndex::load(store, None, &LanceCache::no_cache())
2899            .await
2900            .unwrap();
2901        let r = index
2902            .search(
2903                &TextQuery::StringContains("efgh".to_string()),
2904                &crate::metrics::NoOpMetricsCollector,
2905            )
2906            .await
2907            .unwrap();
2908        match r {
2909            SearchResult::Exact(set) => {
2910                assert_eq!(set.len(), Some(1));
2911            }
2912            _ => panic!("expected exact result"),
2913        }
2914    }
2915
2916    #[tokio::test(flavor = "multi_thread")]
2917    async fn test_stream_train_resumes_existing_partitions() {
2918        let schema = Arc::new(arrow_schema::Schema::new(vec![
2919            arrow_schema::Field::new(
2920                crate::scalar::registry::VALUE_COLUMN_NAME,
2921                DataType::Utf8,
2922                false,
2923            ),
2924            arrow_schema::Field::new(ROW_ADDR, DataType::UInt64, false),
2925        ]));
2926        let tempdir = tempfile::tempdir().unwrap();
2927        let index_dir = Path::from_filesystem_path(tempdir.path()).unwrap();
2928        let store = Arc::new(LanceIndexStore::new(
2929            Arc::new(ObjectStore::local()),
2930            index_dir,
2931            Arc::new(LanceCache::no_cache()),
2932        ));
2933
2934        let first_batch = RecordBatch::try_new(
2935            schema.clone(),
2936            vec![
2937                Arc::new(StringArray::from(vec!["first partition"])),
2938                Arc::new(UInt64Array::from(vec![0])),
2939            ],
2940        )
2941        .unwrap();
2942        let first_stream =
2943            RecordBatchStreamAdapter::new(schema.clone(), stream::iter(vec![Ok(first_batch)]));
2944        let first_files = write_partitioned_fmindex_stream_with_config(
2945            Box::pin(first_stream),
2946            store.as_ref(),
2947            FMIndexPartitionConfig {
2948                num_workers: 1,
2949                max_rows: 1,
2950                max_bytes: 1024,
2951                queue_size: 1,
2952                resume_existing: false,
2953            },
2954        )
2955        .await
2956        .unwrap();
2957        assert_eq!(first_files.len(), 1);
2958        assert_eq!(first_files[0].path, fmindex_partition_path(0));
2959
2960        let resumed_batch = RecordBatch::try_new(
2961            schema.clone(),
2962            vec![
2963                Arc::new(StringArray::from(vec![
2964                    "first partition",
2965                    "second partition",
2966                ])),
2967                Arc::new(UInt64Array::from(vec![0, 1])),
2968            ],
2969        )
2970        .unwrap();
2971        let resumed_stream =
2972            RecordBatchStreamAdapter::new(schema.clone(), stream::iter(vec![Ok(resumed_batch)]));
2973        let resumed_files = write_partitioned_fmindex_stream_with_config(
2974            Box::pin(resumed_stream),
2975            store.as_ref(),
2976            FMIndexPartitionConfig {
2977                num_workers: 1,
2978                max_rows: 1,
2979                max_bytes: 1024,
2980                queue_size: 1,
2981                resume_existing: true,
2982            },
2983        )
2984        .await
2985        .unwrap();
2986
2987        let resumed_paths = resumed_files
2988            .iter()
2989            .map(|file| file.path.clone())
2990            .collect::<Vec<_>>();
2991        assert_eq!(
2992            resumed_paths,
2993            vec![fmindex_partition_path(0), fmindex_partition_path(1)]
2994        );
2995
2996        let index = FMIndexScalarIndex::load(store, None, &LanceCache::no_cache())
2997            .await
2998            .unwrap();
2999        let r = index
3000            .search(
3001                &TextQuery::StringContains("partition".to_string()),
3002                &crate::metrics::NoOpMetricsCollector,
3003            )
3004            .await
3005            .unwrap();
3006        match r {
3007            SearchResult::Exact(set) => {
3008                assert_eq!(set.len(), Some(2));
3009            }
3010            _ => panic!("expected exact result"),
3011        }
3012    }
3013
3014    #[tokio::test(flavor = "multi_thread")]
3015    async fn test_stream_train_rebuilds_stale_resume_partitions() {
3016        let schema = Arc::new(arrow_schema::Schema::new(vec![
3017            arrow_schema::Field::new(
3018                crate::scalar::registry::VALUE_COLUMN_NAME,
3019                DataType::Utf8,
3020                false,
3021            ),
3022            arrow_schema::Field::new(ROW_ADDR, DataType::UInt64, false),
3023        ]));
3024        let tempdir = tempfile::tempdir().unwrap();
3025        let index_dir = Path::from_filesystem_path(tempdir.path()).unwrap();
3026        let store = Arc::new(LanceIndexStore::new(
3027            Arc::new(ObjectStore::local()),
3028            index_dir,
3029            Arc::new(LanceCache::no_cache()),
3030        ));
3031
3032        let stale_batch = RecordBatch::try_new(
3033            schema.clone(),
3034            vec![
3035                Arc::new(StringArray::from(vec!["stale zero", "stale one"])),
3036                Arc::new(UInt64Array::from(vec![0, 1])),
3037            ],
3038        )
3039        .unwrap();
3040        let stale_stream =
3041            RecordBatchStreamAdapter::new(schema.clone(), stream::iter(vec![Ok(stale_batch)]));
3042        write_partitioned_fmindex_stream_with_config(
3043            Box::pin(stale_stream),
3044            store.as_ref(),
3045            FMIndexPartitionConfig {
3046                num_workers: 1,
3047                max_rows: 1,
3048                max_bytes: 1024,
3049                queue_size: 1,
3050                resume_existing: false,
3051            },
3052        )
3053        .await
3054        .unwrap();
3055
3056        let fresh_batch = RecordBatch::try_new(
3057            schema.clone(),
3058            vec![
3059                Arc::new(StringArray::from(vec!["fresh zero"])),
3060                Arc::new(UInt64Array::from(vec![0])),
3061            ],
3062        )
3063        .unwrap();
3064        let fresh_stream =
3065            RecordBatchStreamAdapter::new(schema.clone(), stream::iter(vec![Ok(fresh_batch)]));
3066        let fresh_files = write_partitioned_fmindex_stream_with_config(
3067            Box::pin(fresh_stream),
3068            store.as_ref(),
3069            FMIndexPartitionConfig {
3070                num_workers: 1,
3071                max_rows: 10,
3072                max_bytes: 1024,
3073                queue_size: 1,
3074                resume_existing: true,
3075            },
3076        )
3077        .await
3078        .unwrap();
3079
3080        assert_eq!(fresh_files.len(), 1);
3081        assert_eq!(fresh_files[0].path, fmindex_partition_path(0));
3082        let remaining_paths = store
3083            .list_files_with_sizes()
3084            .await
3085            .unwrap()
3086            .iter()
3087            .map(|file| file.path.clone())
3088            .collect::<Vec<_>>();
3089        assert_eq!(remaining_paths, vec![fmindex_partition_path(0)]);
3090
3091        let index = FMIndexScalarIndex::load(store, None, &LanceCache::no_cache())
3092            .await
3093            .unwrap();
3094        let r = index
3095            .search(
3096                &TextQuery::StringContains("fresh zero".to_string()),
3097                &crate::metrics::NoOpMetricsCollector,
3098            )
3099            .await
3100            .unwrap();
3101        match r {
3102            SearchResult::Exact(set) => assert_eq!(set.len(), Some(1)),
3103            _ => panic!("expected exact result"),
3104        }
3105
3106        let r = index
3107            .search(
3108                &TextQuery::StringContains("stale one".to_string()),
3109                &crate::metrics::NoOpMetricsCollector,
3110            )
3111            .await
3112            .unwrap();
3113        match r {
3114            SearchResult::Exact(set) => assert_eq!(set.len(), Some(0)),
3115            _ => panic!("expected exact result"),
3116        }
3117    }
3118
3119    #[tokio::test(flavor = "multi_thread")]
3120    async fn test_stream_train_propagates_worker_write_error() {
3121        let schema = Arc::new(arrow_schema::Schema::new(vec![
3122            arrow_schema::Field::new(
3123                crate::scalar::registry::VALUE_COLUMN_NAME,
3124                DataType::Utf8,
3125                false,
3126            ),
3127            arrow_schema::Field::new(ROW_ADDR, DataType::UInt64, false),
3128        ]));
3129        let batch = RecordBatch::try_new(
3130            schema.clone(),
3131            vec![
3132                Arc::new(StringArray::from(vec!["hello"])),
3133                Arc::new(UInt64Array::from(vec![0])),
3134            ],
3135        )
3136        .unwrap();
3137
3138        let tempdir = tempfile::tempdir().unwrap();
3139        let index_dir = Path::from_filesystem_path(tempdir.path()).unwrap();
3140        let inner = Arc::new(LanceIndexStore::new(
3141            Arc::new(ObjectStore::local()),
3142            index_dir,
3143            Arc::new(LanceCache::no_cache()),
3144        )) as Arc<dyn IndexStore>;
3145        let store = FailNewFileStore { inner };
3146
3147        let stream = RecordBatchStreamAdapter::new(schema, stream::iter(vec![Ok(batch)]));
3148        let err = write_partitioned_fmindex_stream_with_config(
3149            Box::pin(stream),
3150            &store,
3151            FMIndexPartitionConfig {
3152                num_workers: 1,
3153                max_rows: 1,
3154                max_bytes: 1024,
3155                queue_size: 1,
3156                resume_existing: false,
3157            },
3158        )
3159        .await
3160        .unwrap_err();
3161
3162        assert!(format!("{err}").contains("injected FMIndex write failure"));
3163    }
3164
3165    #[tokio::test]
3166    async fn test_fail_new_file_store_with_io_priority_preserves_failure() {
3167        // `with_io_priority` re-wraps in `FailNewFileStore`, so the reprioritized
3168        // store must keep injecting the `new_index_file` failure.
3169        let tempdir = tempfile::tempdir().unwrap();
3170        let index_dir = Path::from_filesystem_path(tempdir.path()).unwrap();
3171        let inner = Arc::new(LanceIndexStore::new(
3172            Arc::new(ObjectStore::local()),
3173            index_dir,
3174            Arc::new(LanceCache::no_cache()),
3175        )) as Arc<dyn IndexStore>;
3176        let store = FailNewFileStore { inner };
3177
3178        let reprioritized = store.with_io_priority(7);
3179
3180        let schema = Arc::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new(
3181            "x",
3182            DataType::UInt64,
3183            false,
3184        )]));
3185        let err = reprioritized
3186            .new_index_file("test", schema)
3187            .await
3188            .err()
3189            .expect("new_index_file should fail");
3190        assert!(format!("{err}").contains("injected FMIndex write failure"));
3191    }
3192
3193    #[tokio::test(flavor = "multi_thread")]
3194    async fn test_plugin_train_streams_multiple_partitions() {
3195        fn training_batch(
3196            schema: Arc<arrow_schema::Schema>,
3197            start: usize,
3198            len: usize,
3199        ) -> RecordBatch {
3200            let docs = vec!["x"; len];
3201            let row_addrs: Vec<u64> = (start..start + len).map(|i| i as u64).collect();
3202            RecordBatch::try_new(
3203                schema,
3204                vec![
3205                    Arc::new(StringArray::from(docs)),
3206                    Arc::new(UInt64Array::from(row_addrs)),
3207                ],
3208            )
3209            .unwrap()
3210        }
3211
3212        let total_rows = PARTITION_SIZE + 5;
3213        let first_batch_rows = PARTITION_SIZE - 3;
3214        let schema = Arc::new(arrow_schema::Schema::new(vec![
3215            arrow_schema::Field::new(
3216                crate::scalar::registry::VALUE_COLUMN_NAME,
3217                DataType::Utf8,
3218                false,
3219            ),
3220            arrow_schema::Field::new(ROW_ADDR, DataType::UInt64, false),
3221        ]));
3222        let batches = vec![
3223            Ok(training_batch(schema.clone(), 0, first_batch_rows)),
3224            Ok(training_batch(
3225                schema.clone(),
3226                first_batch_rows,
3227                total_rows - first_batch_rows,
3228            )),
3229        ];
3230
3231        let tempdir = tempfile::tempdir().unwrap();
3232        let index_dir = Path::from_filesystem_path(tempdir.path()).unwrap();
3233        let store = Arc::new(LanceIndexStore::new(
3234            Arc::new(ObjectStore::local()),
3235            index_dir,
3236            Arc::new(LanceCache::no_cache()),
3237        ));
3238
3239        let stream = RecordBatchStreamAdapter::new(schema, stream::iter(batches));
3240        let req = FMIndexPlugin
3241            .new_training_request("", &arrow_schema::Field::new("val", DataType::Utf8, false))
3242            .unwrap();
3243        let created = FMIndexPlugin
3244            .train_index(
3245                Box::pin(stream),
3246                store.as_ref(),
3247                req,
3248                None,
3249                Arc::new(crate::progress::NoopIndexBuildProgress),
3250            )
3251            .await
3252            .unwrap();
3253
3254        assert_eq!(created.files.len(), 2);
3255        assert_eq!(created.files[0].path, fmindex_partition_path(0));
3256        assert_eq!(created.files[1].path, fmindex_partition_path(1));
3257
3258        let index = FMIndexPlugin
3259            .load_index(store, &created.index_details, None, &LanceCache::no_cache())
3260            .await
3261            .unwrap();
3262        let r = index
3263            .search(
3264                &TextQuery::StringContains("x".to_string()),
3265                &crate::metrics::NoOpMetricsCollector,
3266            )
3267            .await
3268            .unwrap();
3269        match r {
3270            SearchResult::Exact(set) => {
3271                assert_eq!(set.len(), Some(total_rows as u64));
3272            }
3273            _ => panic!("expected exact result"),
3274        }
3275    }
3276
3277    #[test]
3278    fn test_build_wavelet_batch() {
3279        let texts: Vec<(u64, &[u8])> = vec![(0, b"hello world"), (1, b"test data")];
3280        let fm = FMIndex::build(&texts).unwrap();
3281        let batch = fm.build_wavelet_batch().unwrap();
3282        assert!(batch.num_rows() > 0);
3283        assert_eq!(batch.num_columns(), 5);
3284    }
3285
3286    #[test]
3287    fn test_extract_text_bytes_types() {
3288        let utf8 = StringArray::from(vec!["hello"]);
3289        assert_eq!(
3290            extract_text_bytes(&utf8, 0).unwrap(),
3291            Some(b"hello".to_vec())
3292        );
3293
3294        let large_utf8 = LargeStringArray::from(vec!["world"]);
3295        assert_eq!(
3296            extract_text_bytes(&large_utf8, 0).unwrap(),
3297            Some(b"world".to_vec())
3298        );
3299
3300        let binary = BinaryArray::from(vec![b"bytes" as &[u8]]);
3301        assert_eq!(
3302            extract_text_bytes(&binary, 0).unwrap(),
3303            Some(b"bytes".to_vec())
3304        );
3305        let binary_with_sentinels = BinaryArray::from(vec![b"a\xFFb\0c" as &[u8]]);
3306        assert_eq!(
3307            extract_sanitized_text_bytes(&binary_with_sentinels, 0).unwrap(),
3308            Some(b"a b c".to_vec())
3309        );
3310
3311        let large_binary = LargeBinaryArray::from(vec![b"large" as &[u8]]);
3312        assert_eq!(
3313            extract_text_bytes(&large_binary, 0).unwrap(),
3314            Some(b"large".to_vec())
3315        );
3316
3317        // Null handling
3318        let nullable = StringArray::from(vec![None::<&str>]);
3319        assert_eq!(extract_text_bytes(&nullable, 0).unwrap(), None);
3320    }
3321
3322    #[test]
3323    fn test_fmindex_statistics() {
3324        let rt = tokio::runtime::Builder::new_current_thread()
3325            .build()
3326            .unwrap();
3327        rt.block_on(async {
3328            let docs: Vec<Vec<u8>> = (0..10).map(|i| format!("doc {i}").into_bytes()).collect();
3329            let texts: Vec<(u64, Vec<u8>)> = docs
3330                .into_iter()
3331                .enumerate()
3332                .map(|(i, d)| (i as u64, d))
3333                .collect();
3334
3335            let tempdir = tempfile::tempdir().unwrap();
3336            let index_dir = Path::from_filesystem_path(tempdir.path()).unwrap();
3337            let store = Arc::new(LanceIndexStore::new(
3338                Arc::new(ObjectStore::local()),
3339                index_dir,
3340                Arc::new(LanceCache::no_cache()),
3341            ));
3342
3343            write_partitioned_fmindex(&texts, store.as_ref())
3344                .await
3345                .unwrap();
3346            let index = FMIndexScalarIndex::load(store, None, &LanceCache::no_cache())
3347                .await
3348                .unwrap();
3349
3350            let stats = index.statistics().unwrap();
3351            assert_eq!(stats["type"], "Fm");
3352            assert_eq!(stats["total_docs"], 10);
3353            assert!(stats["total_bwt_len"].as_u64().unwrap() > 0);
3354        });
3355    }
3356}