Skip to main content

lance_index/scalar/inverted/
index.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4use lance_core::utils::row_addr_remap::RowAddrRemap;
5use std::fmt::{Debug, Display};
6use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
7use std::sync::{Arc, OnceLock};
8use std::{
9    cmp::{Reverse, min},
10    collections::BinaryHeap,
11};
12use std::{
13    collections::{BTreeMap, BTreeSet, HashMap, HashSet},
14    ops::Range,
15    time::Instant,
16};
17
18use crate::metrics::NoOpMetricsCollector;
19use crate::prefilter::NoFilter;
20use crate::scalar::registry::{TrainingCriteria, TrainingOrdering};
21use crate::vector::graph::OrderedFloat;
22use arrow::array::{FixedSizeListBuilder, Float32Builder, Int32Builder};
23use arrow::datatypes::{self, Float32Type, Int32Type, UInt64Type};
24use arrow::{
25    array::{
26        AsArray, LargeBinaryBuilder, ListBuilder, StringBuilder, UInt32Builder, UInt64Builder,
27    },
28    buffer::{Buffer, OffsetBuffer},
29};
30use arrow::{buffer::ScalarBuffer, datatypes::UInt32Type};
31use arrow_array::{
32    Array, ArrayRef, Float32Array, LargeBinaryArray, ListArray, OffsetSizeTrait, RecordBatch,
33    UInt32Array, UInt64Array,
34};
35use arrow_schema::{DataType, Field, Schema, SchemaRef};
36use async_trait::async_trait;
37use datafusion::execution::SendableRecordBatchStream;
38use datafusion::physical_plan::metrics::Time;
39use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
40use fst::{Automaton, IntoStreamer, Streamer};
41use futures::{FutureExt, Stream, StreamExt, TryStreamExt, stream};
42use itertools::{Either, Itertools};
43use lance_arrow::{RecordBatchExt, iter_str_array};
44use lance_core::cache::{
45    CacheCodec, CacheKey, CacheKeySchema, KeyBuilder, LanceCache, WeakLanceCache,
46};
47use lance_core::deepsize::DeepSizeOf;
48use lance_core::error::{DataFusionResult, LanceOptionExt};
49use lance_core::utils::address::RowAddress;
50use lance_core::utils::tokio::{get_num_compute_intensive_cpus, spawn_cpu};
51use lance_core::utils::tracing::{IO_TYPE_LOAD_SCALAR_PART, TRACE_IO_EVENTS};
52use lance_core::{Error, ROW_ID, ROW_ID_FIELD, Result};
53use lance_select::{RowAddrMask, RowAddrTreeMap};
54use roaring::RoaringBitmap;
55use std::sync::LazyLock;
56use tokio::{
57    sync::{Mutex, OnceCell},
58    task::spawn_blocking,
59};
60use tracing::{info, instrument, warn};
61
62use super::documents::{
63    DocId, DocLengths, DocVisibility, PartitionDocumentStore, PartitionDocuments,
64};
65use super::encoding::{MAX_POSTING_BLOCK_SIZE, PositionBlockBuilder};
66use super::impact::{IMPACT_LEVEL1_BLOCKS, ImpactSkipData, ImpactSkipDataBuilder};
67use super::iter::PostingListIterator;
68use super::tokenizer::{LEGACY_BLOCK_SIZE, validate_block_size};
69use super::{InvertedIndexBuilder, InvertedIndexParams, wand::*};
70use super::{
71    builder::{
72        BLOCK_SIZE, ScoredDoc, doc_file_path,
73        inverted_list_schema_for_version_with_block_size_and_impacts, posting_file_path,
74        token_file_path,
75    },
76    iter::PlainPostingListIterator,
77    query::*,
78    scorer::{B, IndexBM25Scorer, K1, Scorer, idf},
79};
80use super::{
81    builder::{InnerBuilder, PositionRecorder},
82    iter::CompressedPostingListIterator,
83};
84use crate::pbold;
85use crate::progress::IndexBuildProgress;
86use crate::scalar::inverted::scorer::MemBM25Scorer;
87use crate::scalar::inverted::tokenizer::document_tokenizer::LanceTokenizer;
88use crate::scalar::{
89    AnyQuery, BuiltinIndexType, CreatedIndex, IndexReader, IndexStore, MetricsCollector,
90    OldIndexDataFilter, RowIdRemapper, ScalarIndex, ScalarIndexParams, SearchResult, TokenQuery,
91    UpdateCriteria,
92};
93use crate::{FtsPrewarmOptions, Index};
94use crate::{prefilter::PreFilter, scalar::inverted::iter::take_fst_keys};
95use std::str::FromStr;
96
97// Version 0: Arrow TokenSetFormat (legacy)
98// Version 1: Fst TokenSetFormat with per-doc compressed positions
99// Version 2: Fst TokenSetFormat with shared posting-list position streams.
100// Version 3: Version 2 layout with configurable posting blocks and analyzer metadata.
101pub const INVERTED_INDEX_VERSION_V1: u32 = 1;
102pub const INVERTED_INDEX_VERSION_V2: u32 = 2;
103pub const INVERTED_INDEX_VERSION_V3: u32 = 3;
104pub const TOKENS_FILE: &str = "tokens.lance";
105pub const INVERT_LIST_FILE: &str = "invert.lance";
106pub const DOCS_FILE: &str = "docs.lance";
107pub const METADATA_FILE: &str = "metadata.lance";
108
109/// Partitions searched per CPU-pool task. Each chunk loads concurrently and
110/// then scores sequentially so query concurrency does not flood the pool with
111/// one small task per partition. `LANCE_FTS_SEARCH_CHUNK=1` restores the
112/// per-partition task shape.
113fn fts_search_chunk() -> usize {
114    static CHUNK: LazyLock<usize> = LazyLock::new(|| {
115        std::env::var("LANCE_FTS_SEARCH_CHUNK")
116            .ok()
117            .and_then(|value| value.parse().ok())
118            .filter(|&value| value >= 1)
119            .unwrap_or(16)
120    });
121    *CHUNK
122}
123
124pub const TOKEN_COL: &str = "_token";
125pub const TOKEN_ID_COL: &str = "_token_id";
126pub const TOKEN_FST_BYTES_COL: &str = "_token_fst_bytes";
127pub const TOKEN_NEXT_ID_COL: &str = "_token_next_id";
128pub const TOKEN_TOTAL_LENGTH_COL: &str = "_token_total_length";
129pub const FREQUENCY_COL: &str = "_frequency";
130pub const POSITION_COL: &str = "_position";
131pub const COMPRESSED_POSITION_COL: &str = "_compressed_position";
132pub const POSITION_BLOCK_OFFSET_COL: &str = "_position_block_offset";
133pub const POSTING_COL: &str = "_posting";
134pub const IMPACT_COL: &str = "_impacts";
135pub const MAX_SCORE_COL: &str = "_max_score";
136pub const LENGTH_COL: &str = "_length";
137pub const BLOCK_MAX_SCORE_COL: &str = "_block_max_score";
138pub const NUM_TOKEN_COL: &str = "_num_tokens";
139pub const SCORE_COL: &str = "_score";
140pub const TOKEN_SET_FORMAT_KEY: &str = "token_set_format";
141pub const POSTING_TAIL_CODEC_KEY: &str = "posting_tail_codec";
142pub const FTS_FORMAT_VERSION_KEY: &str = "format_version";
143pub const POSITIONS_LAYOUT_KEY: &str = "positions_layout";
144pub const POSITIONS_CODEC_KEY: &str = "positions_codec";
145pub const POSTING_BLOCK_SIZE_KEY: &str = "posting_block_size";
146pub const POSTING_TAIL_CODEC_FIXED32_V1: &str = "fixed32_v1";
147pub const POSTING_TAIL_CODEC_VARINT_DELTA_V1: &str = "varint_delta_v1";
148pub const POSITIONS_LAYOUT_SHARED_STREAM_V2: &str = "shared_stream_v2";
149pub const POSITIONS_CODEC_VARINT_DOC_DELTA_V2: &str = "varint_doc_delta_v2";
150pub const POSITIONS_CODEC_PACKED_DELTA_V1: &str = "packed_delta_v1";
151pub const DELETED_FRAGMENTS_COL: &str = "deleted_fragments";
152
153// Just a heuristic when we need to pre-allocate memory for tokens
154pub const ESTIMATED_MAX_TOKENS_PER_ROW: usize = 4 * 1024;
155
156pub static SCORE_FIELD: LazyLock<Field> =
157    LazyLock::new(|| Field::new(SCORE_COL, DataType::Float32, true));
158pub static FTS_SCHEMA: LazyLock<SchemaRef> =
159    LazyLock::new(|| Arc::new(Schema::new(vec![ROW_ID_FIELD.clone(), SCORE_FIELD.clone()])));
160static ROW_ID_SCHEMA: LazyLock<SchemaRef> =
161    LazyLock::new(|| Arc::new(Schema::new(vec![ROW_ID_FIELD.clone()])));
162
163pub fn resolve_fts_format_version(
164    value: Option<&str>,
165) -> std::result::Result<InvertedListFormatVersion, Error> {
166    match value {
167        Some(value) => value.parse(),
168        None => Ok(default_fts_format_version()),
169    }
170}
171
172pub fn default_fts_format_version() -> InvertedListFormatVersion {
173    InvertedListFormatVersion::V2
174}
175
176pub fn current_fts_format_version() -> InvertedListFormatVersion {
177    default_fts_format_version()
178}
179
180pub fn max_supported_fts_format_version() -> InvertedListFormatVersion {
181    InvertedListFormatVersion::V3
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
185pub enum InvertedListFormatVersion {
186    V1,
187    #[default]
188    V2,
189    V3,
190}
191
192impl InvertedListFormatVersion {
193    pub fn from_posting_tail_codec(codec: PostingTailCodec) -> Self {
194        match codec {
195            PostingTailCodec::Fixed32 => Self::V1,
196            PostingTailCodec::VarintDelta => Self::V2,
197        }
198    }
199
200    pub fn from_posting_tail_codec_and_block_size(
201        codec: PostingTailCodec,
202        block_size: usize,
203    ) -> Result<Self> {
204        validate_block_size(block_size)?;
205        let format_version = match (codec, block_size) {
206            (PostingTailCodec::Fixed32, LEGACY_BLOCK_SIZE) => Self::V1,
207            (PostingTailCodec::VarintDelta, LEGACY_BLOCK_SIZE) => Self::V2,
208            (PostingTailCodec::VarintDelta, 256) => Self::V3,
209            (PostingTailCodec::Fixed32, 256) => {
210                return Err(Error::invalid_input(
211                    "FTS format_version=3 requires the varint-delta posting tail codec".to_string(),
212                ));
213            }
214            _ => unreachable!("validate_block_size limits supported block sizes"),
215        };
216        validate_format_version_block_size(format_version, block_size)?;
217        Ok(format_version)
218    }
219
220    pub fn index_version(self) -> u32 {
221        match self {
222            Self::V1 => INVERTED_INDEX_VERSION_V1,
223            Self::V2 => INVERTED_INDEX_VERSION_V2,
224            Self::V3 => INVERTED_INDEX_VERSION_V3,
225        }
226    }
227
228    pub fn posting_tail_codec(self) -> PostingTailCodec {
229        match self {
230            Self::V1 => PostingTailCodec::Fixed32,
231            Self::V2 | Self::V3 => PostingTailCodec::VarintDelta,
232        }
233    }
234
235    pub fn position_codec(self) -> Option<PositionStreamCodec> {
236        match self {
237            Self::V1 => None,
238            Self::V2 | Self::V3 => Some(PositionStreamCodec::PackedDelta),
239        }
240    }
241
242    pub fn uses_shared_position_stream(self) -> bool {
243        matches!(self, Self::V2 | Self::V3)
244    }
245}
246
247impl FromStr for InvertedListFormatVersion {
248    type Err = Error;
249
250    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
251        match s.trim() {
252            "1" | "v1" | "V1" => Ok(Self::V1),
253            "2" | "v2" | "V2" => Ok(Self::V2),
254            "3" | "v3" | "V3" => Ok(Self::V3),
255            other => Err(Error::index(format!(
256                "unsupported FTS format version {}, expected 1, 2, or 3",
257                other
258            ))),
259        }
260    }
261}
262
263pub fn default_fts_format_version_for_block_size(
264    block_size: usize,
265) -> Result<InvertedListFormatVersion> {
266    validate_block_size(block_size)?;
267    match block_size {
268        LEGACY_BLOCK_SIZE => Ok(InvertedListFormatVersion::V2),
269        256 => Ok(InvertedListFormatVersion::V3),
270        _ => unreachable!("validate_block_size limits supported block sizes"),
271    }
272}
273
274pub fn validate_format_version_block_size(
275    format_version: InvertedListFormatVersion,
276    block_size: usize,
277) -> Result<()> {
278    validate_block_size(block_size)?;
279    match (format_version, block_size) {
280        (InvertedListFormatVersion::V1 | InvertedListFormatVersion::V2, LEGACY_BLOCK_SIZE)
281        | (InvertedListFormatVersion::V3, _) => Ok(()),
282        (InvertedListFormatVersion::V1 | InvertedListFormatVersion::V2, 256) => {
283            Err(Error::invalid_input(format!(
284                "FTS format_version={} is incompatible with block_size=256; use format_version=3",
285                format_version.index_version()
286            )))
287        }
288        _ => unreachable!("validate_block_size limits supported block sizes"),
289    }
290}
291
292#[derive(Debug)]
293struct PartitionCandidates<C> {
294    tokens_by_position: Vec<String>,
295    grouped_expansions: Vec<GroupedExpansionTerms>,
296    candidates: Vec<DocCandidate<C>>,
297}
298
299struct ModernSearchRequest<'a> {
300    tokens: Arc<Tokens>,
301    params: Arc<FtsSearchParams>,
302    operator: Operator,
303    mask: Arc<RowAddrMask>,
304    metrics: Arc<dyn MetricsCollector>,
305    scorer: &'a MemBM25Scorer,
306    impact_scorer: Arc<MemBM25Scorer>,
307    limit: usize,
308}
309
310/// Typed identity for one modern candidate after partition-local scoring.
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
312struct PartitionDocId {
313    partition_ordinal: u32,
314    doc_id: DocId,
315}
316
317impl PartitionDocId {
318    fn try_new(partition_ordinal: usize, doc_id: DocId) -> Result<Self> {
319        Ok(Self {
320            partition_ordinal: u32::try_from(partition_ordinal).map_err(|_| {
321                Error::index(format!(
322                    "FTS partition ordinal {partition_ordinal} exceeds candidate identity capacity"
323                ))
324            })?,
325            doc_id,
326        })
327    }
328
329    fn partition_ordinal(self) -> usize {
330        self.partition_ordinal as usize
331    }
332}
333
334#[derive(Debug, Clone)]
335struct ScoredPartitionDoc {
336    document: PartitionDocId,
337    score: OrderedFloat,
338}
339
340impl ScoredPartitionDoc {
341    fn new(document: PartitionDocId, score: f32) -> Self {
342        Self {
343            document,
344            score: OrderedFloat(score),
345        }
346    }
347}
348
349impl PartialEq for ScoredPartitionDoc {
350    fn eq(&self, other: &Self) -> bool {
351        self.score == other.score
352    }
353}
354
355impl Eq for ScoredPartitionDoc {}
356
357impl PartialOrd for ScoredPartitionDoc {
358    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
359        Some(self.cmp(other))
360    }
361}
362
363impl Ord for ScoredPartitionDoc {
364    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
365        self.score.cmp(&other.score)
366    }
367}
368
369const MAX_CONCURRENT_ADDRESS_READ_BYTES: usize = 64 * 1024 * 1024;
370
371fn address_read_concurrency(io_parallelism: usize, largest_read_bytes: usize) -> usize {
372    let io_parallelism = io_parallelism.max(1);
373    if largest_read_bytes == 0 {
374        return io_parallelism;
375    }
376    io_parallelism.min(
377        MAX_CONCURRENT_ADDRESS_READ_BYTES
378            .checked_div(largest_read_bytes)
379            .unwrap_or(0)
380            .max(1),
381    )
382}
383
384fn push_scored_key(
385    candidates: &mut BinaryHeap<Reverse<ScoredDoc>>,
386    limit: usize,
387    key: u64,
388    score: f32,
389) {
390    if candidates.len() < limit {
391        candidates.push(Reverse(ScoredDoc::new(key, score)));
392    } else if candidates
393        .peek()
394        .is_some_and(|candidate| candidate.0.score.0 < score)
395    {
396        candidates.pop();
397        candidates.push(Reverse(ScoredDoc::new(key, score)));
398    }
399}
400
401fn push_scored_partition_doc(
402    candidates: &mut BinaryHeap<Reverse<ScoredPartitionDoc>>,
403    limit: usize,
404    document: PartitionDocId,
405    score: f32,
406) {
407    if candidates.len() < limit {
408        candidates.push(Reverse(ScoredPartitionDoc::new(document, score)));
409    } else if candidates
410        .peek()
411        .is_some_and(|candidate| candidate.0.score.0 < score)
412    {
413        candidates.pop();
414        candidates.push(Reverse(ScoredPartitionDoc::new(document, score)));
415    }
416}
417
418fn rescore_partition_candidates<C>(
419    partition: PartitionCandidates<C>,
420    scorer: &MemBM25Scorer,
421    idf_cache: &mut HashMap<String, f32>,
422) -> Vec<(C, f32)> {
423    let PartitionCandidates {
424        tokens_by_position,
425        grouped_expansions,
426        candidates,
427    } = partition;
428    let idf_by_position = tokens_by_position
429        .iter()
430        .map(|token| {
431            *idf_cache
432                .entry(token.clone())
433                .or_insert_with(|| scorer.query_weight(token))
434        })
435        .collect::<Vec<_>>();
436    let grouped_positions = grouped_expansions
437        .iter()
438        .map(|group| group.position)
439        .collect::<HashSet<_>>();
440
441    candidates
442        .into_iter()
443        .map(
444            |DocCandidate {
445                 document,
446                 posting_doc_id,
447                 freqs,
448                 doc_length,
449             }| {
450                let mut score = 0.0;
451                for (term_index, freq) in freqs {
452                    if grouped_positions.contains(&term_index) {
453                        continue;
454                    }
455                    debug_assert!((term_index as usize) < idf_by_position.len());
456                    score +=
457                        idf_by_position[term_index as usize] * scorer.doc_weight(freq, doc_length);
458                }
459                for group in &grouped_expansions {
460                    for term in group.terms.iter() {
461                        let Some(freq) = term.frequency(posting_doc_id) else {
462                            continue;
463                        };
464                        score += term.query_weight() * scorer.doc_weight(freq, doc_length);
465                    }
466                }
467                (document, score)
468            },
469        )
470        .collect()
471}
472
473#[derive(Debug)]
474struct LoadedPostings {
475    postings: Vec<PostingIterator>,
476    grouped_expansions: Vec<GroupedExpansionTerms>,
477    impact_safe: bool,
478    exact_scoring_required: bool,
479}
480
481enum LoadedDocLengths {
482    Legacy(Arc<DocSet>),
483    Modern(Arc<DocLengths>),
484}
485
486impl LoadedDocLengths {
487    fn scoring_num_tokens(&self, doc_id: u32) -> u32 {
488        match self {
489            Self::Legacy(docs) => docs.scoring_num_tokens(doc_id),
490            Self::Modern(lengths) => lengths.scoring(DocId::new(doc_id)),
491        }
492    }
493
494    fn num_tokens_by_row_id(&self, row_id: u64) -> u32 {
495        match self {
496            Self::Legacy(docs) => docs.num_tokens_by_row_id(row_id),
497            Self::Modern(_) => unreachable!("modern posting lists use dense DocIds"),
498        }
499    }
500}
501
502impl LoadedPostings {
503    fn empty() -> Self {
504        Self {
505            postings: Vec::new(),
506            grouped_expansions: Vec::new(),
507            impact_safe: false,
508            exact_scoring_required: false,
509        }
510    }
511}
512
513#[derive(Debug)]
514struct GroupedExpansionTerms {
515    position: u32,
516    terms: Arc<[GroupedTermScorer]>,
517}
518
519#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Default)]
520pub enum TokenSetFormat {
521    Arrow,
522    #[default]
523    Fst,
524}
525
526impl Display for TokenSetFormat {
527    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528        match self {
529            Self::Arrow => f.write_str("arrow"),
530            Self::Fst => f.write_str("fst"),
531        }
532    }
533}
534
535impl FromStr for TokenSetFormat {
536    type Err = Error;
537
538    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
539        match s.trim() {
540            "" => Ok(Self::Arrow),
541            "arrow" => Ok(Self::Arrow),
542            "fst" => Ok(Self::Fst),
543            other => Err(Error::index(format!(
544                "unsupported token set format {}",
545                other
546            ))),
547        }
548    }
549}
550
551impl DeepSizeOf for TokenSetFormat {
552    fn deep_size_of_children(&self, _: &mut lance_core::deepsize::Context) -> usize {
553        0
554    }
555}
556
557#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
558pub enum PositionStreamCodec {
559    VarintDocDelta,
560    #[default]
561    PackedDelta,
562}
563
564#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
565pub enum PostingTailCodec {
566    Fixed32,
567    #[default]
568    VarintDelta,
569}
570
571impl PostingTailCodec {
572    pub fn as_str(self) -> &'static str {
573        match self {
574            Self::Fixed32 => POSTING_TAIL_CODEC_FIXED32_V1,
575            Self::VarintDelta => POSTING_TAIL_CODEC_VARINT_DELTA_V1,
576        }
577    }
578
579    fn from_metadata_value(value: &str) -> Result<Self> {
580        match value.trim() {
581            POSTING_TAIL_CODEC_FIXED32_V1 => Ok(Self::Fixed32),
582            POSTING_TAIL_CODEC_VARINT_DELTA_V1 => Ok(Self::VarintDelta),
583            other => Err(Error::index(format!(
584                "unsupported posting tail codec {}",
585                other
586            ))),
587        }
588    }
589}
590
591pub(super) fn parse_posting_tail_codec(
592    metadata: &HashMap<String, String>,
593) -> Result<PostingTailCodec> {
594    Ok(metadata
595        .get(POSTING_TAIL_CODEC_KEY)
596        .map(|codec| PostingTailCodec::from_metadata_value(codec))
597        .transpose()?
598        .unwrap_or(PostingTailCodec::Fixed32))
599}
600
601pub(super) fn parse_posting_block_size(metadata: &HashMap<String, String>) -> Result<usize> {
602    metadata
603        .get(POSTING_BLOCK_SIZE_KEY)
604        .map(|value| {
605            let block_size = value.parse::<usize>().map_err(|err| {
606                Error::index(format!(
607                    "invalid {POSTING_BLOCK_SIZE_KEY} metadata value {value:?}: {err}"
608                ))
609            })?;
610            validate_block_size(block_size)
611        })
612        .transpose()
613        .map(|block_size| block_size.unwrap_or(LEGACY_BLOCK_SIZE))
614}
615
616impl PositionStreamCodec {
617    pub fn as_str(self) -> &'static str {
618        match self {
619            Self::VarintDocDelta => POSITIONS_CODEC_VARINT_DOC_DELTA_V2,
620            Self::PackedDelta => POSITIONS_CODEC_PACKED_DELTA_V1,
621        }
622    }
623
624    fn from_metadata_value(value: &str) -> Result<Self> {
625        match value.trim() {
626            POSITIONS_CODEC_VARINT_DOC_DELTA_V2 => Ok(Self::VarintDocDelta),
627            POSITIONS_CODEC_PACKED_DELTA_V1 => Ok(Self::PackedDelta),
628            other => Err(Error::index(format!(
629                "unsupported positions codec {}",
630                other
631            ))),
632        }
633    }
634}
635
636fn parse_shared_position_codec(metadata: &HashMap<String, String>) -> Result<PositionStreamCodec> {
637    if let Some(codec) = metadata.get(POSITIONS_CODEC_KEY) {
638        return PositionStreamCodec::from_metadata_value(codec);
639    }
640
641    match metadata
642        .get(POSITIONS_LAYOUT_KEY)
643        .map(|layout| layout.as_str())
644    {
645        Some(POSITIONS_LAYOUT_SHARED_STREAM_V2) => Ok(PositionStreamCodec::VarintDocDelta),
646        _ => Ok(PositionStreamCodec::VarintDocDelta),
647    }
648}
649
650pub(super) fn parse_format_version_from_metadata(
651    metadata: &HashMap<String, String>,
652) -> Result<InvertedListFormatVersion> {
653    if let Some(value) = metadata.get(FTS_FORMAT_VERSION_KEY) {
654        let format_version = InvertedListFormatVersion::from_str(value)?;
655        let block_size = parse_posting_block_size(metadata)?;
656        validate_format_version_block_size(format_version, block_size)?;
657        return Ok(format_version);
658    }
659    let block_size = parse_posting_block_size(metadata)?;
660    if block_size == 256 {
661        if metadata
662            .get(POSTING_TAIL_CODEC_KEY)
663            .map(|_| parse_posting_tail_codec(metadata))
664            .transpose()?
665            .is_some_and(|posting_tail_codec| posting_tail_codec != PostingTailCodec::VarintDelta)
666        {
667            return Err(Error::index(
668                "FTS block_size=256 requires the varint-delta posting tail codec".to_string(),
669            ));
670        }
671        return Ok(InvertedListFormatVersion::V3);
672    }
673    if metadata.contains_key(POSITIONS_CODEC_KEY) || metadata.contains_key(POSITIONS_LAYOUT_KEY) {
674        return Ok(InvertedListFormatVersion::V2);
675    }
676    if parse_posting_tail_codec(metadata)? == PostingTailCodec::VarintDelta {
677        Ok(InvertedListFormatVersion::V2)
678    } else {
679        Ok(InvertedListFormatVersion::V1)
680    }
681}
682
683#[derive(Debug, Default)]
684struct InvertedPrewarmState {
685    query_ready: bool,
686    positions_ready: bool,
687}
688
689impl InvertedPrewarmState {
690    fn satisfies(&self, with_position: bool) -> bool {
691        self.query_ready && (!with_position || self.positions_ready)
692    }
693}
694
695#[derive(Clone)]
696pub struct InvertedIndex {
697    params: InvertedIndexParams,
698    store: Arc<dyn IndexStore>,
699    tokenizer: Box<dyn LanceTokenizer>,
700    token_set_format: TokenSetFormat,
701    format_version: InvertedListFormatVersion,
702    pub(crate) partitions: Vec<Arc<InvertedPartition>>,
703    corpus_stats: Arc<OnceCell<(u64, usize)>>,
704    prewarm_state: Arc<Mutex<InvertedPrewarmState>>,
705    /// Optimistic fast-path hint. Cache eviction can make it stale; the
706    /// resident resolver clears it when a weak projection upgrade misses.
707    document_projections_resident: Arc<AtomicBool>,
708    // Fragments which are contained in the index, but no longer in the dataset.
709    // These should be pruned at search time since we don't prune them at update time.
710    deleted_fragments: RoaringBitmap,
711}
712
713impl Debug for InvertedIndex {
714    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
715        f.debug_struct("InvertedIndex")
716            .field("params", &self.params)
717            .field("token_set_format", &self.token_set_format)
718            .field("format_version", &self.format_version)
719            .field("partitions", &self.partitions)
720            .field("deleted_fragments", &self.deleted_fragments)
721            .finish()
722    }
723}
724
725impl DeepSizeOf for InvertedIndex {
726    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
727        self.partitions.deep_size_of_children(context)
728    }
729}
730
731impl InvertedIndex {
732    fn format_version(&self) -> InvertedListFormatVersion {
733        self.format_version
734    }
735
736    fn index_version(&self) -> u32 {
737        match (self.token_set_format, self.format_version()) {
738            (
739                TokenSetFormat::Arrow,
740                InvertedListFormatVersion::V1 | InvertedListFormatVersion::V2,
741            ) => 0,
742            (_, format_version) => format_version.index_version(),
743        }
744    }
745
746    fn posting_tail_codec(&self) -> PostingTailCodec {
747        self.partitions
748            .first()
749            .map(|partition| partition.inverted_list.posting_tail_codec())
750            .unwrap_or_default()
751    }
752
753    fn to_builder(&self) -> InvertedIndexBuilder {
754        self.to_builder_with_offset(None)
755    }
756
757    fn to_builder_with_offset(&self, fragment_mask: Option<u64>) -> InvertedIndexBuilder {
758        if self.is_legacy() {
759            // for legacy format, we re-create the index in the new format
760            InvertedIndexBuilder::from_existing_index(
761                self.params.clone(),
762                None,
763                Vec::new(),
764                self.token_set_format,
765                fragment_mask,
766                self.deleted_fragments.clone(),
767            )
768            .with_posting_tail_codec(self.posting_tail_codec())
769        } else {
770            let partitions = match fragment_mask {
771                Some(fragment_mask) => self
772                    .partitions
773                    .iter()
774                    // Filter partitions that belong to the specified fragment
775                    // The mask contains fragment_id in high 32 bits, we check if partition's
776                    // fragment_id matches by comparing the masked result with the original mask
777                    .filter(|part| part.belongs_to_fragment(fragment_mask))
778                    .map(|part| part.id())
779                    .collect(),
780                None => self.partitions.iter().map(|part| part.id()).collect(),
781            };
782
783            InvertedIndexBuilder::from_existing_index(
784                self.params.clone(),
785                Some(self.store.clone()),
786                partitions,
787                self.token_set_format,
788                fragment_mask,
789                self.deleted_fragments.clone(),
790            )
791            .with_format_version(self.format_version())
792        }
793    }
794
795    pub fn tokenizer(&self) -> Box<dyn LanceTokenizer> {
796        self.tokenizer.clone()
797    }
798
799    pub fn params(&self) -> &InvertedIndexParams {
800        &self.params
801    }
802
803    /// Returns the number of partitions in this inverted index.
804    pub fn partition_count(&self) -> usize {
805        self.partitions.len()
806    }
807    /// Returns the set of fragments which are contained in the index, but no longer in the dataset.
808    ///
809    /// Most other indices remove data from deleted fragments when the index updates (copy-on-write).
810    /// However, this would require an expensive copy of the FTS index.  Instead, we track the deleted
811    /// fragments and prune them at search time (merge-on-read).
812    pub fn deleted_fragments(&self) -> &RoaringBitmap {
813        &self.deleted_fragments
814    }
815
816    pub async fn merge_segments(
817        segments: &[Arc<Self>],
818        new_data: SendableRecordBatchStream,
819        dest_store: &dyn IndexStore,
820        old_data_filter: Option<OldIndexDataFilter>,
821        progress: Arc<dyn IndexBuildProgress>,
822    ) -> Result<CreatedIndex> {
823        let Some(first) = segments.first() else {
824            return Err(Error::invalid_input(
825                "cannot merge inverted index without at least one source segment".to_string(),
826            ));
827        };
828
829        for segment in segments.iter().skip(1) {
830            if segment.params != first.params {
831                return Err(Error::index(
832                    "cannot merge inverted index segments with different parameters".to_string(),
833                ));
834            }
835            if segment.token_set_format != first.token_set_format {
836                return Err(Error::index(
837                    "cannot merge inverted index segments with different token set formats"
838                        .to_string(),
839                ));
840            }
841            if segment.format_version() != first.format_version() {
842                return Err(Error::index(
843                    "cannot merge inverted index segments with different format versions"
844                        .to_string(),
845                ));
846            }
847            if segment.posting_tail_codec() != first.posting_tail_codec() {
848                return Err(Error::index(
849                    "cannot merge inverted index segments with different posting tail codecs"
850                        .to_string(),
851                ));
852            }
853        }
854
855        let mut builder = InvertedIndexBuilder::new(first.params.clone()).with_progress(progress);
856        builder = builder
857            .with_token_set_format(first.token_set_format)
858            .with_format_version(first.format_version());
859        let files = builder
860            .update_from_segments(new_data, dest_store, segments, old_data_filter)
861            .await?;
862
863        let details = pbold::InvertedIndexDetails::try_from(&first.params)?;
864
865        Ok(CreatedIndex {
866            index_details: prost_types::Any::from_msg(&details).unwrap(),
867            index_version: first.index_version(),
868            files,
869        })
870    }
871
872    /// Build a single-segment [`MemBM25Scorer`] whose per-term IDF table
873    /// covers every token that the per-partition scoring loop will look
874    /// up. For fuzzy queries that means the union of Levenshtein
875    /// expansions, not just the raw query tokens — otherwise
876    /// `query_weight(expanded_token)` returns 0 and the BM25 contribution
877    /// of every expanded match is discarded.
878    pub async fn bm25_base_scorer(
879        &self,
880        query_tokens: &Tokens,
881        params: &FtsSearchParams,
882        metrics: Option<&dyn MetricsCollector>,
883    ) -> Result<MemBM25Scorer> {
884        if matches!(params.fuzziness, Some(n) if n != 0) {
885            let expanded = self.expand_fuzzy_tokens(query_tokens, params)?;
886            self.bm25_scorer_for_final_tokens(&expanded, metrics).await
887        } else {
888            self.bm25_scorer_for_final_tokens(query_tokens, metrics)
889                .await
890        }
891    }
892
893    /// Scorer for a token list that needs no further fuzzy expansion: dedup
894    /// the terms and pull their document frequencies. `bm25_search` calls
895    /// this with the tokens it already expanded, so the expansion runs once
896    /// per query rather than once for the scorer and once per partition.
897    async fn bm25_scorer_for_final_tokens(
898        &self,
899        tokens: &Tokens,
900        metrics: Option<&dyn MetricsCollector>,
901    ) -> Result<MemBM25Scorer> {
902        let (total_tokens, num_docs) = self.aggregate_corpus_stats().await?;
903        let mut terms: Vec<String> = Vec::new();
904        let mut seen = HashSet::new();
905        for token in tokens {
906            if seen.insert(token.to_string()) {
907                terms.push(token.to_string());
908            }
909        }
910        let mut token_docs = HashMap::with_capacity(terms.len());
911        for term in &terms {
912            let df = self.df_for_term(term, metrics).await?;
913            token_docs.insert(term.clone(), df);
914        }
915        Ok(MemBM25Scorer::new(total_tokens, num_docs, token_docs))
916    }
917
918    pub async fn bm25_stats_for_terms(
919        &self,
920        terms: &[String],
921        metrics: Option<&dyn MetricsCollector>,
922    ) -> Result<(u64, usize, Vec<usize>)> {
923        let (total_tokens, num_docs) = self.aggregate_corpus_stats().await?;
924        let token_docs =
925            futures::future::try_join_all(terms.iter().map(|term| self.df_for_term(term, metrics)))
926                .await?;
927        Ok((total_tokens, num_docs, token_docs))
928    }
929
930    /// Aggregate immutable per-partition corpus statistics.  New modern files
931    /// read both values from the already-opened docs footer; older partitioned
932    /// files scan `_num_tokens` once as a compatibility fallback.
933    async fn aggregate_corpus_stats(&self) -> Result<(u64, usize)> {
934        self.corpus_stats
935            .get_or_try_init(|| async {
936                let io_parallelism = self.store.io_parallelism();
937                let futures = self
938                    .partitions
939                    .iter()
940                    .map(|p| {
941                        let part = p.clone();
942                        async move { part.docs.stats().await }
943                    })
944                    .collect::<Vec<_>>();
945                let stats = stream::iter(futures)
946                    .buffer_unordered(io_parallelism)
947                    .try_collect::<Vec<_>>()
948                    .await?;
949                let mut total_tokens = 0_u64;
950                let mut num_docs = 0_usize;
951                for stat in stats {
952                    total_tokens = total_tokens
953                        .checked_add(stat.total_tokens)
954                        .ok_or_else(|| Error::index("FTS corpus token count overflows u64"))?;
955                    num_docs = num_docs
956                        .checked_add(stat.num_docs)
957                        .ok_or_else(|| Error::index("FTS corpus document count overflows usize"))?;
958                }
959                Ok((total_tokens, num_docs))
960            })
961            .await
962            .copied()
963    }
964
965    /// Sum the posting-list length for `term` across this index's partitions
966    /// via single-row reads, with partition lookups bounded by the store's
967    /// `io_parallelism()`.
968    async fn df_for_term(
969        &self,
970        term: &str,
971        metrics: Option<&dyn MetricsCollector>,
972    ) -> Result<usize> {
973        let io_parallelism = self.store.io_parallelism();
974        let futures = self
975            .partitions
976            .iter()
977            .map(|part| {
978                let part = part.clone();
979                async move {
980                    match part.tokens.get(term) {
981                        Some(token_id) => {
982                            part.inverted_list
983                                .posting_len_for_token(token_id, metrics)
984                                .await
985                        }
986                        None => Ok(0),
987                    }
988                }
989            })
990            .collect::<Vec<_>>();
991        let dfs: Vec<usize> = stream::iter(futures)
992            .buffer_unordered(io_parallelism)
993            .try_collect()
994            .await?;
995        Ok(dfs.into_iter().sum())
996    }
997
998    /// Expand fuzzy query tokens against all partitions in this segment.
999    ///
1000    /// `params.max_expansions` caps the whole query's expansion, not any
1001    /// single partition's: for each query token the per-partition candidates
1002    /// (each streamed in FST key order) merge into one lexicographically
1003    /// ordered set, and the remaining budget takes a prefix of it. The
1004    /// selected terms are a pure function of the segment's vocabulary, so
1005    /// splitting the same corpus into more partitions cannot change which
1006    /// terms a fuzzy query matches.
1007    pub fn expand_fuzzy_tokens(&self, tokens: &Tokens, params: &FtsSearchParams) -> Result<Tokens> {
1008        let mut expanded_tokens = Vec::new();
1009        let mut expanded_positions = Vec::new();
1010        let mut seen = HashSet::new();
1011        for token_idx in 0..tokens.len() {
1012            let remaining = params.max_expansions.saturating_sub(expanded_tokens.len());
1013            if remaining == 0 {
1014                break;
1015            }
1016            let token = tokens.get_token(token_idx);
1017            let position = tokens.position(token_idx);
1018            // Each partition contributes at most its `remaining`
1019            // lexicographically smallest candidates, so the global
1020            // lex-smallest `remaining` selection below is unaffected by the
1021            // per-partition truncation.
1022            let mut candidates = BTreeSet::new();
1023            let base_prefix_len = tokens.token_type().prefix_len(token) as u32;
1024            for partition in &self.partitions {
1025                partition.collect_fuzzy_candidates(
1026                    token,
1027                    base_prefix_len,
1028                    params,
1029                    remaining,
1030                    &mut candidates,
1031                )?;
1032            }
1033            for candidate in candidates {
1034                if expanded_tokens.len() >= params.max_expansions {
1035                    break;
1036                }
1037                if seen.insert((candidate.clone(), position)) {
1038                    expanded_tokens.push(candidate);
1039                    expanded_positions.push(position);
1040                }
1041            }
1042        }
1043        Ok(Tokens::with_positions(
1044            expanded_tokens,
1045            expanded_positions,
1046            tokens.token_type().clone(),
1047        ))
1048    }
1049
1050    /// Search documents that match the query and return row ids sorted by BM25 score.
1051    ///
1052    /// When `base_scorer` is provided, search uses those corpus-level BM25 statistics
1053    /// instead of deriving them from this segment alone.
1054    #[instrument(level = "debug", skip_all)]
1055    pub async fn bm25_search(
1056        &self,
1057        tokens: Arc<Tokens>,
1058        params: Arc<FtsSearchParams>,
1059        operator: Operator,
1060        prefilter: Arc<dyn PreFilter>,
1061        metrics: Arc<dyn MetricsCollector>,
1062        base_scorer: Option<&MemBM25Scorer>,
1063    ) -> Result<(Vec<u64>, Vec<f32>)> {
1064        // Fuzzy expansion runs once here, with the global `max_expansions`
1065        // budget, instead of once per partition: partitions receive the
1066        // final token list, so the matched terms cannot depend on how the
1067        // corpus happens to be partitioned.
1068        let tokens = if matches!(params.fuzziness, Some(n) if n != 0) {
1069            let expanded = Arc::new(self.expand_fuzzy_tokens(tokens.as_ref(), params.as_ref())?);
1070            if operator == Operator::And || params.phrase_slop.is_some() {
1071                // AND/phrase semantics require every original token position
1072                // to keep at least one expansion; a position that expands to
1073                // nothing anywhere in the segment can never be matched.
1074                let surviving = (0..expanded.len())
1075                    .map(|idx| expanded.position(idx))
1076                    .collect::<HashSet<_>>();
1077                if (0..tokens.len()).any(|idx| !surviving.contains(&tokens.position(idx))) {
1078                    return Ok((Vec::new(), Vec::new()));
1079                }
1080            }
1081            expanded
1082        } else {
1083            tokens
1084        };
1085
1086        // The wand only consults `scorer.doc_weight`, which is metadata-free.
1087        // The outer aggregation below consults `scorer.query_weight`, which
1088        // hits per-token `posting_len`; building a `MemBM25Scorer` with
1089        // precomputed per-term IDFs avoids the v2 bulk metadata pull.
1090        let local_scorer;
1091        let scorer: &MemBM25Scorer = if let Some(base_scorer) = base_scorer {
1092            base_scorer
1093        } else {
1094            local_scorer = self
1095                .bm25_scorer_for_final_tokens(tokens.as_ref(), Some(metrics.as_ref()))
1096                .await?;
1097            &local_scorer
1098        };
1099        let impact_scorer = Arc::new(scorer.clone());
1100
1101        let limit = params.limit.unwrap_or(usize::MAX);
1102        if limit == 0 {
1103            return Ok((Vec::new(), Vec::new()));
1104        }
1105        let mask = prefilter.mask();
1106        if self.is_legacy() {
1107            self.bm25_search_legacy(
1108                tokens,
1109                params,
1110                operator,
1111                mask,
1112                metrics,
1113                scorer,
1114                impact_scorer,
1115                limit,
1116            )
1117            .await
1118        } else {
1119            self.bm25_search_modern(ModernSearchRequest {
1120                tokens,
1121                params,
1122                operator,
1123                mask,
1124                metrics,
1125                scorer,
1126                impact_scorer,
1127                limit,
1128            })
1129            .await
1130        }
1131    }
1132
1133    #[allow(clippy::too_many_arguments)]
1134    async fn bm25_search_legacy(
1135        &self,
1136        tokens: Arc<Tokens>,
1137        params: Arc<FtsSearchParams>,
1138        operator: Operator,
1139        mask: Arc<RowAddrMask>,
1140        metrics: Arc<dyn MetricsCollector>,
1141        scorer: &MemBM25Scorer,
1142        impact_scorer: Arc<MemBM25Scorer>,
1143        limit: usize,
1144    ) -> Result<(Vec<u64>, Vec<f32>)> {
1145        let impact_shared_threshold = Arc::new(AtomicU32::new(f32::NEG_INFINITY.to_bits()));
1146        let io_parallelism = self.store.io_parallelism();
1147        let parts = self
1148            .partitions
1149            .chunks(fts_search_chunk())
1150            .map(|chunk| {
1151                let chunk = chunk.to_vec();
1152                let tokens = tokens.clone();
1153                let params = params.clone();
1154                let mask = mask.clone();
1155                let metrics = metrics.clone();
1156                let impact_scorer = impact_scorer.clone();
1157                let impact_shared_threshold = impact_shared_threshold.clone();
1158                async move {
1159                    let loads = chunk.into_iter().map(|part| {
1160                        let tokens = tokens.clone();
1161                        let params = params.clone();
1162                        let metrics = metrics.clone();
1163                        let impact_scorer = impact_scorer.clone();
1164                        let impact_shared_threshold = impact_shared_threshold.clone();
1165                        async move {
1166                            let LoadedPostings {
1167                                postings,
1168                                grouped_expansions,
1169                                impact_safe,
1170                                exact_scoring_required,
1171                            } = part
1172                                .load_posting_lists(
1173                                    tokens.as_ref(),
1174                                    params.as_ref(),
1175                                    operator,
1176                                    impact_scorer.as_ref(),
1177                                    metrics.as_ref(),
1178                                )
1179                                .await?;
1180                            if postings.is_empty() {
1181                                return Result::Ok(None);
1182                            }
1183                            let max_position = postings
1184                                .iter()
1185                                .map(|posting| posting.term_index() as usize)
1186                                .max()
1187                                .unwrap_or_default();
1188                            let mut tokens_by_position = vec![String::new(); max_position + 1];
1189                            for posting in &postings {
1190                                tokens_by_position[posting.term_index() as usize] =
1191                                    posting.token().to_owned();
1192                            }
1193                            let docs = part.docs.legacy().cloned().ok_or_else(|| {
1194                                Error::internal("legacy index contains modern partition documents")
1195                            })?;
1196                            let use_global_scorer = impact_safe || exact_scoring_required;
1197                            let threshold = if use_global_scorer {
1198                                impact_shared_threshold
1199                            } else {
1200                                Arc::new(AtomicU32::new(f32::NEG_INFINITY.to_bits()))
1201                            };
1202                            let wand_scorer = use_global_scorer.then(|| impact_scorer.clone());
1203                            Result::Ok(Some((
1204                                part,
1205                                docs,
1206                                postings,
1207                                wand_scorer,
1208                                threshold,
1209                                tokens_by_position,
1210                                grouped_expansions,
1211                            )))
1212                        }
1213                    });
1214                    let loaded = stream::iter(loads)
1215                        .buffer_unordered(io_parallelism)
1216                        .try_collect::<Vec<_>>()
1217                        .await?
1218                        .into_iter()
1219                        .flatten()
1220                        .collect::<Vec<_>>();
1221                    if loaded.is_empty() {
1222                        return Result::Ok(Vec::new());
1223                    }
1224
1225                    let results = spawn_cpu(move || {
1226                        let mut results = Vec::with_capacity(loaded.len());
1227                        for (
1228                            part,
1229                            docs,
1230                            postings,
1231                            wand_scorer,
1232                            threshold,
1233                            tokens_by_position,
1234                            grouped_expansions,
1235                        ) in loaded
1236                        {
1237                            let candidates = part.bm25_search_legacy(
1238                                docs.as_ref(),
1239                                params.as_ref(),
1240                                operator,
1241                                mask.as_ref(),
1242                                postings,
1243                                wand_scorer,
1244                                metrics.as_ref(),
1245                                threshold,
1246                            )?;
1247                            results.push(PartitionCandidates {
1248                                tokens_by_position,
1249                                grouped_expansions,
1250                                candidates,
1251                            });
1252                        }
1253                        Result::Ok(results)
1254                    })
1255                    .await?;
1256                    Result::Ok(results)
1257                }
1258            })
1259            .collect::<Vec<_>>();
1260
1261        let mut ranked = BinaryHeap::new();
1262        let mut idf_cache = HashMap::new();
1263        let mut parts = stream::iter(parts)
1264            .buffer_unordered(get_num_compute_intensive_cpus().min(32))
1265            .map_ok(|results| stream::iter(results.into_iter().map(Result::Ok)))
1266            .try_flatten();
1267        while let Some(partition) = parts.try_next().await? {
1268            for (row_id, score) in rescore_partition_candidates(partition, scorer, &mut idf_cache) {
1269                push_scored_key(&mut ranked, limit, row_id, score);
1270            }
1271        }
1272        Ok(ranked
1273            .into_sorted_vec()
1274            .into_iter()
1275            .map(|Reverse(doc)| (doc.row_id, doc.score.0))
1276            .unzip())
1277    }
1278
1279    async fn bm25_search_modern(
1280        &self,
1281        request: ModernSearchRequest<'_>,
1282    ) -> Result<(Vec<u64>, Vec<f32>)> {
1283        // Select a concrete completion path before candidate search.  The
1284        // fully resident future never builds deferred address-read state, while
1285        // a cold query keeps DocIds until its final bounded I/O phase.
1286        if self.has_resident_document_projections() {
1287            self.bm25_search_modern_resident(request).await
1288        } else {
1289            self.bm25_search_modern_deferred(request).await
1290        }
1291    }
1292
1293    fn has_resident_document_projections(&self) -> bool {
1294        if self.document_projections_resident.load(Ordering::Acquire) {
1295            return true;
1296        }
1297        let resident = self.document_projections_resident_now();
1298        if resident {
1299            self.document_projections_resident
1300                .store(true, Ordering::Release);
1301        }
1302        resident
1303    }
1304
1305    fn document_projections_resident_now(&self) -> bool {
1306        self.partitions.iter().all(|partition| {
1307            partition
1308                .docs
1309                .modern()
1310                .is_some_and(|documents| documents.projection_resident())
1311        })
1312    }
1313
1314    async fn bm25_search_modern_resident(
1315        &self,
1316        request: ModernSearchRequest<'_>,
1317    ) -> Result<(Vec<u64>, Vec<f32>)> {
1318        let ranked = self.bm25_search_modern_candidates(request).await?;
1319        if let Some(result) = self.resolve_resident_modern_candidates(&ranked)? {
1320            return Ok(result);
1321        }
1322        self.document_projections_resident
1323            .store(false, Ordering::Release);
1324        self.resolve_deferred_modern_candidates(ranked).await
1325    }
1326
1327    async fn bm25_search_modern_deferred(
1328        &self,
1329        request: ModernSearchRequest<'_>,
1330    ) -> Result<(Vec<u64>, Vec<f32>)> {
1331        // Old partitioned files without persisted stats populate their
1332        // fallback stats before deferred candidate orchestration.  A resident
1333        // search can skip this full-index synchronization: standard prewarm
1334        // has already initialized it, while any independently resident
1335        // partition loads its lengths before constructing a local scorer.
1336        if self.corpus_stats.get().is_none() {
1337            self.aggregate_corpus_stats().await?;
1338        }
1339        // For new-format indexes, aggregate_corpus_stats reads corpus stats from
1340        // persisted schema metadata (O(1)) without loading doc lengths as a side
1341        // effect. Pre-load lengths in parallel now for partitions that contain at
1342        // least one query token, so the scoring phase gets cache hits instead of
1343        // issuing sequential per-partition IO.  Partitions with no matching terms
1344        // are skipped to preserve the no-load optimization for no-hit queries.
1345        let io_parallelism = self.store.io_parallelism();
1346        let uncached_lengths = self
1347            .partitions
1348            .iter()
1349            .filter_map(|part| {
1350                let docs = part.docs.modern()?.clone();
1351                if docs.cached_lengths().is_some() {
1352                    return None;
1353                }
1354                let has_match = (0..request.tokens.len())
1355                    .any(|i| part.tokens.get(request.tokens.get_token(i)).is_some());
1356                has_match.then_some(async move { docs.lengths().await.map(|_| ()) })
1357            })
1358            .collect::<Vec<_>>();
1359        if !uncached_lengths.is_empty() {
1360            stream::iter(uncached_lengths)
1361                .buffer_unordered(io_parallelism)
1362                .try_collect::<Vec<_>>()
1363                .await?;
1364        }
1365        let ranked = self.bm25_search_modern_candidates(request).await?;
1366        self.resolve_deferred_modern_candidates(ranked).await
1367    }
1368
1369    async fn bm25_search_modern_candidates(
1370        &self,
1371        request: ModernSearchRequest<'_>,
1372    ) -> Result<Vec<Reverse<ScoredPartitionDoc>>> {
1373        let ModernSearchRequest {
1374            tokens,
1375            params,
1376            operator,
1377            mask,
1378            metrics,
1379            scorer,
1380            impact_scorer,
1381            limit,
1382        } = request;
1383        if self.partitions.len() > u32::MAX as usize {
1384            return Err(Error::index(format!(
1385                "FTS partition count {} exceeds candidate identity capacity",
1386                self.partitions.len()
1387            )));
1388        }
1389        let impact_shared_threshold = Arc::new(AtomicU32::new(f32::NEG_INFINITY.to_bits()));
1390        let io_parallelism = self.store.io_parallelism();
1391        let parts = self
1392            .partitions
1393            .chunks(fts_search_chunk())
1394            .enumerate()
1395            .map(|(chunk_ordinal, chunk)| {
1396                let first_partition_ordinal = chunk_ordinal * fts_search_chunk();
1397                let chunk = chunk
1398                    .iter()
1399                    .cloned()
1400                    .enumerate()
1401                    .map(|(offset, part)| (first_partition_ordinal + offset, part))
1402                    .collect::<Vec<_>>();
1403                let tokens = tokens.clone();
1404                let params = params.clone();
1405                let mask = mask.clone();
1406                let metrics = metrics.clone();
1407                let impact_scorer = impact_scorer.clone();
1408                let impact_shared_threshold = impact_shared_threshold.clone();
1409                async move {
1410                    let loads = chunk.into_iter().map(|(partition_ordinal, part)| {
1411                        let tokens = tokens.clone();
1412                        let params = params.clone();
1413                        let mask = mask.clone();
1414                        let metrics = metrics.clone();
1415                        let impact_scorer = impact_scorer.clone();
1416                        let impact_shared_threshold = impact_shared_threshold.clone();
1417                        async move {
1418                            let LoadedPostings {
1419                                postings,
1420                                grouped_expansions,
1421                                impact_safe,
1422                                exact_scoring_required,
1423                            } = part
1424                                .load_posting_lists(
1425                                    tokens.as_ref(),
1426                                    params.as_ref(),
1427                                    operator,
1428                                    impact_scorer.as_ref(),
1429                                    metrics.as_ref(),
1430                                )
1431                                .await?;
1432                            if postings.is_empty() {
1433                                return Result::Ok(None);
1434                            }
1435                            let documents = part.docs.modern().cloned().ok_or_else(|| {
1436                                Error::internal("modern index contains legacy partition documents")
1437                            })?;
1438                            let materialize_selected = operator == Operator::Or
1439                                && mask.max_len().is_some_and(|selected| {
1440                                    u128::from(selected).saturating_mul(100)
1441                                        <= u128::from(*FLAT_SEARCH_PERCENT_THRESHOLD)
1442                                            .saturating_mul(documents.len() as u128)
1443                                });
1444                            let visibility = match documents
1445                                .immediate_visibility(mask.clone(), materialize_selected)
1446                            {
1447                                Some(visibility) => visibility,
1448                                None => {
1449                                    documents
1450                                        .visibility(mask.clone(), materialize_selected)
1451                                        .await?
1452                                }
1453                            };
1454                            if visibility.is_empty() {
1455                                return Result::Ok(None);
1456                            }
1457                            let lengths = match documents.cached_lengths() {
1458                                Some(lengths) => lengths,
1459                                None => documents.lengths().await?,
1460                            };
1461                            let max_position = postings
1462                                .iter()
1463                                .map(|posting| posting.term_index() as usize)
1464                                .max()
1465                                .unwrap_or_default();
1466                            let mut tokens_by_position = vec![String::new(); max_position + 1];
1467                            for posting in &postings {
1468                                tokens_by_position[posting.term_index() as usize] =
1469                                    posting.token().to_owned();
1470                            }
1471                            let use_global_scorer = impact_safe || exact_scoring_required;
1472                            let threshold = if use_global_scorer {
1473                                impact_shared_threshold
1474                            } else {
1475                                Arc::new(AtomicU32::new(f32::NEG_INFINITY.to_bits()))
1476                            };
1477                            let wand_scorer = use_global_scorer.then(|| impact_scorer.clone());
1478                            Result::Ok(Some((
1479                                partition_ordinal,
1480                                part,
1481                                lengths,
1482                                visibility,
1483                                postings,
1484                                wand_scorer,
1485                                threshold,
1486                                tokens_by_position,
1487                                grouped_expansions,
1488                            )))
1489                        }
1490                    });
1491                    let loaded = stream::iter(loads)
1492                        .buffer_unordered(io_parallelism)
1493                        .try_collect::<Vec<_>>()
1494                        .await?
1495                        .into_iter()
1496                        .flatten()
1497                        .collect::<Vec<_>>();
1498                    if loaded.is_empty() {
1499                        return Result::Ok(Vec::new());
1500                    }
1501
1502                    let results = spawn_cpu(move || {
1503                        let mut results = Vec::with_capacity(loaded.len());
1504                        for (
1505                            partition_ordinal,
1506                            part,
1507                            lengths,
1508                            visibility,
1509                            postings,
1510                            wand_scorer,
1511                            threshold,
1512                            tokens_by_position,
1513                            grouped_expansions,
1514                        ) in loaded
1515                        {
1516                            let candidates = part.bm25_search_modern(
1517                                lengths.as_ref(),
1518                                &visibility,
1519                                params.as_ref(),
1520                                operator,
1521                                postings,
1522                                wand_scorer,
1523                                metrics.as_ref(),
1524                                threshold,
1525                            )?;
1526                            results.push((
1527                                partition_ordinal,
1528                                PartitionCandidates {
1529                                    tokens_by_position,
1530                                    grouped_expansions,
1531                                    candidates,
1532                                },
1533                            ));
1534                        }
1535                        Result::Ok(results)
1536                    })
1537                    .await?;
1538                    Result::Ok(results)
1539                }
1540            })
1541            .collect::<Vec<_>>();
1542
1543        let mut ranked = BinaryHeap::new();
1544        let mut idf_cache = HashMap::new();
1545        let mut parts = stream::iter(parts)
1546            .buffer_unordered(get_num_compute_intensive_cpus().min(32))
1547            .map_ok(|results| stream::iter(results.into_iter().map(Result::Ok)))
1548            .try_flatten();
1549        while let Some((partition_ordinal, partition)) = parts.try_next().await? {
1550            for (doc_id, score) in rescore_partition_candidates(partition, scorer, &mut idf_cache) {
1551                push_scored_partition_doc(
1552                    &mut ranked,
1553                    limit,
1554                    PartitionDocId::try_new(partition_ordinal, doc_id)?,
1555                    score,
1556                );
1557            }
1558        }
1559
1560        Ok(ranked.into_sorted_vec())
1561    }
1562
1563    fn resolve_resident_modern_candidates(
1564        &self,
1565        ranked: &[Reverse<ScoredPartitionDoc>],
1566    ) -> Result<Option<(Vec<u64>, Vec<f32>)>> {
1567        let mut addresses = vec![0; ranked.len()];
1568        let mut by_partition = BTreeMap::<usize, Vec<(usize, DocId)>>::new();
1569        for (rank, Reverse(candidate)) in ranked.iter().enumerate() {
1570            let partition_ordinal = candidate.document.partition_ordinal();
1571            let doc_id = candidate.document.doc_id;
1572            by_partition
1573                .entry(partition_ordinal)
1574                .or_default()
1575                .push((rank, doc_id));
1576        }
1577        for (partition_ordinal, entries) in by_partition {
1578            let documents = self
1579                .partitions
1580                .get(partition_ordinal)
1581                .and_then(|partition| partition.docs.modern())
1582                .ok_or_else(|| {
1583                    Error::internal(format!(
1584                        "resident FTS candidates reference missing modern partition ordinal {partition_ordinal}"
1585                    ))
1586                })?;
1587            let doc_ids = entries
1588                .iter()
1589                .map(|(_, doc_id)| *doc_id)
1590                .collect::<Vec<_>>();
1591            let Some(resolved) = documents.cached_row_addresses(&doc_ids)? else {
1592                return Ok(None);
1593            };
1594            for ((rank, _), address) in entries.into_iter().zip(resolved) {
1595                addresses[rank] = address;
1596            }
1597        }
1598        let scores = ranked
1599            .iter()
1600            .map(|Reverse(candidate)| candidate.score.0)
1601            .collect();
1602        Ok(Some((addresses, scores)))
1603    }
1604
1605    async fn resolve_deferred_modern_candidates(
1606        &self,
1607        ranked: Vec<Reverse<ScoredPartitionDoc>>,
1608    ) -> Result<(Vec<u64>, Vec<f32>)> {
1609        let mut addresses = vec![0_u64; ranked.len()];
1610        let mut by_partition = BTreeMap::<usize, Vec<(usize, DocId)>>::new();
1611        for (rank, Reverse(candidate)) in ranked.iter().enumerate() {
1612            let partition_ordinal = candidate.document.partition_ordinal();
1613            let doc_id = candidate.document.doc_id;
1614            by_partition
1615                .entry(partition_ordinal)
1616                .or_default()
1617                .push((rank, doc_id));
1618        }
1619        let mut address_reads = Vec::with_capacity(by_partition.len());
1620        let mut largest_read_bytes = 0;
1621        for (partition_ordinal, entries) in by_partition {
1622            let documents = self
1623                .partitions
1624                .get(partition_ordinal)
1625                .and_then(|partition| partition.docs.modern())
1626                .cloned()
1627                .ok_or_else(|| {
1628                    Error::internal(format!(
1629                        "deferred FTS candidates reference missing modern partition ordinal {partition_ordinal}"
1630                    ))
1631                })?;
1632            let doc_ids = entries
1633                .iter()
1634                .map(|(_, doc_id)| *doc_id)
1635                .collect::<Vec<_>>();
1636            largest_read_bytes =
1637                largest_read_bytes.max(documents.estimated_address_read_bytes(&doc_ids));
1638            address_reads.push(async move {
1639                let resolved = documents.resolve_addresses(&doc_ids).await?;
1640                Result::Ok((entries, resolved))
1641            });
1642        }
1643        let concurrency = address_read_concurrency(self.store.io_parallelism(), largest_read_bytes);
1644        let mut address_reads = stream::iter(address_reads).buffer_unordered(concurrency);
1645        while let Some((entries, resolved)) = address_reads.try_next().await? {
1646            for ((rank, _), address) in entries.into_iter().zip(resolved) {
1647                addresses[rank] = address;
1648            }
1649        }
1650        let scores = ranked
1651            .into_iter()
1652            .map(|Reverse(candidate)| candidate.score.0)
1653            .collect();
1654        Ok((addresses, scores))
1655    }
1656
1657    async fn load_legacy_index(
1658        store: Arc<dyn IndexStore>,
1659        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
1660        index_cache: &LanceCache,
1661    ) -> Result<Arc<Self>> {
1662        log::warn!("loading legacy FTS index");
1663        let tokens_fut = tokio::spawn({
1664            let store = store.clone();
1665            async move {
1666                let token_reader = store.open_index_file(TOKENS_FILE).await?;
1667                let tokenizer = token_reader
1668                    .schema()
1669                    .metadata
1670                    .get("tokenizer")
1671                    .map(|s| serde_json::from_str::<InvertedIndexParams>(s))
1672                    .transpose()?
1673                    .unwrap_or_default();
1674                let tokens = TokenSet::load(token_reader, TokenSetFormat::Arrow).await?;
1675                Result::Ok((tokenizer, tokens))
1676            }
1677        });
1678        let invert_list_fut = tokio::spawn({
1679            let store = store.clone();
1680            let index_cache_clone = index_cache.clone();
1681            async move {
1682                let invert_list_reader = store.open_index_file(INVERT_LIST_FILE).await?;
1683                let invert_list =
1684                    PostingListReader::try_new(invert_list_reader, &index_cache_clone).await?;
1685                Result::Ok(Arc::new(invert_list))
1686            }
1687        });
1688        let docs_fut = tokio::spawn({
1689            let store = store.clone();
1690            async move {
1691                let docs_reader = store.open_index_file(DOCS_FILE).await?;
1692                let docs = DocSet::load(docs_reader, true, frag_reuse_index).await?;
1693                Result::Ok(docs)
1694            }
1695        });
1696
1697        let (tokenizer_config, tokens) = tokens_fut.await??;
1698        let inverted_list = invert_list_fut.await??;
1699        let docs = docs_fut.await??;
1700
1701        let tokenizer = tokenizer_config.build()?;
1702
1703        Ok(Arc::new(Self {
1704            params: tokenizer_config,
1705            store: store.clone(),
1706            tokenizer,
1707            token_set_format: TokenSetFormat::Arrow,
1708            format_version: InvertedListFormatVersion::V1,
1709            partitions: vec![Arc::new(InvertedPartition {
1710                id: 0,
1711                store,
1712                tokens,
1713                inverted_list,
1714                docs: PartitionDocumentStore::Legacy(Arc::new(docs)),
1715                token_set_format: TokenSetFormat::Arrow,
1716            })],
1717            corpus_stats: Arc::new(OnceCell::new()),
1718            prewarm_state: Arc::new(Mutex::new(InvertedPrewarmState::default())),
1719            document_projections_resident: Arc::new(AtomicBool::new(false)),
1720            deleted_fragments: RoaringBitmap::new(),
1721        }))
1722    }
1723
1724    pub fn is_legacy(&self) -> bool {
1725        self.partitions.len() == 1 && self.partitions[0].docs.legacy().is_some()
1726    }
1727
1728    /// Read only the index's [`InvertedIndexParams`],
1729    /// Contains more complete info than manifest's lossy `InvertedIndexDetails`.
1730    pub async fn load_params(store: &dyn IndexStore) -> Result<InvertedIndexParams> {
1731        match store.open_index_file(METADATA_FILE).await {
1732            Ok(reader) => {
1733                let params = reader
1734                    .schema()
1735                    .metadata
1736                    .get("params")
1737                    .ok_or(Error::index("params not found in metadata".to_owned()))?;
1738                Ok(serde_json::from_str::<InvertedIndexParams>(params)?)
1739            }
1740            Err(metadata_error) => {
1741                // Legacy format: params live in the tokens file (see
1742                // `load_legacy_index`). Some S3 configurations return 403 for
1743                // a missing object, so the readable legacy file is the
1744                // authoritative format probe.
1745                let Ok(reader) = store.open_index_file(TOKENS_FILE).await else {
1746                    return Err(metadata_error);
1747                };
1748                Ok(reader
1749                    .schema()
1750                    .metadata
1751                    .get("tokenizer")
1752                    .map(|s| serde_json::from_str::<InvertedIndexParams>(s))
1753                    .transpose()?
1754                    .unwrap_or_default())
1755            }
1756        }
1757    }
1758
1759    pub async fn load(
1760        store: Arc<dyn IndexStore>,
1761        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
1762        index_cache: &LanceCache,
1763    ) -> Result<Arc<Self>>
1764    where
1765        Self: Sized,
1766    {
1767        // for new index format, there is a metadata file and multiple partitions,
1768        // each partition is a separate index containing tokens, inverted list and docs.
1769        // for old index format, there is no metadata file, and it's just like a single partition
1770
1771        match store.open_index_file(METADATA_FILE).await {
1772            Ok(reader) => {
1773                let params = reader
1774                    .schema()
1775                    .metadata
1776                    .get("params")
1777                    .ok_or(Error::index("params not found in metadata".to_owned()))?;
1778                let params = serde_json::from_str::<InvertedIndexParams>(params)?;
1779                let partitions = reader
1780                    .schema()
1781                    .metadata
1782                    .get("partitions")
1783                    .ok_or(Error::index("partitions not found in metadata".to_owned()))?;
1784                let partitions: Vec<u64> = serde_json::from_str(partitions)?;
1785                let token_set_format = reader
1786                    .schema()
1787                    .metadata
1788                    .get(TOKEN_SET_FORMAT_KEY)
1789                    .map(|name| TokenSetFormat::from_str(name))
1790                    .transpose()?
1791                    .unwrap_or(TokenSetFormat::Arrow);
1792                let format_version = parse_format_version_from_metadata(&reader.schema().metadata)?;
1793
1794                // Load deleted_fragments if present (optional for backward compatibility)
1795                let deleted_fragments = if reader.num_rows() > 0 {
1796                    let metadata_batch = reader.read_range(0..1, None).await?;
1797                    if let Some(col) = metadata_batch.column_by_name(DELETED_FRAGMENTS_COL) {
1798                        let arr = col.as_binary_opt::<i32>().expect_ok()?;
1799                        RoaringBitmap::deserialize_from(arr.value(0))?
1800                    } else {
1801                        RoaringBitmap::new()
1802                    }
1803                } else {
1804                    RoaringBitmap::new()
1805                };
1806
1807                let format = token_set_format;
1808                let partitions = partitions.into_iter().enumerate().map(|(priority, id)| {
1809                    let store = store.with_io_priority(priority as u64);
1810                    let frag_reuse_index_clone = frag_reuse_index.clone();
1811                    let index_cache_for_part =
1812                        index_cache.with_key_prefix(format!("part-{}", id).as_str());
1813                    let token_set_format = format;
1814                    async move {
1815                        Result::Ok(Arc::new(
1816                            InvertedPartition::load(
1817                                store,
1818                                id,
1819                                frag_reuse_index_clone,
1820                                &index_cache_for_part,
1821                                token_set_format,
1822                            )
1823                            .await?,
1824                        ))
1825                    }
1826                });
1827                let partitions = stream::iter(partitions)
1828                    .buffer_unordered(store.io_parallelism())
1829                    .try_collect::<Vec<_>>()
1830                    .await?;
1831
1832                let tokenizer = params.build()?;
1833                Ok(Arc::new(Self {
1834                    params,
1835                    store,
1836                    tokenizer,
1837                    token_set_format,
1838                    format_version,
1839                    partitions,
1840                    corpus_stats: Arc::new(OnceCell::new()),
1841                    prewarm_state: Arc::new(Mutex::new(InvertedPrewarmState::default())),
1842                    document_projections_resident: Arc::new(AtomicBool::new(false)),
1843                    deleted_fragments,
1844                }))
1845            }
1846            Err(_) => {
1847                // old index format
1848                Self::load_legacy_index(store, frag_reuse_index, index_cache).await
1849            }
1850        }
1851    }
1852}
1853
1854#[async_trait]
1855impl Index for InvertedIndex {
1856    fn as_any(&self) -> &dyn std::any::Any {
1857        self
1858    }
1859
1860    fn as_index(self: Arc<Self>) -> Arc<dyn Index> {
1861        self
1862    }
1863
1864    fn statistics(&self) -> Result<serde_json::Value> {
1865        let num_tokens = self
1866            .partitions
1867            .iter()
1868            .map(|part| part.tokens.len())
1869            .sum::<usize>();
1870        let num_docs = self
1871            .partitions
1872            .iter()
1873            .map(|part| part.docs.len())
1874            .sum::<usize>();
1875        Ok(serde_json::json!({
1876            "params": self.params,
1877            "num_tokens": num_tokens,
1878            "num_docs": num_docs,
1879        }))
1880    }
1881
1882    async fn prewarm(&self) -> Result<()> {
1883        self.prewarm_with_options(&FtsPrewarmOptions::default())
1884            .await
1885    }
1886
1887    fn index_type(&self) -> crate::IndexType {
1888        crate::IndexType::Inverted
1889    }
1890
1891    async fn calculate_included_frags(&self) -> Result<RoaringBitmap> {
1892        unimplemented!()
1893    }
1894}
1895
1896/// Target on-disk size of one prewarm chunk. Keep this large enough that cloud
1897/// stores do not spend prewarm time on thousands of tiny range reads, but still
1898/// bounded so one large partition is not materialized all at once.
1899const PREWARM_CHUNK_TARGET_BYTES: u64 = 128 << 20;
1900
1901/// Cap on token rows per chunk, bounding the built `Vec` when posting lists are tiny.
1902const PREWARM_MAX_CHUNK_TOKENS: usize = 256 * 1024;
1903
1904/// Floor on token rows per chunk, so a partition always makes progress.
1905const PREWARM_MIN_CHUNK_TOKENS: usize = 1;
1906
1907/// Maximum number of posting lists in a runtime synthetic cache group. This is
1908/// deliberately token-count based so grouping works for old v2 indexes without
1909/// scanning posting lengths or requiring index rebuilds.
1910static LANCE_FTS_POSTING_GROUP_MAX_TOKENS: LazyLock<usize> = LazyLock::new(|| {
1911    std::env::var("LANCE_FTS_POSTING_GROUP_MAX_TOKENS")
1912        .unwrap_or_else(|_| "128".to_string())
1913        .parse()
1914        .expect("failed to parse LANCE_FTS_POSTING_GROUP_MAX_TOKENS")
1915});
1916
1917fn runtime_posting_group_tokens() -> usize {
1918    (*LANCE_FTS_POSTING_GROUP_MAX_TOKENS).max(1)
1919}
1920
1921/// Runtime posting-list cache grouping. Non-empty v2 indexes synthesize fixed
1922/// groups at read time so prewarm and queries share group cache entries without
1923/// persisted grouping metadata or index rebuilds.
1924#[derive(Debug, Clone, DeepSizeOf)]
1925enum PostingGrouping {
1926    /// Leaves legacy or empty partitions ungrouped.
1927    None,
1928    /// Uses a fixed runtime cache group size measured in token rows, not posting bytes.
1929    SyntheticFixed { group_size: u32 },
1930}
1931
1932impl PostingGrouping {
1933    fn for_reader(is_legacy_layout: bool, token_count: usize) -> Self {
1934        if is_legacy_layout || token_count == 0 {
1935            return Self::None;
1936        }
1937
1938        let group_size = u32::try_from(runtime_posting_group_tokens())
1939            .unwrap_or(u32::MAX)
1940            .max(1);
1941        Self::SyntheticFixed { group_size }
1942    }
1943
1944    fn is_grouped(&self) -> bool {
1945        !matches!(self, Self::None)
1946    }
1947
1948    fn range_for_token(&self, token_id: u32, token_count: usize) -> Option<(u32, u32)> {
1949        match self {
1950            Self::None => None,
1951            Self::SyntheticFixed { group_size } => {
1952                let token_count = u32::try_from(token_count).unwrap_or(u32::MAX);
1953                let start = (token_id / *group_size) * *group_size;
1954                let end = start.saturating_add(*group_size).min(token_count);
1955                Some((start, end))
1956            }
1957        }
1958    }
1959
1960    fn aligned_chunk_end(&self, token_count: usize, tok_start: usize, desired_end: usize) -> usize {
1961        match self {
1962            Self::None => desired_end,
1963            Self::SyntheticFixed { group_size } => synthetic_group_aligned_chunk_end(
1964                usize::try_from(*group_size).unwrap_or(usize::MAX).max(1),
1965                token_count,
1966                tok_start,
1967                desired_end,
1968            ),
1969        }
1970    }
1971
1972    fn ranges_for_chunk(
1973        &self,
1974        tok_start: usize,
1975        tok_end: usize,
1976        token_count: usize,
1977    ) -> Vec<(u32, u32)> {
1978        match self {
1979            Self::None => Vec::new(),
1980            Self::SyntheticFixed { group_size } => synthetic_group_ranges_for_chunk(
1981                usize::try_from(*group_size).unwrap_or(usize::MAX).max(1),
1982                tok_start,
1983                tok_end,
1984                token_count,
1985            ),
1986        }
1987    }
1988}
1989
1990/// Token rows per chunk: byte target / average bytes-per-token, clamped to `[MIN, MAX]`.
1991fn prewarm_chunk_tokens(token_count: usize, file_size_bytes: u64) -> usize {
1992    if token_count == 0 {
1993        return PREWARM_MIN_CHUNK_TOKENS;
1994    }
1995    let bytes_per_token = (file_size_bytes / token_count as u64).max(1); // >= 1: no div-by-zero
1996    let by_bytes = (PREWARM_CHUNK_TARGET_BYTES / bytes_per_token) as usize;
1997    by_bytes.clamp(PREWARM_MIN_CHUNK_TOKENS, PREWARM_MAX_CHUNK_TOKENS)
1998}
1999
2000fn synthetic_group_aligned_chunk_end(
2001    group_size: usize,
2002    token_count: usize,
2003    tok_start: usize,
2004    desired_end: usize,
2005) -> usize {
2006    if desired_end >= token_count {
2007        return token_count;
2008    }
2009
2010    let boundary = desired_end - (desired_end % group_size);
2011    if boundary > tok_start {
2012        boundary
2013    } else {
2014        tok_start.saturating_add(group_size).min(token_count)
2015    }
2016}
2017
2018fn synthetic_group_ranges_for_chunk(
2019    group_size: usize,
2020    tok_start: usize,
2021    tok_end: usize,
2022    token_count: usize,
2023) -> Vec<(u32, u32)> {
2024    let mut ranges = Vec::new();
2025    let mut start = tok_start - (tok_start % group_size);
2026    if start < tok_start {
2027        start = start.saturating_add(group_size).min(token_count);
2028    }
2029    while start < tok_end {
2030        let end = start.saturating_add(group_size).min(token_count);
2031        ranges.push((
2032            u32::try_from(start).unwrap_or(u32::MAX),
2033            u32::try_from(end).unwrap_or(u32::MAX),
2034        ));
2035        start = end;
2036    }
2037    ranges
2038}
2039
2040fn prewarm_chunk_ranges(
2041    grouping: &PostingGrouping,
2042    token_count: usize,
2043    chunk_tokens: usize,
2044) -> Vec<(usize, usize)> {
2045    let mut ranges = Vec::new();
2046    let mut tok_start = 0usize;
2047    while tok_start < token_count {
2048        let mut tok_end = (tok_start + chunk_tokens).min(token_count);
2049        // `tok_start` is always a group boundary; snap `tok_end` back to one too.
2050        if grouping.is_grouped() {
2051            tok_end = grouping.aligned_chunk_end(token_count, tok_start, tok_end);
2052        }
2053        ranges.push((tok_start, tok_end));
2054        tok_start = tok_end;
2055    }
2056    ranges
2057}
2058
2059impl InvertedIndex {
2060    pub async fn prewarm_with_options(&self, options: &FtsPrewarmOptions) -> Result<()> {
2061        let mut state = self.prewarm_state.lock().await;
2062        if state.satisfies(options.with_position)
2063            && (self.is_legacy() || self.document_projections_resident_now())
2064        {
2065            return Ok(());
2066        }
2067        let with_position = options.with_position || state.positions_ready;
2068        state.query_ready = false;
2069        state.positions_ready = false;
2070        self.document_projections_resident
2071            .store(false, Ordering::Release);
2072        self.prewarm_query_state(with_position).await?;
2073        state.query_ready = true;
2074        state.positions_ready = with_position;
2075        Ok(())
2076    }
2077
2078    async fn prewarm_query_state(&self, with_position: bool) -> Result<()> {
2079        let chunk_concurrency = self.store.io_parallelism().max(1);
2080        let prewarm_started = Instant::now();
2081        info!(
2082            partition_count = self.partitions.len(),
2083            with_position, chunk_concurrency, "fts index prewarm started"
2084        );
2085        for part in &self.partitions {
2086            let partition_started = Instant::now();
2087            info!(
2088                partition_id = part.id(),
2089                token_count = part.tokens.len(),
2090                with_position,
2091                chunk_concurrency,
2092                "fts partition prewarm started"
2093            );
2094            if let Err(err) = part
2095                .inverted_list
2096                .prewarm_posting_lists(with_position, chunk_concurrency)
2097                .await
2098            {
2099                warn!(
2100                    partition_id = part.id(),
2101                    error = %err,
2102                    elapsed_ms = partition_started.elapsed().as_millis() as u64,
2103                    "fts partition posting list prewarm failed"
2104                );
2105                return Err(err);
2106            }
2107            info!(
2108                partition_id = part.id(),
2109                elapsed_ms = partition_started.elapsed().as_millis() as u64,
2110                "fts partition posting lists prewarmed"
2111            );
2112            let docs_started = Instant::now();
2113            if let Err(err) = part.docs.prewarm().await {
2114                warn!(
2115                    partition_id = part.id(),
2116                    error = %err,
2117                    elapsed_ms = docs_started.elapsed().as_millis() as u64,
2118                    total_elapsed_ms = partition_started.elapsed().as_millis() as u64,
2119                    "fts partition docset prewarm failed"
2120                );
2121                return Err(err);
2122            }
2123            info!(
2124                partition_id = part.id(),
2125                docset_elapsed_ms = docs_started.elapsed().as_millis() as u64,
2126                elapsed_ms = partition_started.elapsed().as_millis() as u64,
2127                "fts partition prewarm finished"
2128            );
2129        }
2130        self.aggregate_corpus_stats().await?;
2131        let query_ready = self.partitions.iter().all(|partition| {
2132            partition.docs.query_ready()
2133                && partition.inverted_list.modern_posting_validation_ready()
2134        });
2135        if !query_ready {
2136            return Err(Error::internal(
2137                "FTS prewarm completed without publishing a query-ready document and posting state"
2138                    .to_owned(),
2139            ));
2140        }
2141        self.document_projections_resident
2142            .store(true, Ordering::Release);
2143        info!(
2144            partition_count = self.partitions.len(),
2145            query_ready,
2146            elapsed_ms = prewarm_started.elapsed().as_millis() as u64,
2147            "fts index prewarm finished"
2148        );
2149        Ok(())
2150    }
2151    /// Search docs match the input text.
2152    async fn do_search(&self, text: &str) -> Result<RecordBatch> {
2153        let params = FtsSearchParams::new();
2154        let mut tokenizer = self.tokenizer.clone();
2155        let tokens = collect_query_tokens(text, &mut tokenizer);
2156
2157        let (doc_ids, _) = self
2158            .bm25_search(
2159                Arc::new(tokens),
2160                params.into(),
2161                Operator::And,
2162                Arc::new(NoFilter),
2163                Arc::new(NoOpMetricsCollector),
2164                None,
2165            )
2166            .boxed()
2167            .await?;
2168
2169        Ok(RecordBatch::try_new(
2170            ROW_ID_SCHEMA.clone(),
2171            vec![Arc::new(UInt64Array::from(doc_ids))],
2172        )?)
2173    }
2174}
2175
2176#[async_trait]
2177impl ScalarIndex for InvertedIndex {
2178    // return the row ids of the documents that contain the query
2179    #[instrument(level = "debug", skip_all)]
2180    async fn search(
2181        &self,
2182        query: &dyn AnyQuery,
2183        _metrics: &dyn MetricsCollector,
2184    ) -> Result<SearchResult> {
2185        let query = query.as_any().downcast_ref::<TokenQuery>().unwrap();
2186
2187        match query {
2188            TokenQuery::TokensContains(text) => {
2189                let records = self.do_search(text).await?;
2190                let row_ids = records
2191                    .column(0)
2192                    .as_any()
2193                    .downcast_ref::<UInt64Array>()
2194                    .unwrap();
2195                let row_ids = row_ids.iter().flatten().collect_vec();
2196                Ok(SearchResult::at_most(RowAddrTreeMap::from_iter(row_ids)))
2197            }
2198        }
2199    }
2200
2201    fn can_remap(&self) -> bool {
2202        true
2203    }
2204
2205    async fn remap(
2206        &self,
2207        mapping: &RowAddrRemap,
2208        dest_store: &dyn IndexStore,
2209    ) -> Result<CreatedIndex> {
2210        let files = self
2211            .to_builder()
2212            .remap(mapping, self.store.clone(), dest_store)
2213            .await?;
2214
2215        let details = pbold::InvertedIndexDetails::try_from(&self.params)?;
2216
2217        Ok(CreatedIndex {
2218            index_details: prost_types::Any::from_msg(&details).unwrap(),
2219            index_version: self.index_version(),
2220            files,
2221        })
2222    }
2223
2224    async fn update(
2225        &self,
2226        new_data: SendableRecordBatchStream,
2227        dest_store: &dyn IndexStore,
2228        old_data_filter: Option<crate::scalar::OldIndexDataFilter>,
2229    ) -> Result<CreatedIndex> {
2230        let files = self
2231            .to_builder()
2232            .update(new_data, dest_store, old_data_filter)
2233            .await?;
2234
2235        let details = pbold::InvertedIndexDetails::try_from(&self.params)?;
2236
2237        Ok(CreatedIndex {
2238            index_details: prost_types::Any::from_msg(&details).unwrap(),
2239            index_version: self.index_version(),
2240            files,
2241        })
2242    }
2243
2244    fn update_criteria(&self) -> UpdateCriteria {
2245        let criteria = TrainingCriteria::new(TrainingOrdering::None).with_row_id();
2246        if self.is_legacy() {
2247            UpdateCriteria::requires_old_data(criteria)
2248        } else {
2249            UpdateCriteria::only_new_data(criteria)
2250        }
2251    }
2252
2253    fn derive_index_params(&self) -> Result<ScalarIndexParams> {
2254        let mut params = self.params.clone();
2255        if params.base_tokenizer.is_empty() {
2256            // Empty tokenizer metadata only appears in legacy simple-tokenizer indexes.
2257            params.base_tokenizer = "simple".to_string();
2258        }
2259        params = params.format_version(self.format_version());
2260
2261        let params_json = params.to_training_json()?.to_string();
2262
2263        Ok(ScalarIndexParams {
2264            index_type: BuiltinIndexType::Inverted.as_str().to_string(),
2265            params: Some(params_json),
2266        })
2267    }
2268}
2269
2270#[derive(Debug, Clone, DeepSizeOf)]
2271pub struct InvertedPartition {
2272    // 0 for legacy format
2273    id: u64,
2274    store: Arc<dyn IndexStore>,
2275    pub(crate) tokens: TokenSet,
2276    pub(crate) inverted_list: Arc<PostingListReader>,
2277    /// Legacy documents stay in their original complete `DocSet`; modern
2278    /// documents use typed, independently-loaded lengths and addresses.
2279    pub(super) docs: PartitionDocumentStore,
2280    token_set_format: TokenSetFormat,
2281}
2282
2283impl InvertedPartition {
2284    /// Check if this partition belongs to the specified fragment.
2285    ///
2286    /// This method encapsulates the bit manipulation logic for fragment filtering
2287    /// in distributed indexing scenarios.
2288    ///
2289    /// # Arguments
2290    /// * `fragment_mask` - A mask with fragment_id in high 32 bits
2291    ///
2292    /// # Returns
2293    /// * `true` if the partition belongs to the fragment, `false` otherwise
2294    pub fn belongs_to_fragment(&self, fragment_mask: u64) -> bool {
2295        (self.id() & fragment_mask) == fragment_mask
2296    }
2297
2298    pub fn id(&self) -> u64 {
2299        self.id
2300    }
2301
2302    pub fn store(&self) -> &dyn IndexStore {
2303        self.store.as_ref()
2304    }
2305
2306    pub fn is_legacy(&self) -> bool {
2307        self.inverted_list.is_legacy_layout()
2308    }
2309
2310    pub async fn load(
2311        store: Arc<dyn IndexStore>,
2312        id: u64,
2313        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
2314        index_cache: &LanceCache,
2315        token_set_format: TokenSetFormat,
2316    ) -> Result<Self> {
2317        let token_file = store.open_index_file(&token_file_path(id)).await?;
2318        let tokens = TokenSet::load(token_file, token_set_format).await?;
2319        let invert_list_file = store.open_index_file(&posting_file_path(id)).await?;
2320        let mut inverted_list = PostingListReader::try_new(invert_list_file, index_cache).await?;
2321        let docs_path = doc_file_path(id);
2322        let docs_reader = store.open_index_file(&docs_path).await?;
2323        let docs = PartitionDocuments::try_new(
2324            store.clone(),
2325            docs_path,
2326            id,
2327            WeakLanceCache::from(index_cache),
2328            docs_reader.as_ref(),
2329            frag_reuse_index,
2330            // 256-document blocks score with quantized document lengths.
2331            inverted_list.block_size() == MAX_POSTING_BLOCK_SIZE,
2332        )?;
2333        inverted_list.modern_num_docs = Some(docs.len());
2334
2335        Ok(Self {
2336            id,
2337            store,
2338            tokens,
2339            inverted_list: Arc::new(inverted_list),
2340            docs: PartitionDocumentStore::Modern(Arc::new(docs)),
2341            token_set_format,
2342        })
2343    }
2344
2345    fn map(&self, token: &str) -> Option<u32> {
2346        self.tokens.get(token)
2347    }
2348
2349    pub fn expand_fuzzy(&self, tokens: &Tokens, params: &FtsSearchParams) -> Result<Tokens> {
2350        let mut new_tokens = Vec::with_capacity(min(tokens.len(), params.max_expansions));
2351        let mut new_positions = Vec::with_capacity(new_tokens.capacity());
2352        let mut seen = HashSet::new();
2353        for token_idx in 0..tokens.len() {
2354            let remaining = params.max_expansions.saturating_sub(new_tokens.len());
2355            if remaining == 0 {
2356                break;
2357            }
2358            let token = tokens.get_token(token_idx);
2359            let position = tokens.position(token_idx);
2360            let base_prefix_len = tokens.token_type().prefix_len(token) as u32;
2361            let mut candidates = BTreeSet::new();
2362            self.collect_fuzzy_candidates(
2363                token,
2364                base_prefix_len,
2365                params,
2366                remaining,
2367                &mut candidates,
2368            )?;
2369            for candidate in candidates {
2370                if new_tokens.len() >= params.max_expansions {
2371                    break;
2372                }
2373                if seen.insert((candidate.clone(), position)) {
2374                    new_tokens.push(candidate);
2375                    new_positions.push(position);
2376                }
2377            }
2378        }
2379        Ok(Tokens::with_positions(
2380            new_tokens,
2381            new_positions,
2382            tokens.token_type().clone(),
2383        ))
2384    }
2385
2386    /// Collect up to `limit` fuzzy candidates for one query token from this
2387    /// partition's token FST, in key (lexicographic) order. Callers merge
2388    /// candidates across partitions and apply the query-wide
2389    /// `max_expansions` budget; truncating each partition at `limit` is
2390    /// lossless for that selection because any term among the merged
2391    /// lexicographically-smallest `limit` is also among its own partition's
2392    /// smallest `limit`.
2393    fn collect_fuzzy_candidates(
2394        &self,
2395        token: &str,
2396        base_prefix_len: u32,
2397        params: &FtsSearchParams,
2398        limit: usize,
2399        candidates: &mut BTreeSet<String>,
2400    ) -> Result<()> {
2401        let fuzziness = match params.fuzziness {
2402            Some(fuzziness) => fuzziness,
2403            None => MatchQuery::auto_fuzziness(token),
2404        };
2405        let lev = fst::automaton::Levenshtein::new(token, fuzziness)
2406            .map_err(|e| Error::index(format!("failed to construct the fuzzy query: {}", e)))?;
2407
2408        if let TokenMap::Fst(ref map) = self.tokens.tokens {
2409            let mut expanded = Vec::new();
2410            match base_prefix_len + params.prefix_length {
2411                0 => take_fst_keys(map.search(lev), &mut expanded, limit),
2412                prefix_length => {
2413                    let prefix = &token[..min(prefix_length as usize, token.len())];
2414                    let prefix = fst::automaton::Str::new(prefix).starts_with();
2415                    take_fst_keys(map.search(lev.intersection(prefix)), &mut expanded, limit)
2416                }
2417            }
2418            candidates.extend(expanded);
2419            Ok(())
2420        } else {
2421            Err(Error::index(
2422                "tokens is not fst, which is not expected".to_owned(),
2423            ))
2424        }
2425    }
2426
2427    #[inline]
2428    fn grouped_score_upper_bound(
2429        query_weight: f32,
2430        union_freq: u32,
2431        doc_length: u32,
2432        scorer: &MemBM25Scorer,
2433    ) -> f32 {
2434        // BM25's document weight is monotonic in frequency and every IDF is
2435        // non-negative. Scoring the summed frequency with the summed IDF is
2436        // therefore an upper bound on the sum of the individual term scores.
2437        query_weight * scorer.doc_weight(union_freq, doc_length)
2438    }
2439
2440    fn grouped_block_max_scores(
2441        doc_ids: &[u32],
2442        frequencies: &[u32],
2443        block_size: usize,
2444        docs: &LoadedDocLengths,
2445        query_weight: f32,
2446        scorer: &MemBM25Scorer,
2447    ) -> Vec<f32> {
2448        doc_ids
2449            .chunks(block_size)
2450            .zip(frequencies.chunks(block_size))
2451            .map(|(doc_ids, frequencies)| {
2452                doc_ids
2453                    .iter()
2454                    .zip(frequencies)
2455                    .map(|(doc_id, freq)| {
2456                        Self::grouped_score_upper_bound(
2457                            query_weight,
2458                            *freq,
2459                            docs.scoring_num_tokens(*doc_id),
2460                            scorer,
2461                        )
2462                    })
2463                    .fold(0.0, f32::max)
2464            })
2465            .collect()
2466    }
2467
2468    fn union_plain_posting_lists(
2469        postings: Vec<PostingList>,
2470        docs: &LoadedDocLengths,
2471        query_weight: f32,
2472        scorer: &MemBM25Scorer,
2473    ) -> Result<PostingList> {
2474        let mut freqs_by_row_id = BTreeMap::new();
2475        for posting in postings {
2476            for (row_id, freq, _) in posting.iter() {
2477                let entry = freqs_by_row_id.entry(row_id).or_insert(0u32);
2478                *entry = entry.checked_add(freq).ok_or_else(|| {
2479                    Error::index(format!("posting frequency overflow for row id {}", row_id))
2480                })?;
2481            }
2482        }
2483        let mut row_ids = Vec::with_capacity(freqs_by_row_id.len());
2484        let mut frequencies = Vec::with_capacity(freqs_by_row_id.len());
2485        let mut max_score = 0.0_f32;
2486        for (row_id, freq) in freqs_by_row_id {
2487            max_score = max_score.max(Self::grouped_score_upper_bound(
2488                query_weight,
2489                freq,
2490                docs.num_tokens_by_row_id(row_id),
2491                scorer,
2492            ));
2493            row_ids.push(row_id);
2494            frequencies.push(freq as f32);
2495        }
2496        Ok(PostingList::Plain(PlainPostingList::new(
2497            ScalarBuffer::from(row_ids),
2498            ScalarBuffer::from(frequencies),
2499            Some(max_score),
2500            None,
2501        )))
2502    }
2503
2504    fn union_plain_posting_lists_with_positions(
2505        postings: Vec<PostingList>,
2506        docs: &LoadedDocLengths,
2507        query_weight: f32,
2508        scorer: &MemBM25Scorer,
2509    ) -> Result<PostingList> {
2510        let mut positions_by_row_id = BTreeMap::<u64, Vec<u32>>::new();
2511        for posting in postings {
2512            for (row_id, _, positions) in posting.iter() {
2513                let positions = positions.ok_or_else(|| {
2514                    Error::index("cannot union grouped phrase terms without positions".to_string())
2515                })?;
2516                positions_by_row_id
2517                    .entry(row_id)
2518                    .or_default()
2519                    .extend(positions);
2520            }
2521        }
2522        if positions_by_row_id.is_empty() {
2523            return Ok(PostingList::Plain(PlainPostingList::new(
2524                ScalarBuffer::from(Vec::<u64>::new()),
2525                ScalarBuffer::from(Vec::<f32>::new()),
2526                None,
2527                None,
2528            )));
2529        }
2530
2531        let mut row_ids = Vec::with_capacity(positions_by_row_id.len());
2532        let mut frequencies = Vec::with_capacity(positions_by_row_id.len());
2533        let mut positions_builder = ListBuilder::new(Int32Builder::new());
2534        let mut max_score = 0.0_f32;
2535        for (row_id, mut positions) in positions_by_row_id {
2536            positions.sort_unstable();
2537            let frequency = positions.len() as u32;
2538            max_score = max_score.max(Self::grouped_score_upper_bound(
2539                query_weight,
2540                frequency,
2541                docs.num_tokens_by_row_id(row_id),
2542                scorer,
2543            ));
2544            row_ids.push(row_id);
2545            frequencies.push(frequency as f32);
2546            for position in positions {
2547                positions_builder.values().append_value(position as i32);
2548            }
2549            positions_builder.append(true);
2550        }
2551
2552        Ok(PostingList::Plain(PlainPostingList::new(
2553            ScalarBuffer::from(row_ids),
2554            ScalarBuffer::from(frequencies),
2555            Some(max_score),
2556            Some(positions_builder.finish()),
2557        )))
2558    }
2559
2560    fn union_compressed_posting_lists(
2561        postings: Vec<PostingList>,
2562        docs: &LoadedDocLengths,
2563        query_weight: f32,
2564        scorer: &MemBM25Scorer,
2565    ) -> Result<PostingList> {
2566        let block_size = postings
2567            .iter()
2568            .find_map(|posting| match posting {
2569                PostingList::Compressed(posting) => Some(posting.block_size),
2570                PostingList::Plain(_) => None,
2571            })
2572            .unwrap_or(LEGACY_BLOCK_SIZE);
2573        let mut freqs_by_doc_id = BTreeMap::new();
2574        for posting in postings {
2575            for (doc_id, freq, _) in posting.iter() {
2576                let doc_id = u32::try_from(doc_id).map_err(|_| {
2577                    Error::index(format!(
2578                        "compressed posting doc id {} exceeds u32::MAX",
2579                        doc_id
2580                    ))
2581                })?;
2582                let entry = freqs_by_doc_id.entry(doc_id).or_insert(0u32);
2583                *entry = entry.checked_add(freq).ok_or_else(|| {
2584                    Error::index(format!("posting frequency overflow for doc id {}", doc_id))
2585                })?;
2586            }
2587        }
2588        if freqs_by_doc_id.is_empty() {
2589            return Ok(PostingList::Plain(PlainPostingList::new(
2590                ScalarBuffer::from(Vec::<u64>::new()),
2591                ScalarBuffer::from(Vec::<f32>::new()),
2592                None,
2593                None,
2594            )));
2595        }
2596
2597        let mut builder = PostingListBuilder::new_with_block_size(false, block_size);
2598        let mut doc_ids = Vec::with_capacity(freqs_by_doc_id.len());
2599        let mut frequencies = Vec::with_capacity(freqs_by_doc_id.len());
2600        for (doc_id, freq) in freqs_by_doc_id {
2601            builder.add(doc_id, PositionRecorder::Count(freq));
2602            doc_ids.push(doc_id);
2603            frequencies.push(freq);
2604        }
2605        let block_max_scores = Self::grouped_block_max_scores(
2606            &doc_ids,
2607            &frequencies,
2608            block_size,
2609            docs,
2610            query_weight,
2611            scorer,
2612        );
2613        let batch = builder.to_batch(block_max_scores)?;
2614        let max_score = batch[MAX_SCORE_COL].as_primitive::<Float32Type>().value(0);
2615        let length = batch[LENGTH_COL].as_primitive::<UInt32Type>().value(0);
2616        PostingList::from_batch(&batch, Some(max_score), Some(length))
2617    }
2618
2619    fn union_compressed_posting_lists_with_positions(
2620        postings: Vec<PostingList>,
2621        docs: &LoadedDocLengths,
2622        query_weight: f32,
2623        scorer: &MemBM25Scorer,
2624    ) -> Result<PostingList> {
2625        let block_size = postings
2626            .iter()
2627            .find_map(|posting| match posting {
2628                PostingList::Compressed(posting) => Some(posting.block_size),
2629                PostingList::Plain(_) => None,
2630            })
2631            .unwrap_or(LEGACY_BLOCK_SIZE);
2632        let mut positions_by_doc_id = BTreeMap::<u32, Vec<u32>>::new();
2633        for posting in postings {
2634            for (doc_id, _, positions) in posting.iter() {
2635                let doc_id = u32::try_from(doc_id).map_err(|_| {
2636                    Error::index(format!(
2637                        "compressed posting doc id {} exceeds u32::MAX",
2638                        doc_id
2639                    ))
2640                })?;
2641                let positions = positions.ok_or_else(|| {
2642                    Error::index("cannot union grouped phrase terms without positions".to_string())
2643                })?;
2644                positions_by_doc_id
2645                    .entry(doc_id)
2646                    .or_default()
2647                    .extend(positions);
2648            }
2649        }
2650        if positions_by_doc_id.is_empty() {
2651            return Ok(PostingList::Plain(PlainPostingList::new(
2652                ScalarBuffer::from(Vec::<u64>::new()),
2653                ScalarBuffer::from(Vec::<f32>::new()),
2654                None,
2655                None,
2656            )));
2657        }
2658
2659        let mut builder = PostingListBuilder::new_with_block_size(true, block_size);
2660        let mut doc_ids = Vec::with_capacity(positions_by_doc_id.len());
2661        let mut frequencies = Vec::with_capacity(positions_by_doc_id.len());
2662        for (doc_id, mut positions) in positions_by_doc_id {
2663            positions.sort_unstable();
2664            let frequency = positions.len() as u32;
2665            builder.add(doc_id, PositionRecorder::Position(positions.into()));
2666            doc_ids.push(doc_id);
2667            frequencies.push(frequency);
2668        }
2669        let block_max_scores = Self::grouped_block_max_scores(
2670            &doc_ids,
2671            &frequencies,
2672            block_size,
2673            docs,
2674            query_weight,
2675            scorer,
2676        );
2677        let batch = builder.to_batch(block_max_scores)?;
2678        let max_score = batch[MAX_SCORE_COL].as_primitive::<Float32Type>().value(0);
2679        let length = batch[LENGTH_COL].as_primitive::<UInt32Type>().value(0);
2680        PostingList::from_batch(&batch, Some(max_score), Some(length))
2681    }
2682
2683    fn union_posting_lists(
2684        postings: Vec<PostingList>,
2685        docs: &LoadedDocLengths,
2686        with_positions: bool,
2687        query_weight: f32,
2688        scorer: &MemBM25Scorer,
2689    ) -> Result<PostingList> {
2690        let has_plain = postings
2691            .iter()
2692            .any(|posting| matches!(posting, PostingList::Plain(_)));
2693        let has_compressed = postings
2694            .iter()
2695            .any(|posting| matches!(posting, PostingList::Compressed(_)));
2696        match (has_plain, has_compressed) {
2697            (true, true) => Err(Error::index(
2698                "cannot union mixed plain and compressed posting lists".to_owned(),
2699            )),
2700            (true, false) if with_positions => {
2701                Self::union_plain_posting_lists_with_positions(postings, docs, query_weight, scorer)
2702            }
2703            (true, false) => Self::union_plain_posting_lists(postings, docs, query_weight, scorer),
2704            (false, true) if with_positions => Self::union_compressed_posting_lists_with_positions(
2705                postings,
2706                docs,
2707                query_weight,
2708                scorer,
2709            ),
2710            (false, true) => {
2711                Self::union_compressed_posting_lists(postings, docs, query_weight, scorer)
2712            }
2713            (false, false) => Ok(PostingList::Plain(PlainPostingList::new(
2714                ScalarBuffer::from(Vec::<u64>::new()),
2715                ScalarBuffer::from(Vec::<f32>::new()),
2716                None,
2717                None,
2718            ))),
2719        }
2720    }
2721
2722    // search the documents that contain the query
2723    // return the doc info and the doc length
2724    // ref: https://en.wikipedia.org/wiki/Okapi_BM25
2725    #[instrument(level = "debug", skip_all)]
2726    async fn load_posting_lists(
2727        &self,
2728        tokens: &Tokens,
2729        params: &FtsSearchParams,
2730        operator: Operator,
2731        impact_scorer: &MemBM25Scorer,
2732        metrics: &dyn MetricsCollector,
2733    ) -> Result<LoadedPostings> {
2734        let is_phrase_query = params.phrase_slop.is_some();
2735        let is_and_query = operator == Operator::And;
2736        let required_positions = (is_and_query || is_phrase_query).then(|| {
2737            (0..tokens.len())
2738                .map(|index| tokens.position(index))
2739                .collect::<HashSet<_>>()
2740        });
2741        // Fuzzy expansion already ran once at the index level (see
2742        // `InvertedIndex::bm25_search`) under the global `max_expansions`
2743        // budget. Positions identify alternatives that must share one posting
2744        // iterator, including code identifier subwords and fuzzy expansions.
2745        let tokens = tokens.clone();
2746        let token_positions = (0..tokens.len())
2747            .map(|index| tokens.position(index))
2748            .collect::<Vec<_>>();
2749        let mut seen_positions = HashSet::with_capacity(token_positions.len());
2750        let exact_scoring_required = token_positions
2751            .iter()
2752            .any(|position| !seen_positions.insert(*position));
2753        let mut token_ids = Vec::with_capacity(tokens.len());
2754        let mut matched_positions = required_positions.as_ref().map(|_| HashSet::new());
2755        for (index, token) in tokens.into_iter().enumerate() {
2756            let token_id = self.map(&token);
2757            if let Some(token_id) = token_id {
2758                let position = token_positions[index];
2759                if let Some(matched_positions) = matched_positions.as_mut() {
2760                    matched_positions.insert(position);
2761                }
2762                token_ids.push((token_id, token, position));
2763            }
2764        }
2765        if token_ids.is_empty() {
2766            return Ok(LoadedPostings::empty());
2767        }
2768        if let Some(required_positions) = required_positions.as_ref()
2769            && let Some(matched_positions) = matched_positions.as_ref()
2770            && !required_positions.is_subset(matched_positions)
2771        {
2772            return Ok(LoadedPostings::empty());
2773        }
2774
2775        token_ids.sort_unstable_by_key(|(token_id, _, position)| (*position, *token_id));
2776        token_ids.dedup_by(|lhs, rhs| lhs.0 == rhs.0 && lhs.2 == rhs.2);
2777
2778        let num_docs = self.docs.len();
2779        let loaded_postings = stream::iter(token_ids)
2780            .map(|(token_id, token, position)| async move {
2781                let posting = self
2782                    .inverted_list
2783                    .posting_list(token_id, is_phrase_query, metrics)
2784                    .await?;
2785
2786                Result::Ok((token_id, token, position, posting))
2787            })
2788            .buffered(self.store.io_parallelism())
2789            .try_collect::<Vec<_>>()
2790            .await?;
2791
2792        let needs_union = loaded_postings
2793            .windows(2)
2794            .any(|window| window[0].2 == window[1].2);
2795        if (is_and_query || is_phrase_query)
2796            && !needs_union
2797            && loaded_postings
2798                .iter()
2799                .any(|(_, _, _, posting)| posting.is_empty())
2800        {
2801            return Ok(LoadedPostings::empty());
2802        }
2803
2804        if !needs_union {
2805            let impact_safe = loaded_postings
2806                .iter()
2807                .all(|(_, _, _, posting)| posting.has_impacts());
2808            return Ok(LoadedPostings {
2809                postings: loaded_postings
2810                    .into_iter()
2811                    .map(|(token_id, token, position, posting)| {
2812                        let needs_scorer_upper_bound =
2813                            exact_scoring_required && !posting.has_impacts();
2814                        let query_weight = if impact_safe || exact_scoring_required {
2815                            impact_scorer.query_weight(&token)
2816                        } else {
2817                            idf(posting.len(), num_docs)
2818                        };
2819                        let posting = PostingIterator::with_query_weight(
2820                            token,
2821                            token_id,
2822                            position,
2823                            query_weight,
2824                            posting,
2825                            num_docs,
2826                        );
2827                        if needs_scorer_upper_bound {
2828                            posting.with_scorer_upper_bound()
2829                        } else {
2830                            posting
2831                        }
2832                    })
2833                    .collect(),
2834                grouped_expansions: Vec::new(),
2835                impact_safe,
2836                exact_scoring_required,
2837            });
2838        }
2839
2840        let docs_for_union = if needs_union {
2841            Some(match &self.docs {
2842                PartitionDocumentStore::Legacy(docs) => LoadedDocLengths::Legacy(docs.clone()),
2843                PartitionDocumentStore::Modern(documents) => {
2844                    LoadedDocLengths::Modern(documents.lengths().await?)
2845                }
2846            })
2847        } else {
2848            None
2849        };
2850
2851        // WAND's AND mode treats every iterator as required, so expansions from
2852        // one original query position must be merged before scoring.
2853        let mut grouped_postings = Vec::new();
2854        let mut grouped_expansions = Vec::new();
2855        let mut iter = loaded_postings.into_iter().peekable();
2856        while let Some((token_id, token, position, posting)) = iter.next() {
2857            let mut group = vec![(token_id, token, posting)];
2858            while matches!(iter.peek(), Some((_, _, next_position, _)) if *next_position == position)
2859            {
2860                let (token_id, token, _, posting) = iter.next().expect("peeked item must exist");
2861                group.push((token_id, token, posting));
2862            }
2863
2864            let (token_id, token, posting) = if group.len() == 1 {
2865                group.pop().expect("single-item group must exist")
2866            } else {
2867                let token_id = group[0].0;
2868                let token = group[0].1.clone();
2869                let terms = group
2870                    .iter()
2871                    .map(|(_, token, posting)| {
2872                        GroupedTermScorer::new(impact_scorer.query_weight(token), posting)
2873                    })
2874                    .collect::<Vec<_>>();
2875                let terms = Arc::<[GroupedTermScorer]>::from(terms);
2876                let query_weight = terms.iter().map(GroupedTermScorer::query_weight).sum();
2877                grouped_expansions.push(GroupedExpansionTerms {
2878                    position,
2879                    terms: terms.clone(),
2880                });
2881                let postings = group
2882                    .into_iter()
2883                    .map(|(_, _, posting)| posting)
2884                    .collect::<Vec<_>>();
2885                let docs = docs_for_union.as_ref().ok_or_else(|| {
2886                    Error::index("union docs were not loaded for grouped query terms".to_string())
2887                })?;
2888                let posting = Self::union_posting_lists(
2889                    postings,
2890                    docs,
2891                    is_phrase_query,
2892                    query_weight,
2893                    impact_scorer,
2894                )?;
2895                if posting.is_empty() && (is_and_query || is_phrase_query) {
2896                    return Ok(LoadedPostings::empty());
2897                }
2898                grouped_postings.push(
2899                    PostingIterator::with_query_weight(
2900                        token,
2901                        token_id,
2902                        position,
2903                        query_weight,
2904                        posting,
2905                        num_docs,
2906                    )
2907                    .with_grouped_terms(terms),
2908                );
2909                continue;
2910            };
2911            if posting.is_empty() {
2912                if is_and_query || is_phrase_query {
2913                    return Ok(LoadedPostings::empty());
2914                }
2915                continue;
2916            }
2917
2918            let query_weight = impact_scorer.query_weight(&token);
2919            let needs_scorer_upper_bound = !posting.has_impacts();
2920            let posting = PostingIterator::with_query_weight(
2921                token,
2922                token_id,
2923                position,
2924                query_weight,
2925                posting,
2926                num_docs,
2927            );
2928            grouped_postings.push(if needs_scorer_upper_bound {
2929                posting.with_scorer_upper_bound()
2930            } else {
2931                posting
2932            });
2933        }
2934
2935        Ok(LoadedPostings {
2936            postings: grouped_postings,
2937            grouped_expansions,
2938            impact_safe: false,
2939            exact_scoring_required: true,
2940        })
2941    }
2942
2943    #[allow(clippy::too_many_arguments)]
2944    fn bm25_search_legacy(
2945        &self,
2946        docs: &DocSet,
2947        params: &FtsSearchParams,
2948        operator: Operator,
2949        mask: &RowAddrMask,
2950        postings: Vec<PostingIterator>,
2951        impact_scorer: Option<Arc<MemBM25Scorer>>,
2952        metrics: &dyn MetricsCollector,
2953        shared_threshold: Arc<AtomicU32>,
2954    ) -> Result<Vec<DocCandidate<u64>>> {
2955        let documents = LegacyWandDocuments::new(docs, mask);
2956        self.bm25_search_with_documents(
2957            &documents,
2958            params,
2959            operator,
2960            postings,
2961            impact_scorer,
2962            metrics,
2963            shared_threshold,
2964        )
2965    }
2966
2967    #[allow(clippy::too_many_arguments)]
2968    fn bm25_search_modern(
2969        &self,
2970        lengths: &DocLengths,
2971        visibility: &DocVisibility,
2972        params: &FtsSearchParams,
2973        operator: Operator,
2974        postings: Vec<PostingIterator>,
2975        impact_scorer: Option<Arc<MemBM25Scorer>>,
2976        metrics: &dyn MetricsCollector,
2977        shared_threshold: Arc<AtomicU32>,
2978    ) -> Result<Vec<DocCandidate<DocId>>> {
2979        if visibility.is_all() {
2980            let documents = ModernWandDocuments::all(lengths);
2981            self.bm25_search_with_documents(
2982                &documents,
2983                params,
2984                operator,
2985                postings,
2986                impact_scorer,
2987                metrics,
2988                shared_threshold,
2989            )
2990        } else {
2991            let documents = ModernWandDocuments::filtered(lengths, visibility);
2992            self.bm25_search_with_documents(
2993                &documents,
2994                params,
2995                operator,
2996                postings,
2997                impact_scorer,
2998                metrics,
2999                shared_threshold,
3000            )
3001        }
3002    }
3003
3004    #[instrument(level = "debug", skip_all)]
3005    #[allow(clippy::too_many_arguments)]
3006    fn bm25_search_with_documents<D: WandDocuments>(
3007        &self,
3008        documents: &D,
3009        params: &FtsSearchParams,
3010        operator: Operator,
3011        postings: Vec<PostingIterator>,
3012        impact_scorer: Option<Arc<MemBM25Scorer>>,
3013        metrics: &dyn MetricsCollector,
3014        shared_threshold: Arc<AtomicU32>,
3015    ) -> Result<Vec<DocCandidate<D::Candidate>>> {
3016        if postings.is_empty() {
3017            return Ok(Vec::new());
3018        }
3019
3020        let hits = if let Some(scorer) = impact_scorer {
3021            let mut wand = Wand::new(operator, postings.into_iter(), documents, scorer)
3022                .with_shared_threshold(shared_threshold);
3023            wand.search(params, metrics)?
3024        } else {
3025            let scorer = IndexBM25Scorer::new(std::iter::once(self));
3026            let mut wand = Wand::new(operator, postings.into_iter(), documents, scorer)
3027                .with_shared_threshold(shared_threshold);
3028            wand.search(params, metrics)?
3029        };
3030        Ok(hits)
3031    }
3032
3033    pub async fn into_builder(self) -> Result<InnerBuilder> {
3034        let mut builder = InnerBuilder::new_with_posting_tail_codec_and_block_size(
3035            self.id,
3036            self.inverted_list.has_positions(),
3037            self.token_set_format,
3038            self.inverted_list.posting_tail_codec(),
3039            self.inverted_list.block_size(),
3040        );
3041        builder.tokens = self.tokens.into_mutable();
3042        builder.docs = self.docs.load_build_docset().await?;
3043
3044        builder
3045            .posting_lists
3046            .reserve_exact(self.inverted_list.len());
3047        for posting_list in self
3048            .inverted_list
3049            .read_all(self.inverted_list.has_positions())
3050            .await?
3051        {
3052            let posting_list = posting_list?;
3053            builder
3054                .posting_lists
3055                .push(posting_list.into_builder(&builder.docs));
3056        }
3057        Ok(builder)
3058    }
3059}
3060
3061// at indexing, we use HashMap because we need it to be mutable,
3062// at searching, we use fst::Map because it's more efficient
3063#[derive(Debug, Clone)]
3064pub enum TokenMap {
3065    HashMap(HashMap<String, u32>),
3066    Fst(fst::Map<Vec<u8>>),
3067}
3068
3069impl Default for TokenMap {
3070    fn default() -> Self {
3071        Self::HashMap(HashMap::new())
3072    }
3073}
3074
3075impl DeepSizeOf for TokenMap {
3076    fn deep_size_of_children(&self, ctx: &mut lance_core::deepsize::Context) -> usize {
3077        match self {
3078            Self::HashMap(map) => map.deep_size_of_children(ctx),
3079            Self::Fst(map) => map.as_fst().size(),
3080        }
3081    }
3082}
3083
3084impl TokenMap {
3085    pub fn len(&self) -> usize {
3086        match self {
3087            Self::HashMap(map) => map.len(),
3088            Self::Fst(map) => map.len(),
3089        }
3090    }
3091
3092    pub fn is_empty(&self) -> bool {
3093        self.len() == 0
3094    }
3095}
3096
3097// TokenSet is a mapping from tokens to token ids
3098#[derive(Debug, Clone, Default, DeepSizeOf)]
3099pub struct TokenSet {
3100    // token -> token_id
3101    pub(crate) tokens: TokenMap,
3102    pub(crate) next_id: u32,
3103    total_length: usize,
3104}
3105
3106impl TokenSet {
3107    pub fn into_mut(self) -> Self {
3108        let tokens = match self.tokens {
3109            TokenMap::HashMap(map) => map,
3110            TokenMap::Fst(map) => {
3111                let mut new_map = HashMap::with_capacity(map.len());
3112                let mut stream = map.into_stream();
3113                while let Some((token, token_id)) = stream.next() {
3114                    new_map.insert(String::from_utf8_lossy(token).into_owned(), token_id as u32);
3115                }
3116
3117                new_map
3118            }
3119        };
3120
3121        Self {
3122            tokens: TokenMap::HashMap(tokens),
3123            next_id: self.next_id,
3124            total_length: self.total_length,
3125        }
3126    }
3127
3128    pub fn len(&self) -> usize {
3129        self.tokens.len()
3130    }
3131
3132    pub fn is_empty(&self) -> bool {
3133        self.len() == 0
3134    }
3135
3136    pub fn to_batch(self, format: TokenSetFormat) -> Result<RecordBatch> {
3137        match format {
3138            TokenSetFormat::Arrow => self.into_arrow_batch(),
3139            TokenSetFormat::Fst => self.into_fst_batch(),
3140        }
3141    }
3142
3143    fn into_arrow_batch(self) -> Result<RecordBatch> {
3144        let mut token_builder = StringBuilder::with_capacity(self.tokens.len(), self.total_length);
3145        let mut token_id_builder = UInt32Builder::with_capacity(self.tokens.len());
3146
3147        match self.tokens {
3148            TokenMap::Fst(map) => {
3149                let mut stream = map.stream();
3150                while let Some((token, token_id)) = stream.next() {
3151                    token_builder.append_value(String::from_utf8_lossy(token));
3152                    token_id_builder.append_value(token_id as u32);
3153                }
3154            }
3155            TokenMap::HashMap(map) => {
3156                for (token, token_id) in map.into_iter().sorted_unstable() {
3157                    token_builder.append_value(token);
3158                    token_id_builder.append_value(token_id);
3159                }
3160            }
3161        }
3162
3163        let token_col = token_builder.finish();
3164        let token_id_col = token_id_builder.finish();
3165
3166        let schema = arrow_schema::Schema::new(vec![
3167            arrow_schema::Field::new(TOKEN_COL, DataType::Utf8, false),
3168            arrow_schema::Field::new(TOKEN_ID_COL, DataType::UInt32, false),
3169        ]);
3170
3171        let batch = RecordBatch::try_new(
3172            Arc::new(schema),
3173            vec![
3174                Arc::new(token_col) as ArrayRef,
3175                Arc::new(token_id_col) as ArrayRef,
3176            ],
3177        )?;
3178        Ok(batch)
3179    }
3180
3181    fn into_fst_batch(mut self) -> Result<RecordBatch> {
3182        let fst_map = match std::mem::take(&mut self.tokens) {
3183            TokenMap::Fst(map) => map,
3184            TokenMap::HashMap(map) => Self::build_fst_from_map(map)?,
3185        };
3186        let bytes = fst_map.into_fst().into_inner();
3187
3188        let mut fst_builder = LargeBinaryBuilder::with_capacity(1, bytes.len());
3189        fst_builder.append_value(bytes);
3190        let fst_col = fst_builder.finish();
3191
3192        let mut next_id_builder = UInt32Builder::with_capacity(1);
3193        next_id_builder.append_value(self.next_id);
3194        let next_id_col = next_id_builder.finish();
3195
3196        let mut total_length_builder = UInt64Builder::with_capacity(1);
3197        total_length_builder.append_value(self.total_length as u64);
3198        let total_length_col = total_length_builder.finish();
3199
3200        let schema = arrow_schema::Schema::new(vec![
3201            arrow_schema::Field::new(TOKEN_FST_BYTES_COL, DataType::LargeBinary, false),
3202            arrow_schema::Field::new(TOKEN_NEXT_ID_COL, DataType::UInt32, false),
3203            arrow_schema::Field::new(TOKEN_TOTAL_LENGTH_COL, DataType::UInt64, false),
3204        ]);
3205
3206        let batch = RecordBatch::try_new(
3207            Arc::new(schema),
3208            vec![
3209                Arc::new(fst_col) as ArrayRef,
3210                Arc::new(next_id_col) as ArrayRef,
3211                Arc::new(total_length_col) as ArrayRef,
3212            ],
3213        )?;
3214        Ok(batch)
3215    }
3216
3217    fn build_fst_from_map(map: HashMap<String, u32>) -> Result<fst::Map<Vec<u8>>> {
3218        let mut entries: Vec<_> = map.into_iter().collect();
3219        entries.sort_unstable_by(|(lhs, _), (rhs, _)| lhs.cmp(rhs));
3220        let mut builder = fst::MapBuilder::memory();
3221        for (token, token_id) in entries {
3222            builder
3223                .insert(&token, token_id as u64)
3224                .map_err(|e| Error::index(format!("failed to insert token {}: {}", token, e)))?;
3225        }
3226        Ok(builder.into_map())
3227    }
3228
3229    pub async fn load(reader: Arc<dyn IndexReader>, format: TokenSetFormat) -> Result<Self> {
3230        match format {
3231            TokenSetFormat::Arrow => Self::load_arrow(reader).await,
3232            TokenSetFormat::Fst => Self::load_fst(reader).await,
3233        }
3234    }
3235
3236    async fn load_arrow(reader: Arc<dyn IndexReader>) -> Result<Self> {
3237        let batch = reader.read_range(0..reader.num_rows(), None).await?;
3238
3239        let (tokens, next_id, total_length) = spawn_blocking(move || {
3240            let mut next_id = 0;
3241            let mut total_length = 0;
3242            let mut tokens = fst::MapBuilder::memory();
3243
3244            let token_col = batch[TOKEN_COL].as_string::<i32>();
3245            let token_id_col = batch[TOKEN_ID_COL].as_primitive::<datatypes::UInt32Type>();
3246
3247            for (token, &token_id) in token_col.iter().zip(token_id_col.values().iter()) {
3248                let token =
3249                    token.ok_or(Error::index("found null token in token set".to_owned()))?;
3250                next_id = next_id.max(token_id + 1);
3251                total_length += token.len();
3252                tokens.insert(token, token_id as u64).map_err(|e| {
3253                    Error::index(format!("failed to insert token {}: {}", token, e))
3254                })?;
3255            }
3256
3257            Ok::<_, Error>((tokens.into_map(), next_id, total_length))
3258        })
3259        .await
3260        .map_err(|err| Error::execution(format!("failed to spawn blocking task: {}", err)))??;
3261
3262        Ok(Self {
3263            tokens: TokenMap::Fst(tokens),
3264            next_id,
3265            total_length,
3266        })
3267    }
3268
3269    async fn load_fst(reader: Arc<dyn IndexReader>) -> Result<Self> {
3270        let batch = reader.read_range(0..reader.num_rows(), None).await?;
3271        if batch.num_rows() == 0 {
3272            return Err(Error::index("token set batch is empty".to_owned()));
3273        }
3274
3275        let fst_col = batch[TOKEN_FST_BYTES_COL].as_binary::<i64>();
3276        let bytes = fst_col.value(0);
3277        let map = fst::Map::new(bytes.to_vec())
3278            .map_err(|e| Error::index(format!("failed to load fst tokens: {}", e)))?;
3279
3280        let total_length_col =
3281            batch[TOKEN_TOTAL_LENGTH_COL].as_primitive::<datatypes::UInt64Type>();
3282
3283        // Token ids are dense `[0, len)`, so `next_id` must equal the token count. Recompute
3284        // it instead of trusting the persisted value, which writers before #7115 could leave
3285        // stale. Mirrors `load_arrow`.
3286        let next_id = map.len() as u32;
3287
3288        let total_length = total_length_col
3289            .values()
3290            .first()
3291            .copied()
3292            .ok_or(Error::index(
3293                "token total length column is empty".to_owned(),
3294            ))?;
3295
3296        Ok(Self {
3297            tokens: TokenMap::Fst(map),
3298            next_id,
3299            total_length: usize::try_from(total_length).map_err(|_| {
3300                Error::index(format!(
3301                    "token total length {} overflows usize",
3302                    total_length
3303                ))
3304            })?,
3305        })
3306    }
3307
3308    pub fn add(&mut self, token: String) -> u32 {
3309        let next_id = self.next_id();
3310        let len = token.len();
3311        let token_id = match self.tokens {
3312            TokenMap::HashMap(ref mut map) => *map.entry(token).or_insert(next_id),
3313            _ => unreachable!("tokens must be HashMap while indexing"),
3314        };
3315
3316        // add token if it doesn't exist
3317        if token_id == next_id {
3318            self.next_id += 1;
3319            self.total_length += len;
3320        }
3321
3322        token_id
3323    }
3324
3325    pub(crate) fn get_or_add(&mut self, token: &str) -> u32 {
3326        let next_id = self.next_id;
3327        match self.tokens {
3328            TokenMap::HashMap(ref mut map) => {
3329                if let Some(&token_id) = map.get(token) {
3330                    return token_id;
3331                }
3332
3333                map.insert(token.to_owned(), next_id);
3334            }
3335            _ => unreachable!("tokens must be HashMap while indexing"),
3336        }
3337
3338        self.next_id += 1;
3339        self.total_length += token.len();
3340        next_id
3341    }
3342
3343    pub(crate) fn into_mutable(self) -> Self {
3344        let Self {
3345            tokens,
3346            next_id,
3347            total_length,
3348        } = self;
3349        match tokens {
3350            TokenMap::HashMap(_) => Self {
3351                tokens,
3352                next_id,
3353                total_length,
3354            },
3355            TokenMap::Fst(map) => {
3356                let mut mutable = HashMap::new();
3357                let mut stream = map.stream();
3358                while let Some((token, token_id)) = stream.next() {
3359                    mutable.insert(String::from_utf8_lossy(token).into_owned(), token_id as u32);
3360                }
3361                Self {
3362                    tokens: TokenMap::HashMap(mutable),
3363                    next_id,
3364                    total_length,
3365                }
3366            }
3367        }
3368    }
3369
3370    pub fn get(&self, token: &str) -> Option<u32> {
3371        match self.tokens {
3372            TokenMap::HashMap(ref map) => map.get(token).copied(),
3373            TokenMap::Fst(ref map) => map.get(token).map(|id| id as u32),
3374        }
3375    }
3376
3377    // the `removed_token_ids` must be sorted
3378    pub fn remap(&mut self, removed_token_ids: &[u32]) {
3379        if removed_token_ids.is_empty() {
3380            return;
3381        }
3382
3383        let mut map = match std::mem::take(&mut self.tokens) {
3384            TokenMap::HashMap(map) => map,
3385            TokenMap::Fst(map) => {
3386                let mut new_map = HashMap::with_capacity(map.len());
3387                let mut stream = map.into_stream();
3388                while let Some((token, token_id)) = stream.next() {
3389                    new_map.insert(String::from_utf8_lossy(token).into_owned(), token_id as u32);
3390                }
3391
3392                new_map
3393            }
3394        };
3395
3396        let mut retained_length = 0;
3397        map.retain(
3398            |token, token_id| match removed_token_ids.binary_search(token_id) {
3399                Ok(_) => false,
3400                Err(index) => {
3401                    *token_id -= index as u32;
3402                    retained_length += token.len();
3403                    true
3404                }
3405            },
3406        );
3407
3408        self.tokens = TokenMap::HashMap(map);
3409
3410        // The retain above compacts the surviving token ids into a dense `[0, len)`
3411        // range, so `next_id` (handed to the next new token) must follow them down.
3412        // `total_length` likewise must drop the removed tokens' bytes; it is persisted
3413        // and feeds memory accounting, so a stale value drifts across remap/merge cycles.
3414        self.next_id = self.tokens.len() as u32;
3415        self.total_length = retained_length;
3416    }
3417
3418    pub fn next_id(&self) -> u32 {
3419        self.next_id
3420    }
3421
3422    pub(crate) fn memory_size(&self) -> usize {
3423        match &self.tokens {
3424            TokenMap::HashMap(map) => {
3425                self.total_length
3426                    + map.capacity()
3427                        * (std::mem::size_of::<String>()
3428                            + std::mem::size_of::<u32>()
3429                            + std::mem::size_of::<usize>())
3430            }
3431            TokenMap::Fst(map) => map.as_fst().size(),
3432        }
3433    }
3434}
3435
3436pub struct PostingListReader {
3437    reader: Arc<dyn IndexReader>,
3438
3439    /// Layout-specific metadata. V2 keeps its per-token max-score and
3440    /// length columns lazy so opening a partition doesn't drag O(num_tokens)
3441    /// bytes off cold storage when the caller only needs `df` for a few terms.
3442    metadata: PostingMetadata,
3443
3444    has_position: bool,
3445    has_impacts: bool,
3446    posting_tail_codec: PostingTailCodec,
3447    block_size: usize,
3448    positions_layout: PositionsLayout,
3449
3450    /// Runtime posting-list cache grouping. Non-empty v2 indexes use synthetic
3451    /// fixed groups so prewarm can improve cache density without rebuilding the
3452    /// index or relying on persisted grouping metadata.
3453    grouping: PostingGrouping,
3454
3455    /// Modern postings contain dense DocIds into the partition document table.
3456    /// Cache successful boundary validation per immutable token so repeated
3457    /// queries do not decode the final posting block again.
3458    modern_doc_id_validations: Option<Arc<[OnceCell<()>]>>,
3459    /// Skips per-token readiness checks once the whole immutable table is validated.
3460    modern_postings_validated: AtomicBool,
3461    modern_num_docs: Option<usize>,
3462
3463    index_cache: WeakLanceCache,
3464}
3465
3466/// Per-token metadata (max_score, length) needed by the BM25 query and stats
3467/// paths. The legacy and v2 formats store this metadata in different
3468/// places, with very different cost profiles for cold-load: the variants
3469/// surface that asymmetry so callers can choose a per-token or bulk access
3470/// pattern.
3471enum PostingMetadata {
3472    /// Legacy v1: offsets and max_scores are encoded in the file's schema
3473    /// metadata, so they are already in memory by the time `try_new` returns.
3474    LegacyV1 {
3475        offsets: Vec<usize>,
3476        max_scores: Option<Vec<f32>>,
3477    },
3478    /// V2: per-token `max_score` and `length` live as columns in the
3479    /// posting file. The bulk vectors are filled lazily by
3480    /// `ensure_metadata_loaded`, and the stats path can also fetch a single
3481    /// token via `posting_len_for_token` without forcing the bulk load.
3482    V2 {
3483        metadata: OnceCell<LoadedPostingMetadata>,
3484    },
3485}
3486
3487#[derive(Debug, Clone)]
3488struct LoadedPostingMetadata {
3489    max_scores: Vec<f32>,
3490    lengths: Vec<u32>,
3491}
3492
3493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3494enum PositionsLayout {
3495    None,
3496    LegacyPerDoc,
3497    SharedStream(PositionStreamCodec),
3498}
3499
3500impl std::fmt::Debug for PostingListReader {
3501    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3502        let mut s = f.debug_struct("InvertedListReader");
3503        match &self.metadata {
3504            PostingMetadata::LegacyV1 {
3505                offsets,
3506                max_scores,
3507            } => {
3508                s.field("layout", &"legacy_v1")
3509                    .field("offsets", offsets)
3510                    .field("max_scores", max_scores);
3511            }
3512            PostingMetadata::V2 { metadata } => {
3513                s.field("layout", &"v2")
3514                    .field("metadata_loaded", &metadata.initialized());
3515            }
3516        }
3517        s.finish()
3518    }
3519}
3520
3521impl DeepSizeOf for PostingListReader {
3522    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
3523        let metadata_size = match &self.metadata {
3524            PostingMetadata::LegacyV1 {
3525                offsets,
3526                max_scores,
3527            } => offsets.deep_size_of_children(context) + max_scores.deep_size_of_children(context),
3528            PostingMetadata::V2 { metadata } => metadata
3529                .get()
3530                .map(|loaded| {
3531                    loaded.max_scores.deep_size_of_children(context)
3532                        + loaded.lengths.deep_size_of_children(context)
3533                })
3534                .unwrap_or(0),
3535        };
3536        let validation_size = self
3537            .modern_doc_id_validations
3538            .as_ref()
3539            .map(|validations| {
3540                validations
3541                    .len()
3542                    .saturating_mul(std::mem::size_of::<OnceCell<()>>())
3543            })
3544            .unwrap_or(0);
3545        metadata_size + self.grouping.deep_size_of_children(context) + validation_size
3546    }
3547}
3548
3549impl PostingListReader {
3550    pub(crate) async fn try_new(
3551        reader: Arc<dyn IndexReader>,
3552        index_cache: &LanceCache,
3553    ) -> Result<Self> {
3554        let positions_layout = if reader.schema().field(COMPRESSED_POSITION_COL).is_some() {
3555            PositionsLayout::SharedStream(parse_shared_position_codec(&reader.schema().metadata)?)
3556        } else if reader.schema().field(POSITION_COL).is_some() {
3557            PositionsLayout::LegacyPerDoc
3558        } else {
3559            PositionsLayout::None
3560        };
3561        let posting_tail_codec = parse_posting_tail_codec(&reader.schema().metadata)?;
3562        let block_size = parse_posting_block_size(&reader.schema().metadata)?;
3563        let has_position = positions_layout != PositionsLayout::None;
3564        let has_impacts = reader.schema().field(IMPACT_COL).is_some();
3565        let metadata = if reader.schema().field(POSTING_COL).is_none() {
3566            let (offsets, max_scores) = Self::load_metadata(reader.schema())?;
3567            PostingMetadata::LegacyV1 {
3568                offsets,
3569                max_scores,
3570            }
3571        } else {
3572            PostingMetadata::V2 {
3573                metadata: OnceCell::new(),
3574            }
3575        };
3576
3577        let is_legacy_layout = matches!(&metadata, PostingMetadata::LegacyV1 { .. });
3578        let grouping = PostingGrouping::for_reader(is_legacy_layout, reader.num_rows());
3579        let modern_doc_id_validations = (!is_legacy_layout).then(|| {
3580            (0..reader.num_rows())
3581                .map(|_| OnceCell::new())
3582                .collect::<Vec<_>>()
3583                .into()
3584        });
3585
3586        Ok(Self {
3587            reader,
3588            metadata,
3589            has_position,
3590            has_impacts,
3591            posting_tail_codec,
3592            block_size,
3593            positions_layout,
3594            grouping,
3595            modern_doc_id_validations,
3596            modern_postings_validated: AtomicBool::new(false),
3597            modern_num_docs: None,
3598            index_cache: WeakLanceCache::from(index_cache),
3599        })
3600    }
3601
3602    // for legacy format
3603    // returns the offsets and max scores
3604    fn load_metadata(
3605        schema: &lance_core::datatypes::Schema,
3606    ) -> Result<(Vec<usize>, Option<Vec<f32>>)> {
3607        let offsets = schema
3608            .metadata
3609            .get("offsets")
3610            .ok_or(Error::index("offsets not found in metadata".to_owned()))?;
3611        let offsets = serde_json::from_str(offsets)?;
3612
3613        let max_scores = schema
3614            .metadata
3615            .get("max_scores")
3616            .map(|max_scores| serde_json::from_str(max_scores))
3617            .transpose()?;
3618        Ok((offsets, max_scores))
3619    }
3620
3621    // the number of posting lists
3622    pub fn len(&self) -> usize {
3623        match &self.metadata {
3624            PostingMetadata::LegacyV1 { offsets, .. } => offsets.len(),
3625            PostingMetadata::V2 { .. } => self.reader.num_rows(),
3626        }
3627    }
3628
3629    pub fn is_empty(&self) -> bool {
3630        self.len() == 0
3631    }
3632
3633    pub(crate) fn has_positions(&self) -> bool {
3634        self.has_position
3635    }
3636
3637    pub(crate) fn posting_tail_codec(&self) -> PostingTailCodec {
3638        self.posting_tail_codec
3639    }
3640
3641    pub(crate) fn block_size(&self) -> usize {
3642        self.block_size
3643    }
3644
3645    fn is_legacy_layout(&self) -> bool {
3646        matches!(self.metadata, PostingMetadata::LegacyV1 { .. })
3647    }
3648
3649    /// Sync access to `posting_len`. Requires v2 metadata to already be
3650    /// loaded via [`ensure_metadata_loaded`]; the bm25 scoring path enforces
3651    /// that contract before kicking off wand. The stats path uses
3652    /// [`Self::posting_len_for_token`] instead, which avoids the bulk load.
3653    pub(crate) fn posting_len(&self, token_id: u32) -> usize {
3654        let token_id = token_id as usize;
3655        match &self.metadata {
3656            PostingMetadata::LegacyV1 { offsets, .. } => {
3657                let next_offset = offsets
3658                    .get(token_id + 1)
3659                    .copied()
3660                    .unwrap_or(self.reader.num_rows());
3661                next_offset - offsets[token_id]
3662            }
3663            PostingMetadata::V2 { metadata } => {
3664                let metadata = metadata
3665                    .get()
3666                    .expect("v2 posting metadata must be bulk-loaded before sync posting_len; call ensure_metadata_loaded first");
3667                metadata.lengths[token_id] as usize
3668            }
3669        }
3670    }
3671
3672    /// Async access to a single token's posting list length. For v2
3673    /// indexes this reads one row of posting metadata if the bulk metadata has
3674    /// not been loaded yet, and never triggers the bulk load itself. The stats
3675    /// path uses this so a single-term `df` lookup costs O(1) bytes rather
3676    /// than O(num_unique_tokens).
3677    pub(crate) async fn posting_len_for_token(
3678        &self,
3679        token_id: u32,
3680        metrics: Option<&dyn MetricsCollector>,
3681    ) -> Result<usize> {
3682        match &self.metadata {
3683            PostingMetadata::LegacyV1 { .. } => Ok(self.posting_len(token_id)),
3684            PostingMetadata::V2 { metadata } => {
3685                if let Some(metadata) = metadata.get() {
3686                    return Ok(metadata.lengths[token_id as usize] as usize);
3687                }
3688                let (_, length) = self.posting_metadata_for_token(token_id, metrics).await?;
3689                length
3690                    .map(|len| len as usize)
3691                    .ok_or_else(|| Error::index("posting length metadata missing".to_string()))
3692            }
3693        }
3694    }
3695
3696    /// Async access to a single token's `(max_score, length)` pair. Mirrors
3697    /// [`Self::posting_len_for_token`] but covers both columns the scoring
3698    /// path needs, in one read. For v2 indexes that have not been
3699    /// bulk-loaded this issues one `read_range(token..token+1, [MAX_SCORE,
3700    /// LENGTH])`; for legacy v1 the values come from in-memory schema
3701    /// metadata.
3702    pub(crate) async fn posting_metadata_for_token(
3703        &self,
3704        token_id: u32,
3705        metrics: Option<&dyn MetricsCollector>,
3706    ) -> Result<(Option<f32>, Option<u32>)> {
3707        match &self.metadata {
3708            PostingMetadata::LegacyV1 { max_scores, .. } => {
3709                Ok((max_scores.as_ref().map(|m| m[token_id as usize]), None))
3710            }
3711            PostingMetadata::V2 { metadata } => {
3712                if let Some(loaded) = metadata.get() {
3713                    return Ok((
3714                        Some(loaded.max_scores[token_id as usize]),
3715                        Some(loaded.lengths[token_id as usize]),
3716                    ));
3717                }
3718                let result = self
3719                    .index_cache
3720                    .get_or_insert_with_key_hit(PostingMetadataKey { token_id }, || async move {
3721                        let token_id = token_id as usize;
3722                        let batch = self
3723                            .reader
3724                            .read_range(token_id..token_id + 1, Some(&[MAX_SCORE_COL, LENGTH_COL]))
3725                            .await?;
3726                        let max_score = batch[MAX_SCORE_COL].as_primitive::<Float32Type>().value(0);
3727                        let length = batch[LENGTH_COL].as_primitive::<UInt32Type>().value(0);
3728                        Ok(PostingMetadataValue { max_score, length })
3729                    })
3730                    .await;
3731                if let Some(metrics) = metrics {
3732                    match &result {
3733                        Ok((_, true)) => metrics.record_index_cache_hit(),
3734                        _ => metrics.record_index_cache_miss(),
3735                    }
3736                }
3737                let metadata = result.map(|(value, _)| value)?;
3738                Ok((Some(metadata.max_score), Some(metadata.length)))
3739            }
3740        }
3741    }
3742
3743    /// Force the v2 bulk metadata (`max_scores`, `lengths`) into
3744    /// memory. Cheap to call repeatedly; no-op for legacy v1 indexes whose
3745    /// metadata is already populated from schema metadata at `try_new` time.
3746    pub(crate) async fn ensure_metadata_loaded(&self) -> Result<()> {
3747        let PostingMetadata::V2 { metadata } = &self.metadata else {
3748            return Ok(());
3749        };
3750        metadata
3751            .get_or_try_init(|| async {
3752                let batch = self
3753                    .reader
3754                    .read_range(
3755                        0..self.reader.num_rows(),
3756                        Some(&[MAX_SCORE_COL, LENGTH_COL]),
3757                    )
3758                    .await?;
3759                let max_scores = batch[MAX_SCORE_COL]
3760                    .as_primitive::<Float32Type>()
3761                    .values()
3762                    .to_vec();
3763                let lengths = batch[LENGTH_COL]
3764                    .as_primitive::<UInt32Type>()
3765                    .values()
3766                    .to_vec();
3767                Ok::<LoadedPostingMetadata, Error>(LoadedPostingMetadata {
3768                    max_scores,
3769                    lengths,
3770                })
3771            })
3772            .await?;
3773        Ok(())
3774    }
3775
3776    pub(crate) async fn posting_batch(
3777        &self,
3778        token_id: u32,
3779        with_position: bool,
3780    ) -> Result<RecordBatch> {
3781        if self.is_legacy_layout() {
3782            self.posting_batch_legacy(token_id, with_position).await
3783        } else {
3784            let token_id = token_id as usize;
3785            let mut columns = if with_position {
3786                match self.positions_layout {
3787                    PositionsLayout::SharedStream(_) => {
3788                        vec![
3789                            POSTING_COL,
3790                            COMPRESSED_POSITION_COL,
3791                            POSITION_BLOCK_OFFSET_COL,
3792                        ]
3793                    }
3794                    PositionsLayout::LegacyPerDoc => vec![POSTING_COL, POSITION_COL],
3795                    PositionsLayout::None => vec![POSTING_COL],
3796                }
3797            } else {
3798                vec![POSTING_COL]
3799            };
3800            if self.has_impacts {
3801                columns.push(IMPACT_COL);
3802            }
3803            let batch = self
3804                .reader
3805                .read_range(token_id..token_id + 1, Some(&columns))
3806                .await?;
3807            Ok(batch)
3808        }
3809    }
3810
3811    async fn posting_batch_legacy(
3812        &self,
3813        token_id: u32,
3814        with_position: bool,
3815    ) -> Result<RecordBatch> {
3816        let mut columns = vec![ROW_ID, FREQUENCY_COL];
3817        if with_position {
3818            columns.push(POSITION_COL);
3819        }
3820
3821        let length = self.posting_len(token_id);
3822        let PostingMetadata::LegacyV1 { offsets, .. } = &self.metadata else {
3823            unreachable!("posting_batch_legacy is only reachable on legacy v1 layout");
3824        };
3825        let token_id = token_id as usize;
3826        let offset = offsets[token_id];
3827        let batch = self
3828            .reader
3829            .read_range(offset..offset + length, Some(&columns))
3830            .await?;
3831        Ok(batch)
3832    }
3833
3834    #[instrument(level = "debug", skip(self, metrics))]
3835    pub(crate) async fn posting_list(
3836        &self,
3837        token_id: u32,
3838        is_phrase_query: bool,
3839        metrics: &dyn MetricsCollector,
3840    ) -> Result<PostingList> {
3841        let mut posting = match self.group_range_for_token(token_id) {
3842            // Grouped path (issue #7040): one cache entry covers rows
3843            // [start, end), so neighbouring rare terms share a single read.
3844            Some((start, end)) => {
3845                let result = self
3846                    .index_cache
3847                    .get_or_insert_with_key_hit(
3848                        posting_list_group_cache_key(start, end, self.has_impacts),
3849                        || async move {
3850                            metrics.record_part_load();
3851                            info!(target: TRACE_IO_EVENTS, r#type=IO_TYPE_LOAD_SCALAR_PART, index_type="inverted", part_id=start);
3852                            self.load_posting_list_group(start, end).await
3853                        },
3854                    )
3855                    .await;
3856                match &result {
3857                    Ok((_, true)) => metrics.record_index_cache_hit(),
3858                    _ => metrics.record_index_cache_miss(),
3859                }
3860                let (group, _) = result?;
3861                let (max_score, length) = if group.needs_external_metadata() {
3862                    self.posting_metadata_for_token(token_id, Some(metrics))
3863                        .await?
3864                } else {
3865                    (None, None)
3866                };
3867                let slot = (token_id - start) as usize;
3868                group
3869                    .posting_list(slot, max_score, length)?
3870                    .ok_or_else(|| {
3871                        Error::index(format!(
3872                            "token {token_id} maps to slot {slot} outside posting group [{start}, {end})"
3873                        ))
3874                    })?
3875            }
3876            // Fallback for layouts that cannot use row-based groups: one cache
3877            // entry per token.
3878            None => {
3879                let result = self
3880                    .index_cache
3881                    .get_or_insert_with_key_hit(
3882                        posting_list_cache_key(token_id, self.has_impacts),
3883                        || async move {
3884                            metrics.record_part_load();
3885                            info!(target: TRACE_IO_EVENTS, r#type=IO_TYPE_LOAD_SCALAR_PART, index_type="inverted", part_id=token_id);
3886                            // Fetch the posting batch and this token's (max_score,
3887                            // length) in parallel; for cold v2 partitions this is one
3888                            // single-row metadata read plus one posting-row read,
3889                            // instead of pulling the full per-token metadata table.
3890                            let (batch, (max_score, length)) = futures::try_join!(
3891                                self.posting_batch(token_id, false),
3892                                self.posting_metadata_for_token(token_id, Some(metrics)),
3893                            )?;
3894                            self.posting_list_from_batch(&batch, max_score, length)
3895                        },
3896                    )
3897                    .await;
3898                match &result {
3899                    Ok((_, true)) => metrics.record_index_cache_hit(),
3900                    _ => metrics.record_index_cache_miss(),
3901                }
3902                result?.0.as_ref().clone()
3903            }
3904        };
3905
3906        if !self.modern_posting_is_validated(token_id)? {
3907            self.ensure_modern_posting_validated(token_id, &posting)
3908                .await?;
3909        }
3910
3911        if is_phrase_query && !posting.has_position() {
3912            // hit the cache and when the cache was populated, the positions column was not loaded
3913            let positions = self.read_positions(token_id, metrics).await?;
3914            posting.set_positions(positions);
3915        }
3916
3917        Ok(posting)
3918    }
3919
3920    async fn ensure_modern_posting_validated(
3921        &self,
3922        token_id: u32,
3923        posting: &PostingList,
3924    ) -> Result<()> {
3925        let (Some(validations), Some(num_docs)) =
3926            (&self.modern_doc_id_validations, self.modern_num_docs)
3927        else {
3928            return Ok(());
3929        };
3930        let validation = validations.get(token_id as usize).ok_or_else(|| {
3931            Error::index(format!(
3932                "modern FTS token id {token_id} is outside validation state [0, {})",
3933                validations.len()
3934            ))
3935        })?;
3936        validation
3937            .get_or_try_init(|| async {
3938                Self::validate_modern_posting(token_id, posting, num_docs)
3939            })
3940            .await
3941            .map(|_| ())
3942    }
3943
3944    #[inline]
3945    fn modern_posting_is_validated(&self, token_id: u32) -> Result<bool> {
3946        if self.modern_postings_validated.load(Ordering::Acquire) {
3947            return Ok(true);
3948        }
3949        let (Some(validations), Some(_)) = (&self.modern_doc_id_validations, self.modern_num_docs)
3950        else {
3951            return Ok(true);
3952        };
3953        let validation = validations.get(token_id as usize).ok_or_else(|| {
3954            Error::index(format!(
3955                "modern FTS token id {token_id} is outside validation state [0, {})",
3956                validations.len()
3957            ))
3958        })?;
3959        Ok(validation.get().is_some())
3960    }
3961
3962    fn validate_modern_posting(
3963        token_id: u32,
3964        posting: &PostingList,
3965        num_docs: usize,
3966    ) -> Result<()> {
3967        validate_modern_posting_doc_ids(posting, &format!("token id {token_id}"), num_docs)
3968    }
3969
3970    async fn publish_modern_posting_validated(&self, token_id: u32) -> Result<()> {
3971        let Some(validations) = &self.modern_doc_id_validations else {
3972            return Ok(());
3973        };
3974        let validation = validations.get(token_id as usize).ok_or_else(|| {
3975            Error::index(format!(
3976                "modern FTS token id {token_id} is outside validation state [0, {})",
3977                validations.len()
3978            ))
3979        })?;
3980        validation
3981            .get_or_try_init(|| async { Result::Ok(()) })
3982            .await
3983            .map(|_| ())
3984    }
3985
3986    fn modern_posting_validation_ready(&self) -> bool {
3987        if self.modern_postings_validated.load(Ordering::Acquire) {
3988            return true;
3989        }
3990        let ready = self
3991            .modern_doc_id_validations
3992            .as_ref()
3993            .is_none_or(|validations| validations.iter().all(|state| state.get().is_some()));
3994        if ready {
3995            self.modern_postings_validated
3996                .store(true, Ordering::Release);
3997        }
3998        ready
3999    }
4000
4001    /// Map a token id to its cache group's row range `[start, end)`, or `None`
4002    /// when grouping is not available so the caller falls back to the per-token
4003    /// path. In v2 the token id is the row offset, so the group range is also
4004    /// the physical row range.
4005    fn group_range_for_token(&self, token_id: u32) -> Option<(u32, u32)> {
4006        self.grouping.range_for_token(token_id, self.len())
4007    }
4008
4009    /// Read rows `[start, end)` into one compact Arrow-backed cache value.
4010    /// Positions are excluded; phrase queries load them on demand via
4011    /// [`Self::read_positions`].
4012    async fn load_posting_list_group(&self, start: u32, end: u32) -> Result<PostingListGroup> {
4013        let mut columns = vec![POSTING_COL, MAX_SCORE_COL, LENGTH_COL];
4014        if self.has_impacts {
4015            columns.push(IMPACT_COL);
4016        }
4017        let batch = self
4018            .reader
4019            .read_range(start as usize..end as usize, Some(&columns))
4020            .await?;
4021        PostingListGroup::new_packed_with_block_size(
4022            batch.shrink_to_fit()?,
4023            self.posting_tail_codec,
4024            self.block_size,
4025        )
4026    }
4027
4028    fn posting_list_from_batch_parts(
4029        batch: &RecordBatch,
4030        max_score: Option<f32>,
4031        length: Option<u32>,
4032        posting_tail_codec: PostingTailCodec,
4033        block_size: usize,
4034        positions_layout: PositionsLayout,
4035    ) -> Result<PostingList> {
4036        let posting_list = PostingList::from_batch_with_tail_codec_and_positions_layout(
4037            batch,
4038            max_score,
4039            length,
4040            posting_tail_codec,
4041            block_size,
4042            positions_layout,
4043        )?;
4044        Ok(posting_list)
4045    }
4046
4047    pub(crate) fn posting_list_from_batch(
4048        &self,
4049        batch: &RecordBatch,
4050        max_score: Option<f32>,
4051        length: Option<u32>,
4052    ) -> Result<PostingList> {
4053        Self::posting_list_from_batch_parts(
4054            batch,
4055            max_score,
4056            length,
4057            self.posting_tail_codec,
4058            self.block_size,
4059            self.positions_layout,
4060        )
4061    }
4062
4063    /// Build posting lists for one chunk's token range from `chunk_batch`, rebasing
4064    /// global offsets to chunk-local rows. Returns `(global token_id, PostingList)`
4065    /// pairs identical to the whole-file path, only bounded to one chunk.
4066    fn build_prewarm_posting_lists_chunk(
4067        chunk_batch: RecordBatch,
4068        chunk: PrewarmChunk<'_>,
4069        ctx: &PrewarmBuildCtx<'_>,
4070    ) -> Result<Vec<(u32, PostingList)>> {
4071        let mut posting_lists = Vec::with_capacity(chunk.token_count);
4072        for local in 0..chunk.token_count {
4073            let global = chunk.tok_start + local;
4074            let row_batch = if let Some(chunk_offsets) = chunk.offsets {
4075                // Legacy v1: rebase global offsets to chunk row 0; the last token
4076                // ends at `chunk.end_row` (no trailing sentinel in chunk_offsets).
4077                let base = chunk_offsets[0];
4078                let start = chunk_offsets[local] - base;
4079                let end = if local + 1 < chunk_offsets.len() {
4080                    chunk_offsets[local + 1] - base
4081                } else {
4082                    chunk.end_row - base
4083                };
4084                chunk_batch.slice(start, end - start)
4085            } else {
4086                // V2: one posting row per token; row `local` within the chunk.
4087                chunk_batch.slice(local, 1)
4088            };
4089            let row_batch = row_batch.shrink_to_fit()?;
4090            let posting_list = Self::posting_list_from_batch_parts(
4091                &row_batch,
4092                ctx.max_scores.map(|scores| scores[global]),
4093                ctx.lengths.map(|lengths| lengths[global]),
4094                ctx.posting_tail_codec,
4095                ctx.block_size,
4096                ctx.positions_layout,
4097            )?;
4098            posting_lists.push((global as u32, posting_list));
4099        }
4100
4101        Ok(posting_lists)
4102    }
4103
4104    /// Read the posting rows for token ids `[tok_start, tok_end)` into one RecordBatch.
4105    /// For v2 the token range is the row range; for v1 it's derived from the offsets.
4106    async fn read_chunk_batch(
4107        &self,
4108        tok_start: usize,
4109        tok_end: usize,
4110        with_position: bool,
4111    ) -> Result<RecordBatch> {
4112        let columns = self.posting_columns(with_position);
4113        let row_range = match &self.metadata {
4114            PostingMetadata::LegacyV1 { offsets, .. } => {
4115                let start = offsets[tok_start];
4116                let end = offsets
4117                    .get(tok_end)
4118                    .copied()
4119                    .unwrap_or_else(|| self.reader.num_rows());
4120                start..end
4121            }
4122            PostingMetadata::V2 { .. } => tok_start..tok_end,
4123        };
4124        let batch = self.reader.read_range(row_range, Some(&columns)).await?;
4125        Ok(batch)
4126    }
4127
4128    async fn prewarm_posting_lists(
4129        &self,
4130        with_position: bool,
4131        chunk_concurrency: usize,
4132    ) -> Result<()> {
4133        self.prewarm_posting_lists_chunked(with_position, None, chunk_concurrency)
4134            .await?;
4135        Ok(())
4136    }
4137
4138    /// Stream the partition's posting lists into the cache in bounded token-row chunks
4139    /// (read -> build -> insert -> drop), so peak resident set is ~one chunk. Returns
4140    /// the chunk count (tests assert it split). `chunk_tokens_override` is test-only.
4141    async fn prewarm_posting_lists_chunked(
4142        &self,
4143        with_position: bool,
4144        chunk_tokens_override: Option<usize>,
4145        chunk_concurrency: usize,
4146    ) -> Result<usize> {
4147        if with_position && !self.has_positions() {
4148            return Err(Error::invalid_input(
4149                "cannot prewarm positions for an inverted index that was built without positions; recreate the index with with_position=true".to_owned(),
4150            ));
4151        }
4152
4153        // Make max_scores/lengths available for query-local packed views. The
4154        // materialized fallback also clones them into its blocking build task.
4155        self.ensure_metadata_loaded().await?;
4156
4157        // With grouping the cache stores one entry per group, so a group's
4158        // posting lists must all be resident at once: align chunk boundaries to
4159        // whole groups. Without grouping, chunks are plain token ranges.
4160        let grouping = self.grouping.clone();
4161        let use_packed_groups = grouping.is_grouped() && !with_position;
4162        // Packed groups reuse the reader's bulk metadata at query time, so they
4163        // do not need the temporary full-partition metadata clones used by the
4164        // materialized fallback.
4165        let state = (!use_packed_groups).then(|| self.chunk_build_state());
4166        let token_count = self.len();
4167        let posting_data_size_bytes = self.posting_data_size_bytes();
4168        let chunk_tokens = chunk_tokens_override
4169            .unwrap_or_else(|| prewarm_chunk_tokens(token_count, posting_data_size_bytes))
4170            .max(1);
4171        let chunk_ranges = prewarm_chunk_ranges(&grouping, token_count, chunk_tokens);
4172        let chunk_count = chunk_ranges.len();
4173        let chunk_concurrency = chunk_concurrency.max(1);
4174
4175        let read_build_start = Instant::now();
4176        stream::iter(chunk_ranges)
4177            .map(|(tok_start, tok_end)| {
4178                let state = state.as_ref();
4179                let grouping = &grouping;
4180                async move {
4181                    if use_packed_groups {
4182                        let groups = self
4183                            .build_packed_chunk_groups(tok_start, tok_end, token_count, grouping)
4184                            .await?;
4185                        for (start, end, group) in groups {
4186                            self.index_cache
4187                                .insert_with_key(
4188                                    &posting_list_group_cache_key(start, end, self.has_impacts),
4189                                    Arc::new(group),
4190                                )
4191                                .await;
4192                        }
4193                    } else {
4194                        let state = state.expect(
4195                            "materialized prewarm must initialize posting-list build state",
4196                        );
4197                        let posting_lists = self
4198                            .build_chunk_postings(tok_start, tok_end, with_position, state)
4199                            .await?;
4200                        self.publish_chunk_postings(
4201                            posting_lists,
4202                            grouping,
4203                            tok_start,
4204                            tok_end,
4205                            token_count,
4206                            with_position,
4207                        )
4208                        .await;
4209                    }
4210                    Result::Ok(())
4211                }
4212            })
4213            .buffer_unordered(chunk_concurrency)
4214            .try_collect::<()>()
4215            .await?;
4216        let read_build_elapsed = read_build_start.elapsed();
4217
4218        info!(
4219            legacy_layout = self.is_legacy_layout(),
4220            with_position,
4221            token_count,
4222            chunk_count,
4223            chunk_tokens,
4224            chunk_concurrency,
4225            posting_data_size_bytes,
4226            read_build_ms = read_build_elapsed.as_secs_f64() * 1000.0,
4227            "posting list prewarm timing"
4228        );
4229
4230        Ok(chunk_count)
4231    }
4232
4233    /// Loop-invariant inputs shared by every chunk build: the metadata vecs
4234    /// (`Arc`d so chunks share them without re-cloning) plus codec/layout.
4235    fn chunk_build_state(&self) -> ChunkBuildState {
4236        let (offsets, max_scores, lengths) = match &self.metadata {
4237            PostingMetadata::LegacyV1 {
4238                offsets,
4239                max_scores,
4240            } => (Some(offsets.clone()), max_scores.clone(), None),
4241            PostingMetadata::V2 { metadata } => (
4242                None,
4243                metadata.get().map(|loaded| loaded.max_scores.clone()),
4244                metadata.get().map(|loaded| loaded.lengths.clone()),
4245            ),
4246        };
4247        ChunkBuildState {
4248            offsets: offsets.map(Arc::new),
4249            max_scores: max_scores.map(Arc::new),
4250            lengths: lengths.map(Arc::new),
4251            posting_tail_codec: self.posting_tail_codec,
4252            block_size: self.block_size,
4253            positions_layout: self.positions_layout,
4254        }
4255    }
4256
4257    /// Read one token-row chunk and build its posting lists off the runtime thread.
4258    /// The large batch is dropped inside the blocking task once built, bounding
4259    /// resident memory to one chunk.
4260    async fn build_chunk_postings(
4261        &self,
4262        tok_start: usize,
4263        tok_end: usize,
4264        with_position: bool,
4265        state: &ChunkBuildState,
4266    ) -> Result<Vec<(u32, PostingList)>> {
4267        let chunk_token_count = tok_end - tok_start;
4268        let chunk_batch = self
4269            .read_chunk_batch(tok_start, tok_end, with_position)
4270            .await?;
4271
4272        let (chunk_offsets, chunk_end_row) = match state.offsets.as_ref() {
4273            Some(offsets) => {
4274                let end_row = offsets
4275                    .get(tok_end)
4276                    .copied()
4277                    .unwrap_or_else(|| self.reader.num_rows());
4278                (Some(offsets[tok_start..tok_end].to_vec()), end_row)
4279            }
4280            // V2 doesn't use chunk_end_row (one row per token); pass tok_end.
4281            None => (None, tok_end),
4282        };
4283        let max_scores = state.max_scores.clone();
4284        let lengths = state.lengths.clone();
4285        let posting_tail_codec = state.posting_tail_codec;
4286        let block_size = state.block_size;
4287        let positions_layout = state.positions_layout;
4288        let num_docs = self.modern_num_docs;
4289        let posting_lists = spawn_blocking(move || {
4290            let ctx = PrewarmBuildCtx {
4291                max_scores: max_scores.as_deref().map(|v| v.as_slice()),
4292                lengths: lengths.as_deref().map(|v| v.as_slice()),
4293                posting_tail_codec,
4294                block_size,
4295                positions_layout,
4296            };
4297            let chunk = PrewarmChunk {
4298                tok_start,
4299                token_count: chunk_token_count,
4300                offsets: chunk_offsets.as_deref(),
4301                end_row: chunk_end_row,
4302            };
4303            let posting_lists = Self::build_prewarm_posting_lists_chunk(chunk_batch, chunk, &ctx)?;
4304            if let Some(num_docs) = num_docs {
4305                for (token_id, posting) in &posting_lists {
4306                    Self::validate_modern_posting(*token_id, posting, num_docs)?;
4307                }
4308            }
4309            Result::Ok(posting_lists)
4310        })
4311        .await
4312        .map_err(|err| {
4313            Error::internal(format!(
4314                "Failed to build prewarm posting lists in blocking task: {err}"
4315            ))
4316        })??;
4317        for (token_id, _) in &posting_lists {
4318            self.publish_modern_posting_validated(*token_id).await?;
4319        }
4320        // The chunk yields its token range as contiguous ascending ids from
4321        // `tok_start`; the group publish path relies on this to index the lists.
4322        debug_assert_eq!(posting_lists.len(), chunk_token_count);
4323        debug_assert!(
4324            posting_lists
4325                .iter()
4326                .enumerate()
4327                .all(|(i, (token_id, _))| *token_id as usize == tok_start + i)
4328        );
4329        Ok(posting_lists)
4330    }
4331
4332    /// Build compact v2 groups directly from one posting-row chunk. Each group
4333    /// slice is deep-copied once, so it owns only its Arrow buffers without
4334    /// materializing a `Vec<PostingList>` or retaining the full chunk.
4335    async fn build_packed_chunk_groups(
4336        &self,
4337        tok_start: usize,
4338        tok_end: usize,
4339        token_count: usize,
4340        grouping: &PostingGrouping,
4341    ) -> Result<Vec<(u32, u32, PostingListGroup)>> {
4342        debug_assert!(grouping.is_grouped());
4343        debug_assert!(!self.is_legacy_layout());
4344
4345        let chunk_batch = self.read_chunk_batch(tok_start, tok_end, false).await?;
4346        let ranges = grouping.ranges_for_chunk(tok_start, tok_end, token_count);
4347        let posting_tail_codec = self.posting_tail_codec;
4348        let block_size = self.block_size;
4349        let num_docs = self.modern_num_docs;
4350        let (chunk_max_scores, chunk_lengths) = match &self.metadata {
4351            PostingMetadata::V2 { metadata } => {
4352                let loaded = metadata.get().ok_or_else(|| {
4353                    Error::internal("packed prewarm requires loaded posting metadata".to_owned())
4354                })?;
4355                (
4356                    loaded.max_scores[tok_start..tok_end].to_vec(),
4357                    loaded.lengths[tok_start..tok_end].to_vec(),
4358                )
4359            }
4360            PostingMetadata::LegacyV1 { .. } => {
4361                return Err(Error::internal(
4362                    "packed prewarm is not supported for legacy posting metadata".to_owned(),
4363                ));
4364            }
4365        };
4366
4367        let groups = spawn_blocking(move || {
4368            let mut groups = Vec::with_capacity(ranges.len());
4369            for (start, end) in ranges {
4370                let start_usize = start as usize;
4371                let end_usize = end as usize;
4372                let local_start = start_usize - tok_start;
4373                let group_len = end_usize - start_usize;
4374                let group_batch = chunk_batch.slice(local_start, group_len).shrink_to_fit()?;
4375                let group = PostingListGroup::new_packed_with_block_size(
4376                    group_batch,
4377                    posting_tail_codec,
4378                    block_size,
4379                )?;
4380                if let Some(num_docs) = num_docs {
4381                    for token_id in start..end {
4382                        let chunk_slot = token_id as usize - tok_start;
4383                        let posting = group
4384                            .posting_list(
4385                                (token_id - start) as usize,
4386                                Some(chunk_max_scores[chunk_slot]),
4387                                Some(chunk_lengths[chunk_slot]),
4388                            )?
4389                            .ok_or_else(|| {
4390                                Error::index(format!(
4391                                    "token {token_id} is missing from prewarm posting group [{start}, {end})"
4392                                ))
4393                            })?;
4394                        Self::validate_modern_posting(token_id, &posting, num_docs)?;
4395                    }
4396                }
4397                groups.push((start, end, group));
4398            }
4399            Result::Ok(groups)
4400        })
4401        .await
4402        .map_err(|err| {
4403            Error::internal(format!(
4404                "Failed to build packed prewarm posting groups in blocking task: {err}"
4405            ))
4406        })??;
4407        for (start, end, _) in &groups {
4408            for token_id in *start..*end {
4409                self.publish_modern_posting_validated(token_id).await?;
4410            }
4411        }
4412        Ok(groups)
4413    }
4414
4415    /// Strip positions into their own per-token cache entries (the posting cache
4416    /// holds positions-free lists), then populate the same cache keys the read
4417    /// path uses: grouped entries when grouping is active, per-token entries
4418    /// otherwise. Called once per chunk; the chunk's lists drop on return.
4419    async fn publish_chunk_postings(
4420        &self,
4421        posting_lists: Vec<(u32, PostingList)>,
4422        grouping: &PostingGrouping,
4423        tok_start: usize,
4424        tok_end: usize,
4425        token_count: usize,
4426        with_position: bool,
4427    ) {
4428        match grouping {
4429            PostingGrouping::None => {
4430                for (token_id, mut posting_list) in posting_lists {
4431                    self.cache_positions(&mut posting_list, token_id, with_position)
4432                        .await;
4433                    self.index_cache
4434                        .insert_with_key(
4435                            &posting_list_cache_key(token_id, self.has_impacts),
4436                            Arc::new(posting_list),
4437                        )
4438                        .await;
4439                }
4440            }
4441            PostingGrouping::SyntheticFixed { .. } => {
4442                let mut chunk_postings = Vec::with_capacity(posting_lists.len());
4443                for (token_id, mut posting_list) in posting_lists {
4444                    self.cache_positions(&mut posting_list, token_id, with_position)
4445                        .await;
4446                    chunk_postings.push(posting_list);
4447                }
4448                // Chunk is group-aligned, so every group starting in it also ends
4449                // in it; `chunk_postings[i]` is token `tok_start + i`. The last
4450                // group's `end` derives from `token_count`, matching the read path
4451                // so both produce identical `PostingListGroupKey`s.
4452                for (start, end) in grouping.ranges_for_chunk(tok_start, tok_end, token_count) {
4453                    let start_usize = start as usize;
4454                    let lo = start_usize - tok_start;
4455                    let hi = end as usize - tok_start;
4456                    let group = PostingListGroup::new(chunk_postings[lo..hi].to_vec());
4457                    self.index_cache
4458                        .insert_with_key(
4459                            &posting_list_group_cache_key(start, end, self.has_impacts),
4460                            Arc::new(group),
4461                        )
4462                        .await;
4463                }
4464            }
4465        }
4466    }
4467
4468    /// Move a posting list's positions (when present and requested) into the
4469    /// dedicated per-token position cache, leaving the posting list positions-free.
4470    async fn cache_positions(
4471        &self,
4472        posting_list: &mut PostingList,
4473        token_id: u32,
4474        with_position: bool,
4475    ) {
4476        if with_position && let Some(positions) = posting_list.take_positions() {
4477            self.index_cache
4478                .insert_with_key(&PositionKey { token_id }, Arc::new(Positions(positions)))
4479                .await;
4480        }
4481    }
4482
4483    /// Cheap `invert.lance` size estimate (file length from object metadata, no
4484    /// data read), used only to size prewarm chunks. Falls back to a row-count
4485    /// proxy when the reader can't surface the length (legacy v1).
4486    pub(crate) fn posting_data_size_bytes(&self) -> u64 {
4487        if let Some(size) = self.reader.file_size_bytes() {
4488            return size;
4489        }
4490        // Fallback proxy for readers that don't cache their file length: just needs
4491        // to be monotonic in partition size.
4492        const ESTIMATED_BYTES_PER_ROW: u64 = 16;
4493        (self.reader.num_rows() as u64).saturating_mul(ESTIMATED_BYTES_PER_ROW)
4494    }
4495
4496    pub(crate) async fn read_batch(&self, with_position: bool) -> Result<RecordBatch> {
4497        let columns = self.posting_columns(with_position);
4498        let batch = self
4499            .reader
4500            .read_range(0..self.reader.num_rows(), Some(&columns))
4501            .await?;
4502        Ok(batch)
4503    }
4504
4505    pub(crate) async fn read_all(
4506        &self,
4507        with_position: bool,
4508    ) -> Result<impl Iterator<Item = Result<PostingList>> + '_> {
4509        // read_all walks every posting list; the bulk metadata is paid for
4510        // unconditionally, so just load it once up front and index into it
4511        // synchronously below.
4512        self.ensure_metadata_loaded().await?;
4513        let batch = self.read_batch(with_position).await?;
4514        Ok((0..self.len()).map(move |i| {
4515            let token_id = i as u32;
4516            let range = self.posting_list_range(token_id);
4517            let batch = batch.slice(i, range.end - range.start);
4518            let (max_score, length) = self.bulk_metadata_for_token(token_id);
4519            self.posting_list_from_batch(&batch, max_score, length)
4520        }))
4521    }
4522
4523    /// Sync lookup of `(max_score, length)` from the bulk-loaded metadata.
4524    /// Only safe after [`Self::ensure_metadata_loaded`]; callers that hold
4525    /// the OnceCell-loaded reference (e.g. read_all, prewarm) use this to
4526    /// avoid the per-token IO path.
4527    fn bulk_metadata_for_token(&self, token_id: u32) -> (Option<f32>, Option<u32>) {
4528        match &self.metadata {
4529            PostingMetadata::LegacyV1 { max_scores, .. } => {
4530                (max_scores.as_ref().map(|m| m[token_id as usize]), None)
4531            }
4532            PostingMetadata::V2 { metadata } => {
4533                let loaded = metadata.get().expect(
4534                    "v2 metadata must be bulk-loaded before bulk_metadata_for_token; call ensure_metadata_loaded first",
4535                );
4536                (
4537                    Some(loaded.max_scores[token_id as usize]),
4538                    Some(loaded.lengths[token_id as usize]),
4539                )
4540            }
4541        }
4542    }
4543
4544    async fn read_positions(
4545        &self,
4546        token_id: u32,
4547        metrics: &dyn MetricsCollector,
4548    ) -> Result<CompressedPositionStorage> {
4549        let result = self.index_cache.get_or_insert_with_key_hit(PositionKey { token_id }, || async move {
4550            let positions = match self.positions_layout {
4551                PositionsLayout::None => {
4552                    return Err(Error::invalid_input(
4553                        "position is not found but required for phrase queries, try recreating the index with position".to_owned(),
4554                    ));
4555                }
4556                PositionsLayout::LegacyPerDoc => {
4557                    let batch = self
4558                        .reader
4559                        .read_range(self.posting_list_range(token_id), Some(&[POSITION_COL]))
4560                        .await
4561                        .map_err(|e| match e {
4562                            Error::Schema { .. } => Error::invalid_input("position is not found but required for phrase queries, try recreating the index with position".to_owned()),
4563                            e => e,
4564                        })?;
4565                    CompressedPositionStorage::LegacyPerDoc(
4566                        batch[POSITION_COL].as_list::<i32>().value(0).as_list::<i32>().clone(),
4567                    )
4568                }
4569                PositionsLayout::SharedStream(codec) => {
4570                    let batch = self
4571                        .reader
4572                        .read_range(
4573                            self.posting_list_range(token_id),
4574                            Some(&[COMPRESSED_POSITION_COL, POSITION_BLOCK_OFFSET_COL]),
4575                        )
4576                        .await
4577                        .map_err(|e| match e {
4578                            Error::Schema { .. } => Error::invalid_input("position is not found but required for phrase queries, try recreating the index with position".to_owned()),
4579                            e => e,
4580                        })?;
4581                    let bytes = bytes::Bytes::from(
4582                        batch[COMPRESSED_POSITION_COL]
4583                            .as_binary::<i64>()
4584                            .value(0)
4585                            .to_vec(),
4586                    );
4587                    let block_offsets = batch[POSITION_BLOCK_OFFSET_COL]
4588                        .as_list::<i32>()
4589                        .value(0)
4590                        .as_primitive::<UInt32Type>()
4591                        .values()
4592                        .to_vec();
4593                    CompressedPositionStorage::SharedStream(SharedPositionStream::new(
4594                        codec,
4595                        block_offsets,
4596                        bytes,
4597                    ))
4598                }
4599            };
4600            Result::Ok(Positions(positions))
4601        }).await;
4602        match &result {
4603            Ok((_, true)) => metrics.record_index_cache_hit(),
4604            _ => metrics.record_index_cache_miss(),
4605        }
4606        let (positions, _) = result?;
4607        Ok(positions.0.clone())
4608    }
4609
4610    fn posting_list_range(&self, token_id: u32) -> Range<usize> {
4611        match &self.metadata {
4612            PostingMetadata::LegacyV1 { offsets, .. } => {
4613                let offset = offsets[token_id as usize];
4614                let posting_len = self.posting_len(token_id);
4615                offset..offset + posting_len
4616            }
4617            PostingMetadata::V2 { .. } => {
4618                let token_id = token_id as usize;
4619                token_id..token_id + 1
4620            }
4621        }
4622    }
4623
4624    fn posting_columns(&self, with_position: bool) -> Vec<&'static str> {
4625        let mut base_columns = if self.is_legacy_layout() {
4626            vec![ROW_ID, FREQUENCY_COL]
4627        } else {
4628            vec![POSTING_COL]
4629        };
4630        if with_position {
4631            match self.positions_layout {
4632                PositionsLayout::None => {}
4633                PositionsLayout::LegacyPerDoc => base_columns.push(POSITION_COL),
4634                PositionsLayout::SharedStream(_) => {
4635                    base_columns.push(COMPRESSED_POSITION_COL);
4636                    base_columns.push(POSITION_BLOCK_OFFSET_COL);
4637                }
4638            }
4639        }
4640        if self.has_impacts {
4641            base_columns.push(IMPACT_COL);
4642        }
4643        base_columns
4644    }
4645}
4646
4647/// Loop-invariant state for [`InvertedPartition::build_chunk_postings`]. The
4648/// metadata vecs are `Arc`d so each chunk's blocking build shares them cheaply.
4649struct ChunkBuildState {
4650    offsets: Option<Arc<Vec<usize>>>,
4651    max_scores: Option<Arc<Vec<f32>>>,
4652    lengths: Option<Arc<Vec<u32>>>,
4653    posting_tail_codec: PostingTailCodec,
4654    block_size: usize,
4655    positions_layout: PositionsLayout,
4656}
4657
4658/// Chunk-invariant inputs to [`InvertedPartition::build_prewarm_posting_lists_chunk`]:
4659/// the per-partition codec/layout and the (shared, whole-partition) metadata
4660/// slices indexed by global token id. These don't change across chunks.
4661struct PrewarmBuildCtx<'a> {
4662    max_scores: Option<&'a [f32]>,
4663    lengths: Option<&'a [u32]>,
4664    posting_tail_codec: PostingTailCodec,
4665    block_size: usize,
4666    positions_layout: PositionsLayout,
4667}
4668
4669/// Per-chunk inputs to [`InvertedPartition::build_prewarm_posting_lists_chunk`]:
4670/// the token sub-range `[tok_start, tok_start + token_count)` and, for legacy
4671/// v1, the rebased offset slice plus the chunk's end row.
4672struct PrewarmChunk<'a> {
4673    tok_start: usize,
4674    token_count: usize,
4675    /// Legacy v1 only: `offsets[tok_start..tok_start+token_count]` (no sentinel).
4676    offsets: Option<&'a [usize]>,
4677    /// Legacy v1 only: global row at which this chunk's posting rows end.
4678    end_row: usize,
4679}
4680
4681/// New type just to allow Positions implement DeepSizeOf so it can be put
4682/// in the cache.
4683#[derive(Clone)]
4684pub struct Positions(pub(super) CompressedPositionStorage);
4685
4686/// Slice-aware cache-size charge for the Arrow array shapes stored in posting
4687/// caches. [`Array::get_buffer_memory_size`] reports the full capacity of shared
4688/// backing buffers; cached posting lists often reference only a small slice of a
4689/// group read. Count the referenced span for the known posting-list types and
4690/// fall back to Arrow's full-buffer size for anything else.
4691fn sliced_cache_bytes(array: &dyn Array) -> usize {
4692    let validity_bytes = array
4693        .nulls()
4694        .map(|nulls| nulls.len().div_ceil(8))
4695        .unwrap_or(0);
4696    match array.data_type() {
4697        DataType::LargeBinary => {
4698            let array = array.as_binary::<i64>();
4699            let data_bytes = if array.is_empty() {
4700                0
4701            } else {
4702                let offsets = array.value_offsets();
4703                (offsets[array.len()] - offsets[0]) as usize
4704            };
4705            data_bytes + (array.len() + 1) * std::mem::size_of::<i64>() + validity_bytes
4706        }
4707        DataType::List(_) => {
4708            let array = array.as_list::<i32>();
4709            let (child_start, child_end) = if array.is_empty() {
4710                (0, 0)
4711            } else {
4712                let offsets = array.value_offsets();
4713                (offsets[0] as usize, offsets[array.len()] as usize)
4714            };
4715            let offset_bytes = (array.len() + 1) * std::mem::size_of::<i32>();
4716            let child = array.values().slice(child_start, child_end - child_start);
4717            offset_bytes + validity_bytes + sliced_cache_bytes(child.as_ref())
4718        }
4719        // Fixed-width primitives hold exactly `len * width` bytes regardless of
4720        // buffer capacity, so this is already slice-aware. Any other type falls
4721        // back to the full-buffer size.
4722        other => match other.primitive_width() {
4723            Some(width) => array.len() * width + validity_bytes,
4724            None => array.get_buffer_memory_size(),
4725        },
4726    }
4727}
4728
4729impl DeepSizeOf for Positions {
4730    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
4731        self.0.deep_size_of_children(context)
4732    }
4733}
4734
4735// Cache key implementations for type-safe cache access
4736#[derive(Debug, Clone)]
4737pub struct PostingListKey {
4738    pub token_id: u32,
4739}
4740
4741impl CacheKey for PostingListKey {
4742    type ValueType = PostingList;
4743
4744    fn key(&self) -> std::borrow::Cow<'_, str> {
4745        format!("postings-{}", self.token_id).into()
4746    }
4747
4748    fn type_name() -> &'static str {
4749        "PostingList"
4750    }
4751
4752    fn schema() -> CacheKeySchema {
4753        CacheKeySchema::new("lance.scalar.inverted.posting-list-key", 1)
4754    }
4755
4756    fn write_key(&self, builder: &mut KeyBuilder) {
4757        builder.write_u32(self.token_id);
4758    }
4759
4760    fn codec() -> Option<CacheCodec> {
4761        Some(CacheCodec::from_impl::<PostingList>())
4762    }
4763}
4764
4765/// Cache key for a group of consecutive posting lists stored as a single
4766/// entry, covering rows `[start, end)` (issue #7040). The range, not a token
4767/// id, is the key so a runtime group-size change simply misses old entries
4768/// instead of serving a differently-shaped group.
4769#[derive(Debug, Clone)]
4770pub struct PostingListGroupKey {
4771    pub start: u32,
4772    pub end: u32,
4773}
4774
4775impl CacheKey for PostingListGroupKey {
4776    type ValueType = PostingListGroup;
4777
4778    fn key(&self) -> std::borrow::Cow<'_, str> {
4779        format!("postings-{}-{}", self.start, self.end).into()
4780    }
4781
4782    fn type_name() -> &'static str {
4783        "PostingListGroup"
4784    }
4785
4786    fn schema() -> CacheKeySchema {
4787        CacheKeySchema::new("lance.scalar.inverted.posting-list-group-key", 1)
4788    }
4789
4790    fn write_key(&self, builder: &mut KeyBuilder) {
4791        builder.write_u32(self.start);
4792        builder.write_u32(self.end);
4793    }
4794
4795    fn codec() -> Option<CacheCodec> {
4796        Some(CacheCodec::from_impl::<PostingListGroup>())
4797    }
4798}
4799
4800/// Internal cache-key decorator that isolates impact-bearing posting values
4801/// without changing the source-compatible public posting key structs.
4802#[derive(Debug, Clone)]
4803struct ImpactAwareCacheKey<K> {
4804    inner: K,
4805    has_impacts: bool,
4806}
4807
4808impl<K: CacheKey> CacheKey for ImpactAwareCacheKey<K> {
4809    type ValueType = K::ValueType;
4810
4811    fn key(&self) -> std::borrow::Cow<'_, str> {
4812        if self.has_impacts {
4813            format!("{}-impacts", self.inner.key()).into()
4814        } else {
4815            self.inner.key()
4816        }
4817    }
4818
4819    fn type_name() -> &'static str {
4820        K::type_name()
4821    }
4822
4823    fn stable_type_id() -> &'static str {
4824        K::stable_type_id()
4825    }
4826
4827    fn schema() -> CacheKeySchema {
4828        CacheKeySchema::new("lance.scalar.inverted.impact-aware-key", 1)
4829    }
4830
4831    fn write_key(&self, builder: &mut KeyBuilder) {
4832        let inner_schema = K::schema();
4833        builder.write_str(K::stable_type_id());
4834        builder.write_str(inner_schema.id());
4835        builder.write_u32(inner_schema.version());
4836        builder.write_variant(if self.has_impacts { 1 } else { 0 });
4837        self.inner.write_key(builder);
4838    }
4839
4840    fn codec() -> Option<CacheCodec> {
4841        K::codec()
4842    }
4843}
4844
4845fn posting_list_cache_key(token_id: u32, has_impacts: bool) -> ImpactAwareCacheKey<PostingListKey> {
4846    ImpactAwareCacheKey {
4847        inner: PostingListKey { token_id },
4848        has_impacts,
4849    }
4850}
4851
4852fn posting_list_group_cache_key(
4853    start: u32,
4854    end: u32,
4855    has_impacts: bool,
4856) -> ImpactAwareCacheKey<PostingListGroupKey> {
4857    ImpactAwareCacheKey {
4858        inner: PostingListGroupKey { start, end },
4859        has_impacts,
4860    }
4861}
4862
4863#[derive(Debug, Clone, DeepSizeOf)]
4864struct PostingMetadataValue {
4865    max_score: f32,
4866    length: u32,
4867}
4868
4869#[derive(Debug, Clone)]
4870struct PostingMetadataKey {
4871    token_id: u32,
4872}
4873
4874impl CacheKey for PostingMetadataKey {
4875    type ValueType = PostingMetadataValue;
4876
4877    fn key(&self) -> std::borrow::Cow<'_, str> {
4878        format!("posting-metadata-{}", self.token_id).into()
4879    }
4880
4881    fn type_name() -> &'static str {
4882        "PostingMetadata"
4883    }
4884
4885    fn schema() -> CacheKeySchema {
4886        CacheKeySchema::new("lance.scalar.inverted.posting-metadata-key", 1)
4887    }
4888
4889    fn write_key(&self, builder: &mut KeyBuilder) {
4890        builder.write_u32(self.token_id);
4891    }
4892}
4893
4894#[derive(Debug, Clone)]
4895pub struct PositionKey {
4896    pub token_id: u32,
4897}
4898
4899impl CacheKey for PositionKey {
4900    type ValueType = Positions;
4901
4902    fn key(&self) -> std::borrow::Cow<'_, str> {
4903        format!("positions-{}", self.token_id).into()
4904    }
4905
4906    fn type_name() -> &'static str {
4907        "Position"
4908    }
4909
4910    fn schema() -> CacheKeySchema {
4911        CacheKeySchema::new("lance.scalar.inverted.position-key", 1)
4912    }
4913
4914    fn write_key(&self, builder: &mut KeyBuilder) {
4915        builder.write_u32(self.token_id);
4916    }
4917
4918    fn codec() -> Option<CacheCodec> {
4919        Some(CacheCodec::from_impl::<Positions>())
4920    }
4921}
4922
4923#[derive(Debug, Clone, PartialEq)]
4924pub enum CompressedPositionStorage {
4925    LegacyPerDoc(ListArray),
4926    SharedStream(SharedPositionStream),
4927}
4928
4929impl DeepSizeOf for CompressedPositionStorage {
4930    fn deep_size_of_children(&self, _context: &mut lance_core::deepsize::Context) -> usize {
4931        match self {
4932            Self::LegacyPerDoc(positions) => sliced_cache_bytes(positions),
4933            Self::SharedStream(stream) => stream.size(),
4934        }
4935    }
4936}
4937
4938#[derive(Debug, Clone, PartialEq, Eq, Default)]
4939pub struct SharedPositionStream {
4940    codec: PositionStreamCodec,
4941    block_offsets: Arc<[u32]>,
4942    // Stored with shared ownership so cache hits can clone position streams
4943    // without copying either offsets or bytes.
4944    bytes: bytes::Bytes,
4945}
4946
4947impl SharedPositionStream {
4948    pub fn new(codec: PositionStreamCodec, block_offsets: Vec<u32>, bytes: bytes::Bytes) -> Self {
4949        Self {
4950            codec,
4951            block_offsets: Arc::from(block_offsets.into_boxed_slice()),
4952            bytes,
4953        }
4954    }
4955
4956    pub fn codec(&self) -> PositionStreamCodec {
4957        self.codec
4958    }
4959
4960    pub fn block_count(&self) -> usize {
4961        self.block_offsets.len()
4962    }
4963
4964    pub fn block_range(&self, index: usize) -> Range<usize> {
4965        let start = self.block_offsets[index] as usize;
4966        let end = self
4967            .block_offsets
4968            .get(index + 1)
4969            .map(|offset| *offset as usize)
4970            .unwrap_or(self.bytes.len());
4971        start..end
4972    }
4973
4974    pub fn block(&self, index: usize) -> &[u8] {
4975        let range = self.block_range(index);
4976        &self.bytes[range]
4977    }
4978
4979    pub fn bytes(&self) -> &[u8] {
4980        &self.bytes
4981    }
4982
4983    pub fn block_offsets(&self) -> &[u32] {
4984        self.block_offsets.as_ref()
4985    }
4986
4987    pub fn size(&self) -> usize {
4988        self.block_offsets.len() * std::mem::size_of::<u32>() + self.bytes.len()
4989    }
4990}
4991
4992/// A group of consecutive posting lists held in a single cache entry, in row
4993/// order (issue #7040). Prewarmed modern groups without positions retain only
4994/// the compact Arrow posting rows read from `invert.lance`; max-score/length
4995/// metadata stays in the reader and is injected when a query creates a
4996/// posting-list view. Cold-loaded groups may keep inline metadata to preserve
4997/// one-read query loading. Legacy and position-bearing prewarm paths use the
4998/// materialized fallback.
4999#[derive(Debug, Clone)]
5000pub struct PostingListGroup {
5001    pub(super) storage: PostingListGroupStorage,
5002}
5003
5004#[derive(Debug, Clone)]
5005pub(super) enum PostingListGroupStorage {
5006    Packed(PackedPostingListGroup),
5007    Materialized(Vec<PostingList>),
5008}
5009
5010#[derive(Debug, Clone)]
5011pub(super) struct PackedPostingListGroup {
5012    pub(super) batch: RecordBatch,
5013    pub(super) posting_tail_codec: PostingTailCodec,
5014    pub(super) block_size: usize,
5015    first_docs_states: Arc<[OnceLock<Box<[u32]>>]>,
5016    first_docs_state_capacity_bytes: usize,
5017    impact_states: Option<Arc<[OnceLock<Box<ImpactSkipData>>]>>,
5018    impact_state_capacity_bytes: usize,
5019}
5020
5021impl DeepSizeOf for PostingListGroup {
5022    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
5023        match &self.storage {
5024            PostingListGroupStorage::Packed(group) => group
5025                .batch
5026                .columns()
5027                .iter()
5028                .map(|column| sliced_cache_bytes(column.as_ref()))
5029                .sum::<usize>()
5030                .saturating_add(group.first_docs_state_capacity_bytes)
5031                .saturating_add(group.impact_state_capacity_bytes),
5032            PostingListGroupStorage::Materialized(posting_lists) => {
5033                posting_lists.deep_size_of_children(context)
5034            }
5035        }
5036    }
5037}
5038
5039impl PostingListGroup {
5040    pub(super) fn new(posting_lists: Vec<PostingList>) -> Self {
5041        Self {
5042            storage: PostingListGroupStorage::Materialized(posting_lists),
5043        }
5044    }
5045
5046    pub(super) fn new_packed(
5047        batch: RecordBatch,
5048        posting_tail_codec: PostingTailCodec,
5049    ) -> Result<Self> {
5050        let block_size = parse_posting_block_size(batch.schema_ref().metadata())?;
5051        Self::new_packed_with_block_size(batch, posting_tail_codec, block_size)
5052    }
5053
5054    fn new_packed_with_block_size(
5055        batch: RecordBatch,
5056        posting_tail_codec: PostingTailCodec,
5057        block_size: usize,
5058    ) -> Result<Self> {
5059        validate_block_size(block_size)?;
5060        if let Some(encoded_block_size) = batch.schema_ref().metadata().get(POSTING_BLOCK_SIZE_KEY)
5061        {
5062            let encoded_block_size = encoded_block_size.parse::<usize>().map_err(|err| {
5063                Error::index(format!(
5064                    "invalid {POSTING_BLOCK_SIZE_KEY} metadata value {encoded_block_size:?}: {err}"
5065                ))
5066            })?;
5067            if encoded_block_size != block_size {
5068                return Err(Error::index(format!(
5069                    "packed posting group {POSTING_BLOCK_SIZE_KEY}={encoded_block_size} does not match block_size={block_size}"
5070                )));
5071            }
5072        }
5073
5074        // Projected reads may drop schema metadata. Restore the reader's
5075        // validated block size before the batch enters the packed cache so IPC
5076        // roundtrips remain self-describing. Older packed cache entries omit
5077        // the key and enter through new_packed with the legacy 128-doc default.
5078        let mut schema = batch.schema().as_ref().clone();
5079        schema
5080            .metadata
5081            .insert(POSTING_BLOCK_SIZE_KEY.to_owned(), block_size.to_string());
5082        let batch = batch.with_schema(Arc::new(schema))?;
5083        let postings = batch
5084            .column_by_name(POSTING_COL)
5085            .and_then(|column| column.as_list_opt::<i32>())
5086            .ok_or_else(|| {
5087                Error::index(format!(
5088                    "packed posting group column {POSTING_COL} must be List<LargeBinary>"
5089                ))
5090            })?;
5091        if postings.values().data_type() != &DataType::LargeBinary {
5092            return Err(Error::index(format!(
5093                "packed posting group column {POSTING_COL} must contain LargeBinary values, got {}",
5094                postings.values().data_type()
5095            )));
5096        }
5097        if postings.null_count() != 0 {
5098            return Err(Error::index(
5099                "packed posting group column must not contain nulls".to_string(),
5100            ));
5101        }
5102        let total_posting_blocks = (0..batch.num_rows())
5103            .map(|slot| postings.value_length(slot) as usize)
5104            .sum::<usize>();
5105        let first_docs_states: Arc<[OnceLock<Box<[u32]>>]> = (0..batch.num_rows())
5106            .map(|_| OnceLock::new())
5107            .collect::<Vec<_>>()
5108            .into();
5109        // Reserve the compact per-slot state slab and the block-head arrays it
5110        // can lazily retain, so warming these derived values cannot grow the
5111        // cache beyond its admission charge.
5112        let first_docs_state_capacity_bytes = first_docs_states
5113            .len()
5114            .saturating_mul(std::mem::size_of::<OnceLock<Box<[u32]>>>())
5115            .saturating_add(total_posting_blocks.saturating_mul(std::mem::size_of::<u32>()));
5116        let (impact_states, impact_state_capacity_bytes) = if let Some(impacts) =
5117            batch.column_by_name(IMPACT_COL)
5118        {
5119            let impacts = impacts.as_list_opt::<i32>().ok_or_else(|| {
5120                Error::index(format!(
5121                    "packed posting group column {IMPACT_COL} must be List<LargeBinary>"
5122                ))
5123            })?;
5124            if impacts.values().data_type() != &DataType::LargeBinary {
5125                return Err(Error::index(format!(
5126                    "packed posting group column {IMPACT_COL} must contain LargeBinary values, got {}",
5127                    impacts.values().data_type()
5128                )));
5129            }
5130            if impacts.null_count() != 0 {
5131                return Err(Error::index(format!(
5132                    "packed posting group column {IMPACT_COL} must not contain nulls"
5133                )));
5134            }
5135            let mut derived_cache_bytes = 0usize;
5136            for slot in 0..batch.num_rows() {
5137                let posting_blocks = postings.value_length(slot) as usize;
5138                let impact_entries = impacts.value_length(slot) as usize;
5139                let expected_impact_entries =
5140                    posting_blocks.saturating_add(posting_blocks.div_ceil(IMPACT_LEVEL1_BLOCKS));
5141                if impact_entries != expected_impact_entries {
5142                    return Err(Error::index(format!(
5143                        "packed posting group impact slot {slot} has {impact_entries} entries, expected {expected_impact_entries} for {posting_blocks} posting blocks"
5144                    )));
5145                }
5146                derived_cache_bytes = derived_cache_bytes.saturating_add(
5147                    ImpactSkipData::derived_cache_bytes_for_entries(impact_entries),
5148                );
5149            }
5150
5151            let states: Arc<[OnceLock<Box<ImpactSkipData>>]> = (0..batch.num_rows())
5152                .map(|_| OnceLock::new())
5153                .collect::<Vec<_>>()
5154                .into();
5155            // Account up front for every allocation that the lazy states can
5156            // eventually retain. The impact entry bytes themselves remain in
5157            // `batch` and are already charged exactly once above.
5158            let per_slot_bytes = std::mem::size_of::<OnceLock<Box<ImpactSkipData>>>()
5159                .saturating_add(std::mem::size_of::<ImpactSkipData>());
5160            let capacity_bytes = states
5161                .len()
5162                .saturating_mul(per_slot_bytes)
5163                .saturating_add(derived_cache_bytes);
5164            (Some(states), capacity_bytes)
5165        } else {
5166            (None, 0)
5167        };
5168        match (
5169            batch.column_by_name(MAX_SCORE_COL),
5170            batch.column_by_name(LENGTH_COL),
5171        ) {
5172            (None, None) => {}
5173            (Some(max_scores), Some(lengths)) => {
5174                let max_scores = max_scores
5175                    .as_primitive_opt::<Float32Type>()
5176                    .ok_or_else(|| {
5177                        Error::index(format!(
5178                            "packed posting group column {MAX_SCORE_COL} must be Float32"
5179                        ))
5180                    })?;
5181                let lengths = lengths.as_primitive_opt::<UInt32Type>().ok_or_else(|| {
5182                    Error::index(format!(
5183                        "packed posting group column {LENGTH_COL} must be UInt32"
5184                    ))
5185                })?;
5186                if max_scores.null_count() != 0 || lengths.null_count() != 0 {
5187                    return Err(Error::index(
5188                        "packed posting group metadata columns must not contain nulls".to_string(),
5189                    ));
5190                }
5191            }
5192            _ => {
5193                return Err(Error::index(format!(
5194                    "packed posting group must contain both {MAX_SCORE_COL} and {LENGTH_COL}, or neither"
5195                )));
5196            }
5197        }
5198
5199        Ok(Self {
5200            storage: PostingListGroupStorage::Packed(PackedPostingListGroup {
5201                batch,
5202                posting_tail_codec,
5203                block_size,
5204                first_docs_states,
5205                first_docs_state_capacity_bytes,
5206                impact_states,
5207                impact_state_capacity_bytes,
5208            }),
5209        })
5210    }
5211
5212    pub(super) fn len(&self) -> usize {
5213        match &self.storage {
5214            PostingListGroupStorage::Packed(group) => group.batch.num_rows(),
5215            PostingListGroupStorage::Materialized(posting_lists) => posting_lists.len(),
5216        }
5217    }
5218
5219    #[cfg(test)]
5220    pub(super) fn is_packed(&self) -> bool {
5221        matches!(&self.storage, PostingListGroupStorage::Packed(_))
5222    }
5223
5224    fn needs_external_metadata(&self) -> bool {
5225        match &self.storage {
5226            PostingListGroupStorage::Packed(group) => {
5227                group.batch.column_by_name(MAX_SCORE_COL).is_none()
5228            }
5229            PostingListGroupStorage::Materialized(_) => false,
5230        }
5231    }
5232
5233    /// Build an owned posting-list view for `slot`. Packed groups clone only
5234    /// Arrow array metadata; the compressed posting bytes remain shared with
5235    /// the group's `List<LargeBinary>` child buffers.
5236    pub(super) fn posting_list(
5237        &self,
5238        slot: usize,
5239        max_score: Option<f32>,
5240        length: Option<u32>,
5241    ) -> Result<Option<PostingList>> {
5242        match &self.storage {
5243            PostingListGroupStorage::Materialized(posting_lists) => {
5244                Ok(posting_lists.get(slot).cloned())
5245            }
5246            PostingListGroupStorage::Packed(group) => {
5247                if slot >= group.batch.num_rows() {
5248                    return Ok(None);
5249                }
5250                let postings = group
5251                    .batch
5252                    .column_by_name(POSTING_COL)
5253                    .and_then(|column| column.as_list_opt::<i32>())
5254                    .ok_or_else(|| {
5255                        Error::index(format!(
5256                            "packed posting group column {POSTING_COL} must be List<LargeBinary>"
5257                        ))
5258                    })?;
5259                let blocks = postings.value(slot);
5260                let blocks = blocks.as_binary_opt::<i64>().ok_or_else(|| {
5261                    Error::index(format!(
5262                        "packed posting group slot {slot} is not LargeBinary"
5263                    ))
5264                })?;
5265                let max_score = match group.batch.column_by_name(MAX_SCORE_COL) {
5266                    Some(column) => column
5267                        .as_primitive_opt::<Float32Type>()
5268                        .expect("packed group metadata was validated at construction")
5269                        .value(slot),
5270                    None => max_score.ok_or_else(|| {
5271                        Error::index("packed posting group requires max-score metadata".to_string())
5272                    })?,
5273                };
5274                let length = match group.batch.column_by_name(LENGTH_COL) {
5275                    Some(column) => column
5276                        .as_primitive_opt::<UInt32Type>()
5277                        .expect("packed group metadata was validated at construction")
5278                        .value(slot),
5279                    None => length.ok_or_else(|| {
5280                        Error::index("packed posting group requires length metadata".to_string())
5281                    })?,
5282                };
5283                let impacts = match (
5284                    group.impact_states.as_ref(),
5285                    group.batch.column_by_name(IMPACT_COL),
5286                ) {
5287                    (Some(states), Some(column)) => {
5288                        let state = states.get(slot).ok_or_else(|| {
5289                            Error::index(format!(
5290                                "packed posting group impact state missing slot {slot}"
5291                            ))
5292                        })?;
5293                        let impact_lists = column.as_list_opt::<i32>().ok_or_else(|| {
5294                            Error::index(format!(
5295                                "packed posting group column {IMPACT_COL} must be List<LargeBinary>"
5296                            ))
5297                        })?;
5298                        let entries = impact_lists.value(slot);
5299                        let entries = entries.as_binary_opt::<i64>().ok_or_else(|| {
5300                            Error::index(format!(
5301                                "packed posting group impact slot {slot} is not LargeBinary"
5302                            ))
5303                        })?;
5304                        let impacts =
5305                            state.get_or_init(|| {
5306                                Box::new(ImpactSkipData::new(entries.clone(), blocks.len()).expect(
5307                                    "packed impact entry count was validated at construction",
5308                                ))
5309                            });
5310                        Some(impacts.as_ref().clone())
5311                    }
5312                    (None, None) => None,
5313                    _ => {
5314                        return Err(Error::internal(
5315                            "packed posting group impact column/state mismatch".to_string(),
5316                        ));
5317                    }
5318                };
5319                Ok(Some(PostingList::Compressed(
5320                    CompressedPostingList::new(
5321                        blocks.clone(),
5322                        max_score,
5323                        length,
5324                        group.posting_tail_codec,
5325                        group.block_size,
5326                        None,
5327                        impacts,
5328                    )
5329                    .with_packed_first_docs(group.first_docs_states.clone(), slot),
5330                )))
5331            }
5332        }
5333    }
5334}
5335
5336#[derive(Debug, Clone, DeepSizeOf)]
5337#[allow(clippy::large_enum_variant)]
5338pub enum PostingList {
5339    Plain(PlainPostingList),
5340    Compressed(CompressedPostingList),
5341}
5342
5343impl PostingList {
5344    pub fn from_batch(
5345        batch: &RecordBatch,
5346        max_score: Option<f32>,
5347        length: Option<u32>,
5348    ) -> Result<Self> {
5349        let posting_tail_codec = parse_posting_tail_codec(batch.schema_ref().metadata())?;
5350        let block_size = parse_posting_block_size(batch.schema_ref().metadata())?;
5351        Self::from_batch_with_tail_codec(batch, max_score, length, posting_tail_codec, block_size)
5352    }
5353
5354    pub fn from_batch_with_tail_codec(
5355        batch: &RecordBatch,
5356        max_score: Option<f32>,
5357        length: Option<u32>,
5358        posting_tail_codec: PostingTailCodec,
5359        block_size: usize,
5360    ) -> Result<Self> {
5361        let positions_layout = if batch.column_by_name(COMPRESSED_POSITION_COL).is_some() {
5362            PositionsLayout::SharedStream(parse_shared_position_codec(
5363                batch.schema_ref().metadata(),
5364            )?)
5365        } else if batch.column_by_name(POSITION_COL).is_some() {
5366            PositionsLayout::LegacyPerDoc
5367        } else {
5368            PositionsLayout::None
5369        };
5370        Self::from_batch_with_tail_codec_and_positions_layout(
5371            batch,
5372            max_score,
5373            length,
5374            posting_tail_codec,
5375            block_size,
5376            positions_layout,
5377        )
5378    }
5379
5380    fn from_batch_with_tail_codec_and_positions_layout(
5381        batch: &RecordBatch,
5382        max_score: Option<f32>,
5383        length: Option<u32>,
5384        posting_tail_codec: PostingTailCodec,
5385        block_size: usize,
5386        positions_layout: PositionsLayout,
5387    ) -> Result<Self> {
5388        match batch.column_by_name(POSTING_COL) {
5389            Some(_) => {
5390                debug_assert!(max_score.is_some() && length.is_some());
5391                let shared_position_codec = match positions_layout {
5392                    PositionsLayout::SharedStream(codec) => Some(codec),
5393                    _ => None,
5394                };
5395                let posting = CompressedPostingList::from_batch(
5396                    batch,
5397                    max_score.unwrap(),
5398                    length.unwrap(),
5399                    posting_tail_codec,
5400                    block_size,
5401                    shared_position_codec,
5402                )?;
5403                Ok(Self::Compressed(posting))
5404            }
5405            None => {
5406                let posting = PlainPostingList::from_batch(batch, max_score);
5407                Ok(Self::Plain(posting))
5408            }
5409        }
5410    }
5411
5412    pub fn iter(&self) -> PostingListIterator<'_> {
5413        PostingListIterator::new(self)
5414    }
5415
5416    pub fn has_position(&self) -> bool {
5417        match self {
5418            Self::Plain(posting) => posting.positions.is_some(),
5419            Self::Compressed(posting) => posting.positions.is_some(),
5420        }
5421    }
5422
5423    pub fn has_impacts(&self) -> bool {
5424        match self {
5425            Self::Plain(_) => false,
5426            Self::Compressed(posting) => posting.impacts.is_some(),
5427        }
5428    }
5429
5430    pub fn set_positions(&mut self, positions: CompressedPositionStorage) {
5431        match self {
5432            Self::Plain(posting) => match positions {
5433                CompressedPositionStorage::LegacyPerDoc(positions) => {
5434                    posting.positions = Some(positions)
5435                }
5436                CompressedPositionStorage::SharedStream(_) => {
5437                    unreachable!("shared position stream is not supported for plain postings")
5438                }
5439            },
5440            Self::Compressed(posting) => {
5441                posting.positions = Some(positions);
5442            }
5443        }
5444    }
5445
5446    pub fn take_positions(&mut self) -> Option<CompressedPositionStorage> {
5447        match self {
5448            Self::Plain(posting) => posting
5449                .positions
5450                .take()
5451                .map(CompressedPositionStorage::LegacyPerDoc),
5452            Self::Compressed(posting) => posting.positions.take(),
5453        }
5454    }
5455
5456    pub fn max_score(&self) -> Option<f32> {
5457        match self {
5458            Self::Plain(posting) => posting.max_score,
5459            Self::Compressed(posting) => Some(posting.max_score),
5460        }
5461    }
5462
5463    pub fn len(&self) -> usize {
5464        match self {
5465            Self::Plain(posting) => posting.len(),
5466            Self::Compressed(posting) => posting.length as usize,
5467        }
5468    }
5469
5470    pub fn is_empty(&self) -> bool {
5471        self.len() == 0
5472    }
5473
5474    pub fn into_builder(self, docs: &DocSet) -> PostingListBuilder {
5475        let posting_tail_codec = match &self {
5476            Self::Plain(_) => PostingTailCodec::Fixed32,
5477            Self::Compressed(posting) => posting.posting_tail_codec,
5478        };
5479        let block_size = match &self {
5480            Self::Plain(_) => LEGACY_BLOCK_SIZE,
5481            Self::Compressed(posting) => posting.block_size,
5482        };
5483        let mut builder = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
5484            self.has_position(),
5485            posting_tail_codec,
5486            block_size,
5487        );
5488        match self {
5489            // legacy format
5490            Self::Plain(posting) => {
5491                // convert the posting list to the new format:
5492                // 1. map row ids to doc ids
5493                // 2. sort the posting list by doc ids
5494                struct Item {
5495                    doc_id: u32,
5496                    positions: PositionRecorder,
5497                }
5498                let doc_ids = docs
5499                    .row_ids
5500                    .iter()
5501                    .enumerate()
5502                    .map(|(doc_id, row_id)| (*row_id, doc_id as u32))
5503                    .collect::<HashMap<_, _>>();
5504                let mut items = Vec::with_capacity(posting.len());
5505                for (row_id, freq, positions) in posting.iter() {
5506                    let freq = freq as u32;
5507                    let positions = match positions {
5508                        Some(positions) => {
5509                            PositionRecorder::Position(positions.collect::<Vec<_>>().into())
5510                        }
5511                        None => PositionRecorder::Count(freq),
5512                    };
5513                    items.push(Item {
5514                        doc_id: doc_ids[&row_id],
5515                        positions,
5516                    });
5517                }
5518                items.sort_unstable_by_key(|item| item.doc_id);
5519                for item in items {
5520                    builder.add(item.doc_id, item.positions);
5521                }
5522            }
5523            Self::Compressed(posting) => {
5524                posting.iter().for_each(|(doc_id, freq, positions)| {
5525                    let positions = match positions {
5526                        Some(positions) => {
5527                            PositionRecorder::Position(positions.collect::<Vec<_>>().into())
5528                        }
5529                        None => PositionRecorder::Count(freq),
5530                    };
5531                    builder.add(doc_id, positions);
5532                });
5533            }
5534        }
5535        builder
5536    }
5537}
5538
5539#[derive(Debug, PartialEq, Clone)]
5540pub struct PlainPostingList {
5541    pub row_ids: ScalarBuffer<u64>,
5542    pub frequencies: ScalarBuffer<f32>,
5543    pub max_score: Option<f32>,
5544    pub positions: Option<ListArray>, // List of Int32
5545}
5546
5547impl DeepSizeOf for PlainPostingList {
5548    fn deep_size_of_children(&self, _context: &mut lance_core::deepsize::Context) -> usize {
5549        self.row_ids.len() * std::mem::size_of::<u64>()
5550            + self.frequencies.len() * std::mem::size_of::<f32>()
5551            + self
5552                .positions
5553                .as_ref()
5554                .map(|positions| sliced_cache_bytes(positions))
5555                .unwrap_or(0)
5556    }
5557}
5558
5559impl PlainPostingList {
5560    pub fn new(
5561        row_ids: ScalarBuffer<u64>,
5562        frequencies: ScalarBuffer<f32>,
5563        max_score: Option<f32>,
5564        positions: Option<ListArray>,
5565    ) -> Self {
5566        Self {
5567            row_ids,
5568            frequencies,
5569            max_score,
5570            positions,
5571        }
5572    }
5573
5574    pub fn from_batch(batch: &RecordBatch, max_score: Option<f32>) -> Self {
5575        let row_ids = batch[ROW_ID].as_primitive::<UInt64Type>().values().clone();
5576        let frequencies = batch[FREQUENCY_COL]
5577            .as_primitive::<Float32Type>()
5578            .values()
5579            .clone();
5580        let positions = batch
5581            .column_by_name(POSITION_COL)
5582            .map(|col| col.as_list::<i32>().clone());
5583
5584        Self::new(row_ids, frequencies, max_score, positions)
5585    }
5586
5587    pub fn len(&self) -> usize {
5588        self.row_ids.len()
5589    }
5590
5591    pub fn is_empty(&self) -> bool {
5592        self.len() == 0
5593    }
5594
5595    pub fn iter(&self) -> PlainPostingListIterator<'_> {
5596        Box::new(
5597            self.row_ids
5598                .iter()
5599                .zip(self.frequencies.iter())
5600                .enumerate()
5601                .map(|(idx, (doc_id, freq))| {
5602                    (
5603                        *doc_id,
5604                        *freq,
5605                        self.positions.as_ref().map(|p| {
5606                            let start = p.value_offsets()[idx] as usize;
5607                            let end = p.value_offsets()[idx + 1] as usize;
5608                            Box::new(
5609                                p.values().as_primitive::<Int32Type>().values()[start..end]
5610                                    .iter()
5611                                    .map(|pos| *pos as u32),
5612                            ) as _
5613                        }),
5614                    )
5615                }),
5616        )
5617    }
5618
5619    #[inline]
5620    pub fn doc(&self, i: usize) -> LocatedDocInfo {
5621        LocatedDocInfo::new(self.row_ids[i], self.frequencies[i])
5622    }
5623
5624    pub fn positions(&self, index: usize) -> Option<Arc<dyn Array>> {
5625        self.positions
5626            .as_ref()
5627            .map(|positions| positions.value(index))
5628    }
5629
5630    pub fn max_score(&self) -> Option<f32> {
5631        self.max_score
5632    }
5633
5634    pub fn row_id(&self, i: usize) -> u64 {
5635        self.row_ids[i]
5636    }
5637}
5638
5639#[derive(Debug, Clone)]
5640enum FirstDocsState {
5641    Standalone(Arc<OnceLock<Box<[u32]>>>),
5642    Packed {
5643        states: Arc<[OnceLock<Box<[u32]>>]>,
5644        slot: usize,
5645    },
5646}
5647
5648impl FirstDocsState {
5649    fn standalone() -> Self {
5650        Self::Standalone(Arc::new(OnceLock::new()))
5651    }
5652
5653    fn state(&self) -> &OnceLock<Box<[u32]>> {
5654        match self {
5655            Self::Standalone(state) => state,
5656            Self::Packed { states, slot } => &states[*slot],
5657        }
5658    }
5659
5660    fn get_or_init(&self, initialize: impl FnOnce() -> Box<[u32]>) -> &[u32] {
5661        self.state().get_or_init(initialize)
5662    }
5663
5664    fn capacity_bytes(
5665        &self,
5666        block_count: usize,
5667        context: &mut lance_core::deepsize::Context,
5668    ) -> usize {
5669        if context.mark_seen(self.state() as *const _ as usize) {
5670            std::mem::size_of::<OnceLock<Box<[u32]>>>()
5671                .saturating_add(block_count.saturating_mul(std::mem::size_of::<u32>()))
5672        } else {
5673            0
5674        }
5675    }
5676
5677    #[cfg(test)]
5678    fn shares_state_with(&self, other: &Self) -> bool {
5679        std::ptr::eq(self.state(), other.state())
5680    }
5681}
5682
5683#[derive(Debug, Clone)]
5684pub struct CompressedPostingList {
5685    pub max_score: f32,
5686    pub length: u32,
5687    // each binary is a block of compressed data
5688    // that contains `block_size` doc ids and then `block_size` frequencies,
5689    // packed by the physical bitpacker matching that block size.
5690    pub blocks: LargeBinaryArray,
5691    pub posting_tail_codec: PostingTailCodec,
5692    pub block_size: usize,
5693    pub positions: Option<CompressedPositionStorage>,
5694    pub(crate) impacts: Option<ImpactSkipData>,
5695    // First doc id per block, baked lazily and shared across per-query clones
5696    // of the cached list. See `block_first_docs`.
5697    first_docs: FirstDocsState,
5698}
5699
5700impl PartialEq for CompressedPostingList {
5701    fn eq(&self, other: &Self) -> bool {
5702        self.max_score == other.max_score
5703            && self.length == other.length
5704            && self.blocks == other.blocks
5705            && self.posting_tail_codec == other.posting_tail_codec
5706            && self.block_size == other.block_size
5707            && self.positions == other.positions
5708            && self.impacts == other.impacts
5709    }
5710}
5711
5712impl DeepSizeOf for CompressedPostingList {
5713    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
5714        sliced_cache_bytes(&self.blocks)
5715            + self
5716                .positions
5717                .as_ref()
5718                .map(|positions| positions.deep_size_of_children(context))
5719                .unwrap_or(0)
5720            + self
5721                .impacts
5722                .as_ref()
5723                .map(|impacts| {
5724                    sliced_cache_bytes(impacts.entries())
5725                        .saturating_add(impacts.derived_cache_bytes())
5726                })
5727                .unwrap_or(0)
5728            + self.first_docs.capacity_bytes(self.blocks.len(), context)
5729    }
5730}
5731
5732impl CompressedPostingList {
5733    pub(crate) fn new(
5734        blocks: LargeBinaryArray,
5735        max_score: f32,
5736        length: u32,
5737        posting_tail_codec: PostingTailCodec,
5738        block_size: usize,
5739        positions: Option<CompressedPositionStorage>,
5740        impacts: Option<ImpactSkipData>,
5741    ) -> Self {
5742        debug_assert!(block_size.is_power_of_two());
5743        Self {
5744            max_score,
5745            length,
5746            blocks,
5747            posting_tail_codec,
5748            block_size,
5749            positions,
5750            impacts,
5751            first_docs: FirstDocsState::standalone(),
5752        }
5753    }
5754
5755    fn with_packed_first_docs(mut self, states: Arc<[OnceLock<Box<[u32]>>]>, slot: usize) -> Self {
5756        debug_assert!(slot < states.len());
5757        self.first_docs = FirstDocsState::Packed { states, slot };
5758        self
5759    }
5760
5761    /// Block sizes are validated powers of two, so per-doc hot loops derive
5762    /// block indices with shift/mask instead of runtime division, which is
5763    /// measurably slower in the iterator advance path.
5764    #[inline]
5765    pub(crate) fn block_shift(&self) -> u32 {
5766        self.block_size.trailing_zeros()
5767    }
5768
5769    #[inline]
5770    pub(crate) fn block_mask(&self) -> usize {
5771        self.block_size - 1
5772    }
5773
5774    pub fn from_batch(
5775        batch: &RecordBatch,
5776        max_score: f32,
5777        length: u32,
5778        posting_tail_codec: PostingTailCodec,
5779        block_size: usize,
5780        shared_position_codec: Option<PositionStreamCodec>,
5781    ) -> Result<Self> {
5782        debug_assert_eq!(batch.num_rows(), 1);
5783        let blocks = batch[POSTING_COL]
5784            .as_list::<i32>()
5785            .value(0)
5786            .as_binary::<i64>()
5787            .clone();
5788        let positions = if let Some(col) = batch.column_by_name(COMPRESSED_POSITION_COL) {
5789            let bytes = bytes::Bytes::from(col.as_binary::<i64>().value(0).to_vec());
5790            let block_offsets = batch[POSITION_BLOCK_OFFSET_COL]
5791                .as_list::<i32>()
5792                .value(0)
5793                .as_primitive::<UInt32Type>()
5794                .values()
5795                .to_vec();
5796            let codec = shared_position_codec.unwrap_or_else(|| {
5797                parse_shared_position_codec(batch.schema_ref().metadata())
5798                    .expect("shared position stream codec metadata should be valid")
5799            });
5800            Some(CompressedPositionStorage::SharedStream(
5801                SharedPositionStream::new(codec, block_offsets, bytes),
5802            ))
5803        } else {
5804            batch.column_by_name(POSITION_COL).map(|col| {
5805                CompressedPositionStorage::LegacyPerDoc(
5806                    col.as_list::<i32>().value(0).as_list::<i32>().clone(),
5807                )
5808            })
5809        };
5810        let impacts = batch
5811            .column_by_name(IMPACT_COL)
5812            .map(|col| {
5813                let entries = col.as_list::<i32>().value(0).as_binary::<i64>().clone();
5814                ImpactSkipData::new(entries, blocks.len())
5815            })
5816            .transpose()?;
5817
5818        Ok(Self {
5819            max_score,
5820            length,
5821            blocks,
5822            posting_tail_codec,
5823            block_size,
5824            positions,
5825            impacts,
5826            first_docs: FirstDocsState::standalone(),
5827        })
5828    }
5829
5830    pub fn iter(&self) -> CompressedPostingListIterator {
5831        CompressedPostingListIterator::new(
5832            self.length as usize,
5833            self.blocks.clone(),
5834            self.posting_tail_codec,
5835            self.positions.clone(),
5836            self.block_size,
5837        )
5838    }
5839
5840    pub fn block_max_score(&self, block_idx: usize) -> f32 {
5841        // 256-document blocks store no per-block max score: their impact
5842        // skip data supplies the tight per-block bound, so callers on that
5843        // path never reach here. Fall back to the list-level max, which is
5844        // still a valid (looser) bound for any block.
5845        if super::encoding::posting_block_score_prefix_len(self.block_size) == 0 {
5846            return self.max_score;
5847        }
5848        let block = self.blocks.value(block_idx);
5849        block[0..4].try_into().map(f32::from_le_bytes).unwrap()
5850    }
5851
5852    #[inline]
5853    pub fn block_least_doc_id(&self, block_idx: usize) -> u32 {
5854        self.block_first_docs()[block_idx]
5855    }
5856
5857    /// First doc id of every block, decoded once per cached list and shared by
5858    /// the per-query clones. Block boundary lookups (window bounds, block
5859    /// binary searches) are hot enough that re-reading the block headers —
5860    /// and re-decoding the tail block — shows up in profiles.
5861    pub(crate) fn block_first_docs(&self) -> &[u32] {
5862        self.first_docs.get_or_init(|| {
5863            (0..self.blocks.len())
5864                .map(|block_idx| {
5865                    let block = self.blocks.value(block_idx);
5866                    let remainder = self.length as usize % self.block_size;
5867                    if block_idx + 1 == self.blocks.len() && remainder > 0 {
5868                        return super::encoding::read_posting_tail_first_doc(
5869                            block,
5870                            self.posting_tail_codec,
5871                            self.block_size,
5872                        );
5873                    }
5874                    let prefix = super::encoding::posting_block_score_prefix_len(self.block_size);
5875                    block[prefix..prefix + 4]
5876                        .try_into()
5877                        .map(u32::from_le_bytes)
5878                        .unwrap()
5879                })
5880                .collect::<Vec<_>>()
5881                .into_boxed_slice()
5882        })
5883    }
5884
5885    #[cfg(test)]
5886    fn shares_first_docs_with(&self, other: &Self) -> bool {
5887        self.first_docs.shares_state_with(&other.first_docs)
5888    }
5889}
5890
5891#[derive(Debug, Clone, PartialEq, Eq, Default)]
5892struct EncodedBlocks {
5893    offsets: Vec<u32>,
5894    bytes: Vec<u8>,
5895}
5896
5897impl EncodedBlocks {
5898    fn len(&self) -> usize {
5899        self.offsets.len()
5900    }
5901
5902    fn size(&self) -> usize {
5903        self.offsets.capacity() * std::mem::size_of::<u32>() + self.bytes.capacity()
5904    }
5905
5906    fn push_full_block(&mut self, doc_ids: &[u32], frequencies: &[u32]) -> Result<usize> {
5907        let start = self.bytes.len();
5908        self.offsets.push(start as u32);
5909        super::encoding::encode_full_posting_block_into(doc_ids, frequencies, &mut self.bytes)?;
5910        Ok(self.bytes.len() - start)
5911    }
5912
5913    fn block(&self, index: usize) -> &[u8] {
5914        let (start, end) = self.block_range(index);
5915        &self.bytes[start..end]
5916    }
5917
5918    fn block_range(&self, index: usize) -> (usize, usize) {
5919        let start = self.offsets[index] as usize;
5920        let end = self
5921            .offsets
5922            .get(index + 1)
5923            .map(|offset| *offset as usize)
5924            .unwrap_or(self.bytes.len());
5925        (start, end)
5926    }
5927
5928    fn set_block_score(&mut self, index: usize, score: f32) {
5929        let (start, _) = self.block_range(index);
5930        self.bytes[start..start + 4].copy_from_slice(&score.to_le_bytes());
5931    }
5932
5933    fn append_remainder_block_with_codec(
5934        &mut self,
5935        doc_ids: &[u32],
5936        frequencies: &[u32],
5937        codec: PostingTailCodec,
5938        block_size: usize,
5939    ) -> Result<()> {
5940        self.offsets.push(self.bytes.len() as u32);
5941        super::encoding::encode_remainder_posting_block_into(
5942            doc_ids,
5943            frequencies,
5944            codec,
5945            block_size,
5946            &mut self.bytes,
5947        )
5948    }
5949
5950    fn into_array(mut self) -> LargeBinaryArray {
5951        let mut offsets = Vec::with_capacity(self.offsets.len() + 1);
5952        offsets.extend(self.offsets.into_iter().map(i64::from));
5953        offsets.push(self.bytes.len() as i64);
5954        LargeBinaryArray::new(
5955            OffsetBuffer::new(ScalarBuffer::from(offsets)),
5956            Buffer::from_vec(std::mem::take(&mut self.bytes)),
5957            None,
5958        )
5959    }
5960
5961    fn iter(&self) -> impl Iterator<Item = &[u8]> {
5962        (0..self.len()).map(|index| self.block(index))
5963    }
5964}
5965
5966#[derive(Debug, Clone, PartialEq, Eq, Default)]
5967struct EncodedPositionBlocks {
5968    offsets: Vec<u32>,
5969    bytes: Vec<u8>,
5970}
5971
5972impl EncodedPositionBlocks {
5973    fn size(&self) -> usize {
5974        self.offsets.capacity() * std::mem::size_of::<u32>() + self.bytes.capacity()
5975    }
5976
5977    fn block(&self, index: usize) -> &[u8] {
5978        let start = self.offsets[index] as usize;
5979        let end = self
5980            .offsets
5981            .get(index + 1)
5982            .map(|offset| *offset as usize)
5983            .unwrap_or(self.bytes.len());
5984        &self.bytes[start..end]
5985    }
5986
5987    fn push_encoded_block(&mut self, block: &[u8]) -> usize {
5988        let start = self.bytes.len();
5989        self.offsets.push(start as u32);
5990        self.bytes.extend_from_slice(block);
5991        self.bytes.len() - start
5992    }
5993
5994    fn into_stream(self) -> SharedPositionStream {
5995        SharedPositionStream::new(
5996            PositionStreamCodec::PackedDelta,
5997            self.offsets,
5998            bytes::Bytes::from(self.bytes),
5999        )
6000    }
6001}
6002
6003#[derive(Debug)]
6004pub struct PostingListBuilder {
6005    with_positions: bool,
6006    posting_tail_codec: PostingTailCodec,
6007    encoded_blocks: Option<Box<EncodedBlocks>>,
6008    encoded_position_blocks: Option<Box<EncodedPositionBlocks>>,
6009    tail_entries: Vec<RawDocInfo>,
6010    tail_positions: PositionBlockBuilder,
6011    open_doc_id: Option<u32>,
6012    open_doc_frequency: u32,
6013    open_doc_last_position: Option<u32>,
6014    block_size: usize,
6015    memory_size_bytes: u32,
6016    len: u32,
6017}
6018
6019pub(super) struct PostingListBatchBuilder {
6020    schema: SchemaRef,
6021    postings: ListBuilder<LargeBinaryBuilder>,
6022    impacts: Option<ListBuilder<LargeBinaryBuilder>>,
6023    max_scores: Float32Builder,
6024    lengths: UInt32Builder,
6025    positions: BatchPositionsBuilder,
6026    len: usize,
6027}
6028
6029enum BatchPositionsBuilder {
6030    None,
6031    Legacy(ListBuilder<ListBuilder<LargeBinaryBuilder>>),
6032    Shared {
6033        bytes: LargeBinaryBuilder,
6034        block_offsets: ListBuilder<UInt32Builder>,
6035    },
6036}
6037
6038struct PostingListParts<'a> {
6039    with_positions: bool,
6040    posting_tail_codec: PostingTailCodec,
6041    block_size: usize,
6042    length: usize,
6043    encoded_blocks: EncodedBlocks,
6044    encoded_position_blocks: EncodedPositionBlocks,
6045    tail_entries: &'a [RawDocInfo],
6046    tail_position_block: Option<Vec<u8>>,
6047}
6048
6049impl PostingListBatchBuilder {
6050    pub fn new(
6051        schema: SchemaRef,
6052        with_positions: bool,
6053        format_version: InvertedListFormatVersion,
6054        capacity: usize,
6055    ) -> Self {
6056        let positions = if !with_positions {
6057            BatchPositionsBuilder::None
6058        } else if format_version.uses_shared_position_stream() {
6059            BatchPositionsBuilder::Shared {
6060                bytes: LargeBinaryBuilder::with_capacity(capacity, 0),
6061                block_offsets: ListBuilder::with_capacity(UInt32Builder::new(), capacity),
6062            }
6063        } else {
6064            BatchPositionsBuilder::Legacy(ListBuilder::with_capacity(
6065                ListBuilder::new(LargeBinaryBuilder::new()),
6066                capacity,
6067            ))
6068        };
6069        let impacts = schema
6070            .field_with_name(IMPACT_COL)
6071            .ok()
6072            .map(|_| ListBuilder::with_capacity(LargeBinaryBuilder::new(), capacity));
6073        Self {
6074            schema,
6075            postings: ListBuilder::with_capacity(LargeBinaryBuilder::new(), capacity),
6076            impacts,
6077            max_scores: Float32Builder::with_capacity(capacity),
6078            lengths: UInt32Builder::with_capacity(capacity),
6079            positions,
6080            len: 0,
6081        }
6082    }
6083
6084    pub fn len(&self) -> usize {
6085        self.len
6086    }
6087
6088    pub fn is_empty(&self) -> bool {
6089        self.len == 0
6090    }
6091
6092    fn append(
6093        &mut self,
6094        compressed: LargeBinaryArray,
6095        impacts: Option<&ImpactSkipData>,
6096        max_score: f32,
6097        length: u32,
6098        positions: Option<&CompressedPositionStorage>,
6099    ) -> Result<()> {
6100        {
6101            let values = self.postings.values();
6102            for index in 0..compressed.len() {
6103                values.append_value(compressed.value(index));
6104            }
6105        }
6106        self.postings.append(true);
6107        if let Some(impacts_builder) = &mut self.impacts {
6108            let impacts = impacts.ok_or_else(|| {
6109                Error::index(format!(
6110                    "impacts builder missing impact data for posting length {}",
6111                    length
6112                ))
6113            })?;
6114            let values = impacts_builder.values();
6115            for index in 0..impacts.entries().len() {
6116                values.append_value(impacts.entries().value(index));
6117            }
6118            impacts_builder.append(true);
6119        }
6120        self.max_scores.append_value(max_score);
6121        self.lengths.append_value(length);
6122
6123        match &mut self.positions {
6124            BatchPositionsBuilder::None => {}
6125            BatchPositionsBuilder::Shared {
6126                bytes,
6127                block_offsets,
6128            } => {
6129                let positions = positions.ok_or_else(|| {
6130                    Error::index(format!(
6131                        "positions builder missing position data for posting length {}",
6132                        length
6133                    ))
6134                })?;
6135                let CompressedPositionStorage::SharedStream(positions) = positions else {
6136                    return Err(Error::index(
6137                        "shared positions builder received legacy positions".to_owned(),
6138                    ));
6139                };
6140                bytes.append_value(positions.bytes());
6141                let offsets_builder = block_offsets.values();
6142                for &offset in positions.block_offsets() {
6143                    offsets_builder.append_value(offset);
6144                }
6145                block_offsets.append(true);
6146            }
6147            BatchPositionsBuilder::Legacy(position_lists) => {
6148                let positions = positions.ok_or_else(|| {
6149                    Error::index(format!(
6150                        "positions builder missing position data for posting length {}",
6151                        length
6152                    ))
6153                })?;
6154                let CompressedPositionStorage::LegacyPerDoc(positions) = positions else {
6155                    return Err(Error::index(
6156                        "legacy positions builder received shared position stream".to_owned(),
6157                    ));
6158                };
6159                let docs_builder = position_lists.values();
6160                for doc_idx in 0..positions.len() {
6161                    let doc_positions = positions.value(doc_idx);
6162                    let compressed_positions = doc_positions.as_binary::<i64>();
6163                    for block_idx in 0..compressed_positions.len() {
6164                        docs_builder
6165                            .values()
6166                            .append_value(compressed_positions.value(block_idx));
6167                    }
6168                    docs_builder.append(true);
6169                }
6170                position_lists.append(true);
6171            }
6172        }
6173
6174        self.len += 1;
6175        Ok(())
6176    }
6177
6178    pub fn finish(&mut self) -> Result<RecordBatch> {
6179        let mut columns = vec![
6180            Arc::new(self.postings.finish()) as ArrayRef,
6181            Arc::new(self.max_scores.finish()) as ArrayRef,
6182            Arc::new(self.lengths.finish()) as ArrayRef,
6183        ];
6184        if let Some(impacts) = &mut self.impacts {
6185            columns.push(Arc::new(impacts.finish()) as ArrayRef);
6186        }
6187        match &mut self.positions {
6188            BatchPositionsBuilder::None => {}
6189            BatchPositionsBuilder::Legacy(position_lists) => {
6190                columns.push(Arc::new(position_lists.finish()) as ArrayRef);
6191            }
6192            BatchPositionsBuilder::Shared {
6193                bytes,
6194                block_offsets,
6195            } => {
6196                columns.push(Arc::new(bytes.finish()) as ArrayRef);
6197                columns.push(Arc::new(block_offsets.finish()) as ArrayRef);
6198            }
6199        }
6200        self.len = 0;
6201        RecordBatch::try_new(self.schema.clone(), columns).map_err(Error::from)
6202    }
6203}
6204
6205impl PostingListBuilder {
6206    pub fn size(&self) -> u64 {
6207        self.memory_size_bytes as u64
6208    }
6209
6210    pub fn has_positions(&self) -> bool {
6211        self.with_positions
6212    }
6213
6214    pub fn new(with_position: bool) -> Self {
6215        Self::new_with_posting_tail_codec_and_block_size(
6216            with_position,
6217            current_fts_format_version().posting_tail_codec(),
6218            LEGACY_BLOCK_SIZE,
6219        )
6220    }
6221
6222    pub fn new_with_posting_tail_codec(
6223        with_position: bool,
6224        posting_tail_codec: PostingTailCodec,
6225    ) -> Self {
6226        Self::new_with_posting_tail_codec_and_block_size(
6227            with_position,
6228            posting_tail_codec,
6229            LEGACY_BLOCK_SIZE,
6230        )
6231    }
6232
6233    pub fn new_with_block_size(with_position: bool, block_size: usize) -> Self {
6234        Self::new_with_posting_tail_codec_and_block_size(
6235            with_position,
6236            current_fts_format_version().posting_tail_codec(),
6237            block_size,
6238        )
6239    }
6240
6241    pub fn new_with_posting_tail_codec_and_block_size(
6242        with_position: bool,
6243        posting_tail_codec: PostingTailCodec,
6244        block_size: usize,
6245    ) -> Self {
6246        validate_block_size(block_size).expect("invalid posting list block size");
6247        Self {
6248            with_positions: with_position,
6249            posting_tail_codec,
6250            encoded_blocks: None,
6251            encoded_position_blocks: None,
6252            tail_entries: Vec::new(),
6253            tail_positions: PositionBlockBuilder::default(),
6254            open_doc_id: None,
6255            open_doc_frequency: 0,
6256            open_doc_last_position: None,
6257            block_size,
6258            len: 0,
6259            memory_size_bytes: 0,
6260        }
6261    }
6262
6263    pub fn len(&self) -> usize {
6264        self.len as usize
6265    }
6266
6267    pub fn is_empty(&self) -> bool {
6268        self.len == 0
6269    }
6270
6271    pub fn iter(&self) -> std::vec::IntoIter<(u32, u32, Option<Vec<u32>>)> {
6272        self.collect_entries().into_iter()
6273    }
6274
6275    pub fn for_each_entry<E>(
6276        &self,
6277        mut visit: impl FnMut(u32, u32, Option<Vec<u32>>) -> std::result::Result<(), E>,
6278    ) -> std::result::Result<(), E> {
6279        let mut doc_ids = Vec::with_capacity(self.block_size);
6280        let mut frequencies = Vec::with_capacity(self.block_size);
6281        let mut decoded_positions = Vec::new();
6282        let mut position_block_index = 0usize;
6283
6284        if let Some(encoded_blocks) = self.encoded_blocks.as_deref() {
6285            for block in encoded_blocks.iter() {
6286                doc_ids.clear();
6287                frequencies.clear();
6288                super::encoding::decode_full_posting_block(
6289                    block,
6290                    &mut doc_ids,
6291                    &mut frequencies,
6292                    self.block_size,
6293                );
6294                decoded_positions.clear();
6295                if self.with_positions {
6296                    let position_blocks = self
6297                        .encoded_position_blocks
6298                        .as_deref()
6299                        .expect("positions must exist for posting list");
6300                    super::encoding::decode_position_stream_block(
6301                        position_blocks.block(position_block_index),
6302                        &frequencies,
6303                        PositionStreamCodec::PackedDelta,
6304                        &mut decoded_positions,
6305                    )
6306                    .expect("position stream decoding should succeed");
6307                    position_block_index += 1;
6308                }
6309                let mut offset = 0usize;
6310                for (doc_id, frequency) in doc_ids.iter().copied().zip(frequencies.iter().copied())
6311                {
6312                    let positions = self.with_positions.then(|| {
6313                        let end = offset + frequency as usize;
6314                        let doc_positions = decoded_positions[offset..end].to_vec();
6315                        offset = end;
6316                        doc_positions
6317                    });
6318                    visit(doc_id, frequency, positions)?;
6319                }
6320            }
6321        }
6322
6323        let mut decoded_tail_positions = Vec::new();
6324        if self.with_positions && !self.tail_entries.is_empty() {
6325            let tail_frequencies = self
6326                .tail_entries
6327                .iter()
6328                .map(|entry| entry.frequency)
6329                .collect::<Vec<_>>();
6330            self.tail_positions
6331                .decode_into(tail_frequencies.as_slice(), &mut decoded_tail_positions)
6332                .expect("tail position stream decoding should succeed");
6333        }
6334        let mut tail_offset = 0usize;
6335        for entry in &self.tail_entries {
6336            let positions = self.with_positions.then(|| {
6337                let end = tail_offset + entry.frequency as usize;
6338                let doc_positions = decoded_tail_positions[tail_offset..end].to_vec();
6339                tail_offset = end;
6340                doc_positions
6341            });
6342            visit(entry.doc_id, entry.frequency, positions)?;
6343        }
6344
6345        Ok(())
6346    }
6347
6348    pub fn add(&mut self, doc_id: u32, term_positions: PositionRecorder) {
6349        debug_assert!(
6350            self.open_doc_id.is_none(),
6351            "cannot add closed doc while a positions doc is still open"
6352        );
6353        let tail_entries_capacity_before = self.tail_entries.capacity();
6354        self.tail_entries
6355            .push(RawDocInfo::new(doc_id, term_positions.len()));
6356        let tail_entries_capacity_after = self.tail_entries.capacity();
6357        if tail_entries_capacity_after > tail_entries_capacity_before {
6358            self.add_memory_bytes(
6359                (tail_entries_capacity_after - tail_entries_capacity_before)
6360                    * std::mem::size_of::<RawDocInfo>(),
6361            );
6362        }
6363        if let PositionRecorder::Position(positions_in_doc) = term_positions {
6364            debug_assert!(self.with_positions);
6365            let old_size = self.tail_positions.size();
6366            self.tail_positions
6367                .append_doc_positions(positions_in_doc.as_slice())
6368                .expect("position stream encoding should succeed");
6369            self.adjust_tail_positions_size(old_size);
6370        }
6371        self.len += 1;
6372
6373        if self.tail_entries.len() == self.block_size {
6374            self.flush_tail_block()
6375                .expect("posting list block compression should succeed");
6376        }
6377    }
6378
6379    pub fn add_occurrence(&mut self, doc_id: u32, position: u32) -> Result<bool> {
6380        if !self.with_positions {
6381            return Err(Error::index(
6382                "cannot append streamed positions to a posting list without positions".to_owned(),
6383            ));
6384        }
6385
6386        match self.open_doc_id {
6387            Some(open_doc_id) if open_doc_id == doc_id => {
6388                let old_size = self.tail_positions.size();
6389                self.tail_positions
6390                    .append_position(position, self.open_doc_last_position)?;
6391                self.adjust_tail_positions_size(old_size);
6392                self.open_doc_frequency += 1;
6393                self.open_doc_last_position = Some(position);
6394                Ok(false)
6395            }
6396            Some(open_doc_id) => Err(Error::index(format!(
6397                "posting list received doc {} before finishing open doc {}",
6398                doc_id, open_doc_id
6399            ))),
6400            None => {
6401                let old_size = self.tail_positions.size();
6402                self.tail_positions.append_position(position, None)?;
6403                self.adjust_tail_positions_size(old_size);
6404                self.open_doc_id = Some(doc_id);
6405                self.open_doc_frequency = 1;
6406                self.open_doc_last_position = Some(position);
6407                self.len += 1;
6408                Ok(true)
6409            }
6410        }
6411    }
6412
6413    pub fn finish_open_doc(&mut self, doc_id: u32) -> Result<()> {
6414        if !self.with_positions {
6415            return Ok(());
6416        }
6417        match self.open_doc_id {
6418            Some(open_doc_id) if open_doc_id == doc_id => {
6419                let tail_entries_capacity_before = self.tail_entries.capacity();
6420                self.tail_entries
6421                    .push(RawDocInfo::new(doc_id, self.open_doc_frequency));
6422                let tail_entries_capacity_after = self.tail_entries.capacity();
6423                if tail_entries_capacity_after > tail_entries_capacity_before {
6424                    self.add_memory_bytes(
6425                        (tail_entries_capacity_after - tail_entries_capacity_before)
6426                            * std::mem::size_of::<RawDocInfo>(),
6427                    );
6428                }
6429                self.open_doc_id = None;
6430                self.open_doc_frequency = 0;
6431                self.open_doc_last_position = None;
6432                if self.tail_entries.len() == self.block_size {
6433                    self.flush_tail_block()?;
6434                }
6435                Ok(())
6436            }
6437            Some(open_doc_id) => Err(Error::index(format!(
6438                "attempted to finish doc {} while doc {} is still open",
6439                doc_id, open_doc_id
6440            ))),
6441            None => Ok(()),
6442        }
6443    }
6444
6445    fn collect_entries(&self) -> Vec<(u32, u32, Option<Vec<u32>>)> {
6446        let mut entries = Vec::with_capacity(self.len());
6447        self.for_each_entry(|doc_id, frequency, positions| {
6448            entries.push((doc_id, frequency, positions));
6449            Ok::<(), ()>(())
6450        })
6451        .expect("collecting posting list entries should not fail");
6452        entries
6453    }
6454
6455    fn encoded_blocks_mut(&mut self) -> &mut EncodedBlocks {
6456        if self.encoded_blocks.is_none() {
6457            self.encoded_blocks = Some(Box::default());
6458            self.add_memory_bytes(std::mem::size_of::<EncodedBlocks>());
6459        }
6460        self.encoded_blocks
6461            .as_deref_mut()
6462            .expect("encoded blocks must exist")
6463    }
6464
6465    fn encoded_position_blocks_mut(&mut self) -> &mut EncodedPositionBlocks {
6466        if self.encoded_position_blocks.is_none() {
6467            self.encoded_position_blocks = Some(Box::default());
6468            self.add_memory_bytes(std::mem::size_of::<EncodedPositionBlocks>());
6469        }
6470        self.encoded_position_blocks
6471            .as_deref_mut()
6472            .expect("encoded position blocks must exist")
6473    }
6474
6475    fn flush_tail_block(&mut self) -> Result<()> {
6476        if self.tail_entries.is_empty() {
6477            return Ok(());
6478        }
6479        debug_assert!(
6480            self.open_doc_id.is_none(),
6481            "cannot flush a posting block while a document is still open"
6482        );
6483        debug_assert_eq!(self.tail_entries.len(), self.block_size);
6484        let doc_ids = self
6485            .tail_entries
6486            .iter()
6487            .map(|entry| entry.doc_id)
6488            .collect::<Vec<_>>();
6489        let frequencies = self
6490            .tail_entries
6491            .iter()
6492            .map(|entry| entry.frequency)
6493            .collect::<Vec<_>>();
6494        let encoded_blocks_size_before = self
6495            .encoded_blocks
6496            .as_ref()
6497            .map(|encoded_blocks| encoded_blocks.size())
6498            .unwrap_or(0usize);
6499        self.encoded_blocks_mut()
6500            .push_full_block(&doc_ids, &frequencies)?;
6501        let encoded_blocks_size_after = self
6502            .encoded_blocks
6503            .as_ref()
6504            .map(|encoded_blocks| encoded_blocks.size())
6505            .unwrap_or(0usize);
6506        if encoded_blocks_size_after > encoded_blocks_size_before {
6507            self.add_memory_bytes(encoded_blocks_size_after - encoded_blocks_size_before);
6508        }
6509        if self.with_positions {
6510            let encoded_positions_size_before = self
6511                .encoded_position_blocks
6512                .as_ref()
6513                .map(|encoded| encoded.size())
6514                .unwrap_or(0usize);
6515            let released_tail_positions_bytes = self.tail_positions.size();
6516            let tail_position_block = std::mem::take(&mut self.tail_positions).finish();
6517            self.encoded_position_blocks_mut()
6518                .push_encoded_block(tail_position_block.as_slice());
6519            let encoded_positions_size_after = self
6520                .encoded_position_blocks
6521                .as_ref()
6522                .map(|encoded| encoded.size())
6523                .unwrap_or(0usize);
6524            if released_tail_positions_bytes > 0 {
6525                self.subtract_memory_bytes(released_tail_positions_bytes);
6526            }
6527            if encoded_positions_size_after > encoded_positions_size_before {
6528                self.add_memory_bytes(encoded_positions_size_after - encoded_positions_size_before);
6529            }
6530        }
6531        self.tail_entries.clear();
6532        Ok(())
6533    }
6534
6535    fn adjust_tail_positions_size(&mut self, old_size: usize) {
6536        let new_size = self.tail_positions.size();
6537        if new_size > old_size {
6538            self.add_memory_bytes(new_size - old_size);
6539        } else if old_size > new_size {
6540            self.subtract_memory_bytes(old_size - new_size);
6541        }
6542    }
6543
6544    fn add_memory_bytes(&mut self, bytes: usize) {
6545        self.memory_size_bytes = self
6546            .memory_size_bytes
6547            .checked_add(
6548                u32::try_from(bytes).expect("posting list memory size delta overflowed u32"),
6549            )
6550            .expect("posting list memory size overflowed u32");
6551    }
6552
6553    fn subtract_memory_bytes(&mut self, bytes: usize) {
6554        self.memory_size_bytes = self
6555            .memory_size_bytes
6556            .checked_sub(
6557                u32::try_from(bytes).expect("posting list memory size delta overflowed u32"),
6558            )
6559            .expect("posting list memory size underflowed u32");
6560    }
6561
6562    fn build_position_columns(
6563        positions: Option<CompressedPositionStorage>,
6564    ) -> Result<Vec<ArrayRef>> {
6565        let Some(positions) = positions else {
6566            return Ok(Vec::new());
6567        };
6568        match positions {
6569            CompressedPositionStorage::LegacyPerDoc(positions) => {
6570                Ok(vec![Arc::new(ListArray::try_new(
6571                    Arc::new(Field::new("item", positions.data_type().clone(), true)),
6572                    OffsetBuffer::new(ScalarBuffer::from(vec![0_i32, positions.len() as i32])),
6573                    Arc::new(positions) as ArrayRef,
6574                    None,
6575                )?) as ArrayRef])
6576            }
6577            CompressedPositionStorage::SharedStream(positions) => {
6578                let mut columns = Vec::with_capacity(2);
6579                columns.push(
6580                    Arc::new(LargeBinaryArray::from(vec![Some(positions.bytes())])) as ArrayRef,
6581                );
6582
6583                let mut offsets_builder = ListBuilder::new(UInt32Builder::new());
6584                for &offset in positions.block_offsets() {
6585                    offsets_builder.values().append_value(offset);
6586                }
6587                offsets_builder.append(true);
6588                columns.push(Arc::new(offsets_builder.finish()) as ArrayRef);
6589                Ok(columns)
6590            }
6591        }
6592    }
6593
6594    fn build_batch(
6595        self,
6596        compressed: LargeBinaryArray,
6597        impacts: Option<ImpactSkipData>,
6598        max_score: f32,
6599        schema: SchemaRef,
6600        positions: Option<CompressedPositionStorage>,
6601    ) -> Result<RecordBatch> {
6602        let length = self.len();
6603        let offsets = OffsetBuffer::new(ScalarBuffer::from(vec![0, compressed.len() as i32]));
6604        let mut columns = vec![
6605            Arc::new(ListArray::try_new(
6606                Arc::new(Field::new("item", datatypes::DataType::LargeBinary, true)),
6607                offsets,
6608                Arc::new(compressed),
6609                None,
6610            )?) as ArrayRef,
6611            Arc::new(Float32Array::from_iter_values(std::iter::once(max_score))) as ArrayRef,
6612            Arc::new(UInt32Array::from_iter_values(std::iter::once(
6613                length as u32,
6614            ))) as ArrayRef,
6615        ];
6616        if schema.field_with_name(IMPACT_COL).is_ok() {
6617            let impacts = impacts.ok_or_else(|| {
6618                Error::index(format!(
6619                    "impact column requested without impact data for posting length {}",
6620                    length
6621                ))
6622            })?;
6623            let impact_offsets =
6624                OffsetBuffer::new(ScalarBuffer::from(vec![0, impacts.entries().len() as i32]));
6625            columns.push(Arc::new(ListArray::try_new(
6626                Arc::new(Field::new("item", datatypes::DataType::LargeBinary, true)),
6627                impact_offsets,
6628                Arc::new(impacts.entries().clone()),
6629                None,
6630            )?) as ArrayRef);
6631        }
6632        columns.extend(Self::build_position_columns(positions)?);
6633
6634        let batch = RecordBatch::try_new(schema, columns)?;
6635        Ok(batch)
6636    }
6637
6638    fn build_legacy_positions(&self) -> Result<ListArray> {
6639        let mut positions_builder = ListBuilder::new(LargeBinaryBuilder::new());
6640        self.for_each_entry(|_doc_id, frequency, positions| {
6641            let positions = positions.ok_or_else(|| {
6642                Error::index(format!(
6643                    "legacy position writer missing positions for frequency {}",
6644                    frequency
6645                ))
6646            })?;
6647            let compressed = super::encoding::compress_positions(positions.as_slice())?;
6648            for block_idx in 0..compressed.len() {
6649                positions_builder
6650                    .values()
6651                    .append_value(compressed.value(block_idx));
6652            }
6653            positions_builder.append(true);
6654            Ok::<(), Error>(())
6655        })?;
6656        Ok(positions_builder.finish())
6657    }
6658
6659    pub(super) fn append_to_batch_with_docs(
6660        self,
6661        docs: &DocSet,
6662        batch_builder: &mut PostingListBatchBuilder,
6663        format_version: InvertedListFormatVersion,
6664    ) -> Result<()> {
6665        let legacy_positions =
6666            if self.with_positions && !format_version.uses_shared_position_stream() {
6667                Some(self.build_legacy_positions()?)
6668            } else {
6669                None
6670            };
6671        let Self {
6672            with_positions,
6673            posting_tail_codec,
6674            encoded_blocks,
6675            encoded_position_blocks,
6676            tail_entries,
6677            tail_positions,
6678            open_doc_id,
6679            open_doc_frequency,
6680            open_doc_last_position,
6681            block_size,
6682            len,
6683            ..
6684        } = self;
6685        debug_assert!(open_doc_id.is_none());
6686        debug_assert_eq!(open_doc_frequency, 0);
6687        debug_assert!(open_doc_last_position.is_none());
6688        let parts = PostingListParts {
6689            with_positions,
6690            posting_tail_codec,
6691            block_size,
6692            length: len as usize,
6693            encoded_blocks: encoded_blocks
6694                .map(|encoded_blocks| *encoded_blocks)
6695                .unwrap_or_default(),
6696            encoded_position_blocks: encoded_position_blocks
6697                .map(|encoded_positions| *encoded_positions)
6698                .unwrap_or_default(),
6699            tail_entries: tail_entries.as_slice(),
6700            tail_position_block: with_positions.then(|| tail_positions.finish()),
6701        };
6702        let (compressed, shared_positions, max_score, impacts) =
6703            Self::build_compressed_with_scores_from_parts(parts, docs)?;
6704        let positions = match legacy_positions {
6705            Some(positions) => Some(CompressedPositionStorage::LegacyPerDoc(positions)),
6706            None => shared_positions.map(CompressedPositionStorage::SharedStream),
6707        };
6708        batch_builder.append(
6709            compressed,
6710            Some(&impacts),
6711            max_score,
6712            len,
6713            positions.as_ref(),
6714        )
6715    }
6716
6717    fn extend_tail_components(
6718        tail_entries: &[RawDocInfo],
6719        doc_ids: &mut Vec<u32>,
6720        frequencies: &mut Vec<u32>,
6721    ) {
6722        doc_ids.clear();
6723        frequencies.clear();
6724        doc_ids.extend(tail_entries.iter().map(|entry| entry.doc_id));
6725        frequencies.extend(tail_entries.iter().map(|entry| entry.frequency));
6726    }
6727
6728    fn build_compressed_with_scores_from_parts(
6729        parts: PostingListParts<'_>,
6730        docs: &DocSet,
6731    ) -> Result<(
6732        LargeBinaryArray,
6733        Option<SharedPositionStream>,
6734        f32,
6735        ImpactSkipData,
6736    )> {
6737        let PostingListParts {
6738            with_positions,
6739            posting_tail_codec,
6740            length,
6741            block_size,
6742            mut encoded_blocks,
6743            mut encoded_position_blocks,
6744            tail_entries,
6745            tail_position_block,
6746        } = parts;
6747        let avgdl = docs.average_length();
6748        let idf_scale = idf(length, docs.len()) * (K1 + 1.0);
6749        let mut max_score = f32::MIN;
6750        let mut doc_ids = Vec::with_capacity(block_size);
6751        let mut frequencies = Vec::with_capacity(block_size);
6752        let mut impact_block = Vec::with_capacity(block_size);
6753        let mut impact_builder =
6754            ImpactSkipDataBuilder::with_capacity(length.div_ceil(block_size), block_size);
6755
6756        for index in 0..encoded_blocks.len() {
6757            let block = encoded_blocks.block(index);
6758            doc_ids.clear();
6759            frequencies.clear();
6760            super::encoding::decode_full_posting_block(
6761                block,
6762                &mut doc_ids,
6763                &mut frequencies,
6764                block_size,
6765            );
6766            let block_score = compute_block_score_and_impact_block(
6767                docs,
6768                avgdl,
6769                idf_scale,
6770                doc_ids.iter().copied(),
6771                frequencies.iter().copied(),
6772                &mut impact_block,
6773            );
6774            impact_builder.append_block(impact_block.as_slice())?;
6775            max_score = max_score.max(block_score);
6776            if super::encoding::posting_block_score_prefix_len(block_size) > 0 {
6777                encoded_blocks.set_block_score(index, block_score);
6778            }
6779        }
6780
6781        if !tail_entries.is_empty() {
6782            Self::extend_tail_components(tail_entries, &mut doc_ids, &mut frequencies);
6783            let block_score = compute_block_score_and_impact_block(
6784                docs,
6785                avgdl,
6786                idf_scale,
6787                doc_ids.iter().copied(),
6788                frequencies.iter().copied(),
6789                &mut impact_block,
6790            );
6791            impact_builder.append_block(impact_block.as_slice())?;
6792            max_score = max_score.max(block_score);
6793            encoded_blocks.append_remainder_block_with_codec(
6794                doc_ids.as_slice(),
6795                frequencies.as_slice(),
6796                posting_tail_codec,
6797                block_size,
6798            )?;
6799            if super::encoding::posting_block_score_prefix_len(block_size) > 0 {
6800                encoded_blocks.set_block_score(encoded_blocks.len() - 1, block_score);
6801            }
6802            if with_positions {
6803                encoded_position_blocks.push_encoded_block(
6804                    tail_position_block
6805                        .as_deref()
6806                        .expect("tail position block must exist for postings with positions"),
6807                );
6808            }
6809        }
6810
6811        let impacts = impact_builder.finish()?;
6812        Ok((
6813            encoded_blocks.into_array(),
6814            with_positions.then(|| encoded_position_blocks.into_stream()),
6815            max_score,
6816            impacts,
6817        ))
6818    }
6819
6820    #[allow(clippy::too_many_arguments)]
6821    fn build_compressed_with_block_scores_from_parts(
6822        with_positions: bool,
6823        posting_tail_codec: PostingTailCodec,
6824        block_size: usize,
6825        mut encoded_blocks: EncodedBlocks,
6826        mut encoded_position_blocks: EncodedPositionBlocks,
6827        tail_entries: &[RawDocInfo],
6828        tail_position_block: Option<Vec<u8>>,
6829        mut block_max_scores: impl Iterator<Item = f32>,
6830    ) -> Result<(LargeBinaryArray, Option<SharedPositionStream>, f32)> {
6831        let has_score_prefix = super::encoding::posting_block_score_prefix_len(block_size) > 0;
6832        let mut max_score = f32::MIN;
6833        let mut doc_ids = Vec::with_capacity(BLOCK_SIZE);
6834        let mut frequencies = Vec::with_capacity(BLOCK_SIZE);
6835
6836        for index in 0..encoded_blocks.len() {
6837            let block_score = block_max_scores
6838                .next()
6839                .ok_or_else(|| Error::index("missing block max score".to_owned()))?;
6840            max_score = max_score.max(block_score);
6841            if has_score_prefix {
6842                encoded_blocks.set_block_score(index, block_score);
6843            }
6844        }
6845
6846        if !tail_entries.is_empty() {
6847            let block_score = block_max_scores
6848                .next()
6849                .ok_or_else(|| Error::index("missing tail block max score".to_owned()))?;
6850            max_score = max_score.max(block_score);
6851            Self::extend_tail_components(tail_entries, &mut doc_ids, &mut frequencies);
6852            encoded_blocks.append_remainder_block_with_codec(
6853                doc_ids.as_slice(),
6854                frequencies.as_slice(),
6855                posting_tail_codec,
6856                block_size,
6857            )?;
6858            if has_score_prefix {
6859                encoded_blocks.set_block_score(encoded_blocks.len() - 1, block_score);
6860            }
6861            if with_positions {
6862                encoded_position_blocks.push_encoded_block(
6863                    tail_position_block
6864                        .as_deref()
6865                        .expect("tail position block must exist for postings with positions"),
6866                );
6867            }
6868        }
6869
6870        Ok((
6871            encoded_blocks.into_array(),
6872            with_positions.then(|| encoded_position_blocks.into_stream()),
6873            max_score,
6874        ))
6875    }
6876
6877    pub fn to_batch(self, block_max_scores: Vec<f32>) -> Result<RecordBatch> {
6878        let format_version = InvertedListFormatVersion::from_posting_tail_codec_and_block_size(
6879            self.posting_tail_codec,
6880            self.block_size,
6881        )?;
6882        let schema = inverted_list_schema_for_version_with_block_size_and_impacts(
6883            self.has_positions(),
6884            format_version,
6885            self.block_size,
6886            false,
6887        );
6888        let legacy_positions =
6889            if self.with_positions && !format_version.uses_shared_position_stream() {
6890                Some(self.build_legacy_positions()?)
6891            } else {
6892                None
6893            };
6894        let Self {
6895            with_positions,
6896            posting_tail_codec,
6897            encoded_blocks,
6898            encoded_position_blocks,
6899            tail_entries,
6900            tail_positions,
6901            open_doc_id,
6902            open_doc_frequency,
6903            open_doc_last_position,
6904            block_size,
6905            len,
6906            ..
6907        } = self;
6908        debug_assert!(open_doc_id.is_none());
6909        debug_assert_eq!(open_doc_frequency, 0);
6910        debug_assert!(open_doc_last_position.is_none());
6911        let (compressed, shared_positions, max_score) =
6912            Self::build_compressed_with_block_scores_from_parts(
6913                with_positions,
6914                posting_tail_codec,
6915                block_size,
6916                encoded_blocks
6917                    .map(|encoded_blocks| *encoded_blocks)
6918                    .unwrap_or_default(),
6919                encoded_position_blocks
6920                    .map(|encoded_positions| *encoded_positions)
6921                    .unwrap_or_default(),
6922                tail_entries.as_slice(),
6923                with_positions.then(|| tail_positions.finish()),
6924                block_max_scores.into_iter(),
6925            )?;
6926        let builder = Self {
6927            with_positions,
6928            posting_tail_codec,
6929            encoded_blocks: None,
6930            encoded_position_blocks: None,
6931            tail_entries: Vec::new(),
6932            tail_positions: PositionBlockBuilder::default(),
6933            open_doc_id: None,
6934            open_doc_frequency: 0,
6935            open_doc_last_position: None,
6936            block_size,
6937            memory_size_bytes: 0,
6938            len,
6939        };
6940        let positions = match legacy_positions {
6941            Some(positions) => Some(CompressedPositionStorage::LegacyPerDoc(positions)),
6942            None => shared_positions.map(CompressedPositionStorage::SharedStream),
6943        };
6944        builder.build_batch(compressed, None, max_score, schema, positions)
6945    }
6946
6947    pub fn to_batch_with_docs(self, docs: &DocSet, schema: SchemaRef) -> Result<RecordBatch> {
6948        let format_version = parse_format_version_from_metadata(schema.metadata())?;
6949        let legacy_positions =
6950            if self.with_positions && !format_version.uses_shared_position_stream() {
6951                Some(self.build_legacy_positions()?)
6952            } else {
6953                None
6954            };
6955        let Self {
6956            with_positions,
6957            posting_tail_codec,
6958            encoded_blocks,
6959            encoded_position_blocks,
6960            tail_entries,
6961            tail_positions,
6962            open_doc_id,
6963            open_doc_frequency,
6964            open_doc_last_position,
6965            block_size,
6966            len,
6967            ..
6968        } = self;
6969        debug_assert!(open_doc_id.is_none());
6970        debug_assert_eq!(open_doc_frequency, 0);
6971        debug_assert!(open_doc_last_position.is_none());
6972        let parts = PostingListParts {
6973            with_positions,
6974            posting_tail_codec,
6975            block_size,
6976            length: len as usize,
6977            encoded_blocks: encoded_blocks
6978                .map(|encoded_blocks| *encoded_blocks)
6979                .unwrap_or_default(),
6980            encoded_position_blocks: encoded_position_blocks
6981                .map(|encoded_positions| *encoded_positions)
6982                .unwrap_or_default(),
6983            tail_entries: tail_entries.as_slice(),
6984            tail_position_block: with_positions.then(|| tail_positions.finish()),
6985        };
6986        let (compressed, shared_positions, max_score, impacts) =
6987            Self::build_compressed_with_scores_from_parts(parts, docs)?;
6988        let builder = Self {
6989            with_positions,
6990            posting_tail_codec,
6991            encoded_blocks: None,
6992            encoded_position_blocks: None,
6993            tail_entries: Vec::new(),
6994            tail_positions: PositionBlockBuilder::default(),
6995            open_doc_id: None,
6996            open_doc_frequency: 0,
6997            open_doc_last_position: None,
6998            block_size,
6999            memory_size_bytes: 0,
7000            len,
7001        };
7002        let positions = match legacy_positions {
7003            Some(positions) => Some(CompressedPositionStorage::LegacyPerDoc(positions)),
7004            None => shared_positions.map(CompressedPositionStorage::SharedStream),
7005        };
7006        builder.build_batch(compressed, Some(impacts), max_score, schema, positions)
7007    }
7008
7009    pub fn remap(&mut self, removed: &[u32]) {
7010        let mut cursor = 0;
7011        let mut new_builder = Self::new_with_posting_tail_codec_and_block_size(
7012            self.has_positions(),
7013            self.posting_tail_codec,
7014            self.block_size,
7015        );
7016        for (doc_id, freq, positions) in self.iter() {
7017            while cursor < removed.len() && removed[cursor] < doc_id {
7018                cursor += 1;
7019            }
7020            if cursor < removed.len() && removed[cursor] == doc_id {
7021                continue;
7022            }
7023            let positions = match positions {
7024                Some(positions) => PositionRecorder::Position(positions.into()),
7025                None => PositionRecorder::Count(freq),
7026            };
7027            new_builder.add(doc_id - cursor as u32, positions);
7028        }
7029
7030        *self = new_builder;
7031    }
7032}
7033
7034fn compute_block_score_and_impact_block(
7035    docs: &DocSet,
7036    avgdl: f32,
7037    idf_scale: f32,
7038    doc_ids: impl Iterator<Item = u32>,
7039    frequencies: impl Iterator<Item = u32>,
7040    impact_block: &mut Vec<(u32, u32, u32)>,
7041) -> f32 {
7042    impact_block.clear();
7043    let mut block_max_score = f32::MIN;
7044    for (doc_id, freq) in doc_ids.zip(frequencies) {
7045        let doc_len = docs.num_tokens(doc_id);
7046        let doc_norm = K1 * (1.0 - B + B * doc_len as f32 / avgdl);
7047        let freq_f32 = freq as f32;
7048        let score = freq_f32 / (freq_f32 + doc_norm);
7049        block_max_score = block_max_score.max(score);
7050        impact_block.push((doc_id, freq, doc_len));
7051    }
7052    block_max_score * idf_scale
7053}
7054
7055#[derive(Debug, Clone, DeepSizeOf, Copy)]
7056pub enum DocInfo {
7057    Located(LocatedDocInfo),
7058    Raw(RawDocInfo),
7059}
7060
7061impl DocInfo {
7062    pub fn doc_id(&self) -> u64 {
7063        match self {
7064            Self::Raw(info) => info.doc_id as u64,
7065            Self::Located(info) => info.row_id,
7066        }
7067    }
7068
7069    pub fn frequency(&self) -> u32 {
7070        match self {
7071            Self::Raw(info) => info.frequency,
7072            Self::Located(info) => info.frequency as u32,
7073        }
7074    }
7075}
7076
7077impl Eq for DocInfo {}
7078
7079impl PartialEq for DocInfo {
7080    fn eq(&self, other: &Self) -> bool {
7081        self.doc_id() == other.doc_id()
7082    }
7083}
7084
7085impl PartialOrd for DocInfo {
7086    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
7087        Some(self.cmp(other))
7088    }
7089}
7090
7091impl Ord for DocInfo {
7092    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
7093        self.doc_id().cmp(&other.doc_id())
7094    }
7095}
7096
7097#[derive(Debug, Clone, Default, DeepSizeOf, Copy)]
7098pub struct LocatedDocInfo {
7099    pub row_id: u64,
7100    pub frequency: f32,
7101}
7102
7103impl LocatedDocInfo {
7104    pub fn new(row_id: u64, frequency: f32) -> Self {
7105        Self { row_id, frequency }
7106    }
7107}
7108
7109impl Eq for LocatedDocInfo {}
7110
7111impl PartialEq for LocatedDocInfo {
7112    fn eq(&self, other: &Self) -> bool {
7113        self.row_id == other.row_id
7114    }
7115}
7116
7117impl PartialOrd for LocatedDocInfo {
7118    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
7119        Some(self.cmp(other))
7120    }
7121}
7122
7123impl Ord for LocatedDocInfo {
7124    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
7125        self.row_id.cmp(&other.row_id)
7126    }
7127}
7128
7129#[derive(Debug, Clone, Default, DeepSizeOf, Copy)]
7130pub struct RawDocInfo {
7131    pub doc_id: u32,
7132    pub frequency: u32,
7133}
7134
7135impl RawDocInfo {
7136    pub fn new(doc_id: u32, frequency: u32) -> Self {
7137        Self { doc_id, frequency }
7138    }
7139}
7140
7141impl Eq for RawDocInfo {}
7142
7143impl PartialEq for RawDocInfo {
7144    fn eq(&self, other: &Self) -> bool {
7145        self.doc_id == other.doc_id
7146    }
7147}
7148
7149impl PartialOrd for RawDocInfo {
7150    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
7151        Some(self.cmp(other))
7152    }
7153}
7154
7155impl Ord for RawDocInfo {
7156    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
7157        self.doc_id.cmp(&other.doc_id)
7158    }
7159}
7160
7161/// Lucene SmallFloat-style document-length quantization for 256-document-block scoring and impact
7162/// norms: a 4-mantissa-bit float-like byte code. Values 0-7 are exact; larger
7163/// values keep their top four significand bits (relative error <= 6.25%) and
7164/// decode to their bucket floor. The floor only ever shortens a doc, so impact
7165/// bounds remain conservative for exact scoring as well as quantized scoring.
7166pub(super) fn quantize_doc_length(value: u32) -> u8 {
7167    let num_bits = 32 - value.leading_zeros();
7168    if num_bits < 4 {
7169        value as u8
7170    } else {
7171        let shift = num_bits - 4;
7172        (((value >> shift) as u8) & 0x07) | (((shift + 1) as u8) << 3)
7173    }
7174}
7175
7176#[inline]
7177pub(super) fn dequantize_doc_length(code: u8) -> u32 {
7178    DEQUANTIZED_DOC_LENGTHS[code as usize]
7179}
7180
7181pub(super) static DEQUANTIZED_DOC_LENGTHS: [u32; 256] = build_dequantized_doc_lengths();
7182
7183const fn build_dequantized_doc_lengths() -> [u32; 256] {
7184    let mut table = [0u32; 256];
7185    let mut code = 0usize;
7186    while code < 256 {
7187        let bits = (code & 0x07) as u64;
7188        let shift = (code >> 3) as i64 - 1;
7189        let decoded = if shift < 0 {
7190            bits
7191        } else {
7192            (bits | 0x08) << shift
7193        };
7194        // Codes past the largest u32 encoding are never produced; saturate so
7195        // the table stays total.
7196        table[code] = if decoded > u32::MAX as u64 {
7197            u32::MAX
7198        } else {
7199            decoded as u32
7200        };
7201        code += 1;
7202    }
7203    table
7204}
7205
7206#[derive(Debug, Clone)]
7207enum NumTokens {
7208    Owned(Vec<u32>),
7209    Shared(ScalarBuffer<u32>),
7210}
7211
7212impl Default for NumTokens {
7213    fn default() -> Self {
7214        Self::Owned(Vec::new())
7215    }
7216}
7217
7218impl std::ops::Deref for NumTokens {
7219    type Target = [u32];
7220
7221    fn deref(&self) -> &Self::Target {
7222        match self {
7223            Self::Owned(values) => values,
7224            Self::Shared(values) => values,
7225        }
7226    }
7227}
7228
7229impl DeepSizeOf for NumTokens {
7230    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
7231        match self {
7232            Self::Owned(values) => values.deep_size_of_children(context),
7233            Self::Shared(values) => values.deep_size_of_children(context),
7234        }
7235    }
7236}
7237
7238impl NumTokens {
7239    fn with_capacity(capacity: usize) -> Self {
7240        Self::Owned(Vec::with_capacity(capacity))
7241    }
7242
7243    fn into_owned(self) -> Vec<u32> {
7244        match self {
7245            Self::Owned(values) => values,
7246            Self::Shared(values) => values.to_vec(),
7247        }
7248    }
7249
7250    fn push(&mut self, value: u32) {
7251        match self {
7252            Self::Owned(values) => values.push(value),
7253            Self::Shared(values) => {
7254                let mut owned = values.to_vec();
7255                owned.push(value);
7256                *self = Self::Owned(owned);
7257            }
7258        }
7259    }
7260
7261    fn memory_size(&self) -> usize {
7262        match self {
7263            Self::Owned(values) => values.capacity() * std::mem::size_of::<u32>(),
7264            Self::Shared(values) => values.inner().capacity(),
7265        }
7266    }
7267}
7268
7269// DocSet is a mapping from row ids to the number of tokens in the document
7270// It's used to sort the documents by the bm25 score
7271#[derive(Debug, Clone, Default)]
7272pub struct DocSet {
7273    row_ids: Vec<u64>,
7274    num_tokens: NumTokens,
7275    // (row_id, doc_id) pairs sorted by row_id
7276    inv: Vec<(u64, u32)>,
7277
7278    total_tokens: u64,
7279
7280    // 256-document-block partitions score with quantized document lengths: the
7281    // flag is set at partition load and the byte-norm slab bakes lazily on
7282    // first scoring use (shared by clones of the loaded set). 128-block
7283    // partitions never set the flag and keep exact scoring.
7284    scoring_quantized: bool,
7285    norms: Arc<std::sync::OnceLock<Box<[u8]>>>,
7286}
7287
7288impl DeepSizeOf for DocSet {
7289    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
7290        self.row_ids.deep_size_of_children(context)
7291            + self.num_tokens.deep_size_of_children(context)
7292            + self.inv.deep_size_of_children(context)
7293            + self
7294                .norms
7295                .get()
7296                .map(|slab| std::mem::size_of_val(slab.as_ref()))
7297                .unwrap_or(0)
7298    }
7299}
7300
7301impl DocSet {
7302    #[inline]
7303    pub fn len(&self) -> usize {
7304        // Use num_tokens instead of row_ids so the deferred-row_ids
7305        // scoring path (which constructs a DocSet via
7306        // [`Self::from_num_tokens_only`]) still reports the right doc
7307        // count.
7308        self.num_tokens.len()
7309    }
7310
7311    pub fn is_empty(&self) -> bool {
7312        self.len() == 0
7313    }
7314
7315    /// True iff the per-doc `row_id` array is populated. The
7316    /// deferred-row_id scoring path constructs DocSets with the array
7317    /// left empty so wand can skip the load; callers that need to do
7318    /// row_id lookups in the inner loop must check this and fall back
7319    /// to async resolution otherwise.
7320    #[inline]
7321    pub fn has_row_ids(&self) -> bool {
7322        !self.row_ids.is_empty()
7323    }
7324
7325    pub fn iter(&self) -> impl Iterator<Item = (&u64, &u32)> {
7326        self.row_ids.iter().zip(self.num_tokens.iter())
7327    }
7328
7329    pub fn row_id(&self, doc_id: u32) -> u64 {
7330        self.row_ids[doc_id as usize]
7331    }
7332
7333    /// Resolve a `row_id` to every `doc_id` it owns.
7334    ///
7335    /// Modern indexes map each row to a single document. Older list indexes
7336    /// may have indexed each list element as its own document, so a single
7337    /// `row_id` can still own several `doc_id`s sharing that key in `inv`.
7338    /// The prefilter path (`flat_search`) walks an allow-list of row_ids and
7339    /// must evaluate all legacy documents for that row.
7340    pub fn doc_ids(&self, row_id: u64) -> impl Iterator<Item = u64> + '_ {
7341        if self.inv.is_empty() {
7342            // in legacy format, the row id is doc id (one document per row)
7343            let found = self.row_ids.binary_search(&row_id).is_ok();
7344            Either::Left(found.then_some(row_id).into_iter())
7345        } else {
7346            // `inv` is sorted by row_id, so the entries sharing this key form a
7347            // contiguous run; yield the doc_id of each.
7348            let lo = self.inv.partition_point(|entry| entry.0 < row_id);
7349            let hi = self.inv.partition_point(|entry| entry.0 <= row_id);
7350            Either::Right(self.inv[lo..hi].iter().map(|entry| entry.1 as u64))
7351        }
7352    }
7353    pub fn total_tokens_num(&self) -> u64 {
7354        self.total_tokens
7355    }
7356
7357    #[inline]
7358    pub fn average_length(&self) -> f32 {
7359        self.total_tokens as f32 / self.len() as f32
7360    }
7361
7362    pub fn calculate_block_max_scores<'a>(
7363        &self,
7364        doc_ids: impl Iterator<Item = &'a u32>,
7365        freqs: impl Iterator<Item = &'a u32>,
7366    ) -> Vec<f32> {
7367        self.calculate_block_max_scores_with_block_size(doc_ids, freqs, LEGACY_BLOCK_SIZE)
7368    }
7369
7370    pub fn calculate_block_max_scores_with_block_size<'a>(
7371        &self,
7372        doc_ids: impl Iterator<Item = &'a u32>,
7373        freqs: impl Iterator<Item = &'a u32>,
7374        block_size: usize,
7375    ) -> Vec<f32> {
7376        validate_block_size(block_size).expect("invalid posting list block size");
7377        let avgdl = self.average_length();
7378        let length = doc_ids.size_hint().0;
7379        let num_blocks = length.div_ceil(block_size);
7380        let mut block_max_scores = Vec::with_capacity(num_blocks);
7381        let idf_scale = idf(length, self.len()) * (K1 + 1.0);
7382        let mut max_score = f32::MIN;
7383        for (i, (doc_id, freq)) in doc_ids.zip(freqs).enumerate() {
7384            let doc_norm = K1 * (1.0 - B + B * self.num_tokens(*doc_id) as f32 / avgdl);
7385            let freq = *freq as f32;
7386            let score = freq / (freq + doc_norm);
7387            if score > max_score {
7388                max_score = score;
7389            }
7390            if (i + 1) % block_size == 0 {
7391                max_score *= idf_scale;
7392                block_max_scores.push(max_score);
7393                max_score = f32::MIN;
7394            }
7395        }
7396        if !length.is_multiple_of(block_size) {
7397            max_score *= idf_scale;
7398            block_max_scores.push(max_score);
7399        }
7400        block_max_scores
7401    }
7402
7403    pub fn to_batch(&self) -> Result<RecordBatch> {
7404        let row_id_col = UInt64Array::from_iter_values(self.row_ids.iter().cloned());
7405        let num_tokens_col = UInt32Array::from_iter_values(self.num_tokens.iter().cloned());
7406
7407        let schema = arrow_schema::Schema::new(vec![
7408            arrow_schema::Field::new(ROW_ID, DataType::UInt64, false),
7409            arrow_schema::Field::new(NUM_TOKEN_COL, DataType::UInt32, false),
7410        ]);
7411
7412        let batch = RecordBatch::try_new(
7413            Arc::new(schema),
7414            vec![
7415                Arc::new(row_id_col) as ArrayRef,
7416                Arc::new(num_tokens_col) as ArrayRef,
7417            ],
7418        )?;
7419        Ok(batch)
7420    }
7421
7422    pub async fn load(
7423        reader: Arc<dyn IndexReader>,
7424        is_legacy: bool,
7425        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
7426    ) -> Result<Self> {
7427        let batch = reader.read_range(0..reader.num_rows(), None).await?;
7428        let row_id_col = batch[ROW_ID].as_primitive::<datatypes::UInt64Type>();
7429        let num_tokens_col = batch[NUM_TOKEN_COL].as_primitive::<datatypes::UInt32Type>();
7430        Self::from_columns(row_id_col, num_tokens_col, is_legacy, frag_reuse_index)
7431    }
7432
7433    /// Build a `DocSet` carrying only the per-doc `num_tokens` array;
7434    /// `row_ids` and `inv` are left empty. Used by the deferred-row_id
7435    /// scoring path: wand checks `has_row_ids()` to skip `row_id` /
7436    /// `num_tokens_by_row_id` calls, and the per-partition caller
7437    /// resolves doc_id → row_id for the surviving top-K post-wand.
7438    pub fn from_num_tokens_only(num_tokens_col: &arrow_array::UInt32Array) -> Self {
7439        let total_tokens = num_tokens_col.values().iter().map(|&n| n as u64).sum();
7440        Self::from_cached_num_tokens(num_tokens_col, total_tokens)
7441    }
7442
7443    /// Build a zero-copy num-tokens-only view from an Arrow column and its
7444    /// already-computed total. The caller must guarantee that `total_tokens`
7445    /// is the sum of `num_tokens_col`.
7446    pub(crate) fn from_cached_num_tokens(
7447        num_tokens_col: &arrow_array::UInt32Array,
7448        total_tokens: u64,
7449    ) -> Self {
7450        Self {
7451            row_ids: Vec::new(),
7452            num_tokens: NumTokens::Shared(num_tokens_col.values().clone()),
7453            inv: Vec::new(),
7454            total_tokens,
7455            scoring_quantized: false,
7456            norms: Arc::new(std::sync::OnceLock::new()),
7457        }
7458    }
7459
7460    /// Build a `DocSet` from already-loaded `row_id` and `num_tokens`
7461    /// Arrow columns without re-reading either column.
7462    pub fn from_columns(
7463        row_id_col: &UInt64Array,
7464        num_tokens_col: &arrow_array::UInt32Array,
7465        is_legacy: bool,
7466        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
7467    ) -> Result<Self> {
7468        // for legacy format, the row id is doc id; sorting keeps binary search viable
7469        if is_legacy {
7470            let (row_ids, num_tokens): (Vec<_>, Vec<_>) = row_id_col
7471                .values()
7472                .iter()
7473                .filter_map(|id| {
7474                    if let Some(frag_reuse_index_ref) = frag_reuse_index.as_ref() {
7475                        frag_reuse_index_ref.remap_row_id(*id)
7476                    } else {
7477                        Some(*id)
7478                    }
7479                })
7480                .zip(num_tokens_col.values().iter())
7481                .sorted_unstable_by_key(|x| x.0)
7482                .unzip();
7483
7484            let total_tokens = num_tokens.iter().map(|&x| x as u64).sum();
7485            return Ok(Self {
7486                row_ids,
7487                num_tokens: NumTokens::Owned(num_tokens),
7488                inv: Vec::new(),
7489                total_tokens,
7490                scoring_quantized: false,
7491                norms: Arc::new(std::sync::OnceLock::new()),
7492            });
7493        }
7494
7495        // If frag reuse happened, remap the row_ids through it. Crucially we
7496        // must NOT drop the rows the reuse index deleted, because the posting
7497        // lists reference doc_ids *positionally* (a doc_id is an index into
7498        // these arrays, fixed at build time). Dropping deleted rows would
7499        // renumber every later doc_id and desync the posting lists, so wand
7500        // would index `num_tokens`/`row_ids` out of bounds or score the wrong
7501        // doc. Instead we tombstone deleted rows in place: their slot survives
7502        // (so doc_ids stay aligned with the posting lists) carrying
7503        // `RowAddress::TOMBSTONE_ROW`, which wand skips, and they are left out
7504        // of `inv` so a row_id lookup never resolves to a deleted doc. The
7505        // heavyweight physical remap (`DocSet::remap`) is what actually
7506        // renumbers and compacts; this load-time path only has to stay
7507        // consistent until then.
7508        if let Some(frag_reuse_index_ref) = frag_reuse_index.as_ref() {
7509            let mut row_ids = Vec::with_capacity(row_id_col.len());
7510            let num_tokens = num_tokens_col.values().to_vec();
7511            let mut inv = Vec::with_capacity(row_id_col.len());
7512            for (doc_id, row_id) in row_id_col.values().iter().enumerate() {
7513                match frag_reuse_index_ref.remap_row_id(*row_id) {
7514                    Some(new_row_id) => {
7515                        row_ids.push(new_row_id);
7516                        inv.push((new_row_id, doc_id as u32));
7517                    }
7518                    None => {
7519                        // Deleted: keep the slot (doc_ids must not shift) but
7520                        // tombstone it and leave it out of `inv`.
7521                        row_ids.push(RowAddress::TOMBSTONE_ROW);
7522                    }
7523                }
7524            }
7525            inv.sort_unstable_by_key(|entry| entry.0);
7526
7527            let total_tokens = num_tokens.iter().map(|&x| x as u64).sum();
7528            return Ok(Self {
7529                row_ids,
7530                num_tokens: NumTokens::Owned(num_tokens),
7531                inv,
7532                total_tokens,
7533                scoring_quantized: false,
7534                norms: Arc::new(std::sync::OnceLock::new()),
7535            });
7536        }
7537
7538        let row_ids = row_id_col.values().to_vec();
7539        let num_tokens = num_tokens_col.values().to_vec();
7540        let mut inv: Vec<(u64, u32)> = row_ids
7541            .iter()
7542            .enumerate()
7543            .map(|(doc_id, row_id)| (*row_id, doc_id as u32))
7544            .collect();
7545        if !row_ids.is_sorted() {
7546            inv.sort_unstable_by_key(|entry| entry.0);
7547        }
7548        let total_tokens = num_tokens.iter().map(|&x| x as u64).sum();
7549        Ok(Self {
7550            row_ids,
7551            num_tokens: NumTokens::Owned(num_tokens),
7552            inv,
7553            total_tokens,
7554            scoring_quantized: false,
7555            norms: Arc::new(std::sync::OnceLock::new()),
7556        })
7557    }
7558
7559    // remap the row ids to the new row ids
7560    // returns the removed doc ids
7561    pub fn remap(&mut self, mapping: &RowAddrRemap) -> Vec<u32> {
7562        let mut removed = Vec::new();
7563        let len = self.len();
7564        let row_ids = std::mem::replace(&mut self.row_ids, Vec::with_capacity(len));
7565        let num_tokens =
7566            std::mem::replace(&mut self.num_tokens, NumTokens::with_capacity(len)).into_owned();
7567        self.invalidate_norms();
7568        self.total_tokens = 0;
7569        for (doc_id, (row_id, num_token)) in std::iter::zip(row_ids, num_tokens).enumerate() {
7570            match mapping.get(row_id) {
7571                Some(Some(new_row_id)) => {
7572                    self.row_ids.push(new_row_id);
7573                    self.num_tokens.push(num_token);
7574                    self.total_tokens += num_token as u64;
7575                }
7576                Some(None) => {
7577                    removed.push(doc_id as u32);
7578                }
7579                None => {
7580                    self.row_ids.push(row_id);
7581                    self.num_tokens.push(num_token);
7582                    self.total_tokens += num_token as u64;
7583                }
7584            }
7585        }
7586        removed
7587    }
7588
7589    #[inline]
7590    pub fn num_tokens(&self, doc_id: u32) -> u32 {
7591        self.num_tokens[doc_id as usize]
7592    }
7593
7594    /// Enable quantized document-length scoring for 256-document-block partitions.
7595    pub fn set_quantized_scoring(&mut self, quantized: bool) {
7596        self.scoring_quantized = quantized;
7597    }
7598
7599    /// The quantized document-length slab when this set scores quantized,
7600    /// baked on first use; `None` for exact-scoring sets.
7601    pub fn scoring_norms(&self) -> Option<&[u8]> {
7602        if !self.scoring_quantized {
7603            return None;
7604        }
7605        Some(
7606            self.norms
7607                .get_or_init(|| {
7608                    self.num_tokens
7609                        .iter()
7610                        .map(|&n| quantize_doc_length(n))
7611                        .collect()
7612                })
7613                .as_ref(),
7614        )
7615    }
7616
7617    /// Document length as scoring sees it: the quantized bucket floor for
7618    /// 256-document-block partitions, the exact value otherwise.
7619    #[inline]
7620    pub fn scoring_num_tokens(&self, doc_id: u32) -> u32 {
7621        match self.scoring_norms() {
7622            Some(norms) => dequantize_doc_length(norms[doc_id as usize]),
7623            None => self.num_tokens[doc_id as usize],
7624        }
7625    }
7626
7627    // this can be used only if it's a legacy format,
7628    // which store the sorted row ids so that we can use binary search
7629    #[inline]
7630    pub fn num_tokens_by_row_id(&self, row_id: u64) -> u32 {
7631        self.row_ids
7632            .binary_search(&row_id)
7633            .map(|idx| self.num_tokens[idx])
7634            .unwrap_or(0)
7635    }
7636
7637    // append a document to the doc set
7638    // returns the doc_id (the number of documents before appending)
7639    pub fn append(&mut self, row_id: u64, num_tokens: u32) -> u32 {
7640        self.row_ids.push(row_id);
7641        self.num_tokens.push(num_tokens);
7642        self.total_tokens += num_tokens as u64;
7643        self.invalidate_norms();
7644        self.row_ids.len() as u32 - 1
7645    }
7646
7647    // Drop the baked norm slab after a mutation; it re-bakes on the next
7648    // scoring use.
7649    fn invalidate_norms(&mut self) {
7650        if self.norms.get().is_some() {
7651            self.norms = Arc::new(std::sync::OnceLock::new());
7652        }
7653    }
7654
7655    pub(crate) fn memory_size(&self) -> usize {
7656        self.row_ids.capacity() * std::mem::size_of::<u64>()
7657            + self.num_tokens.memory_size()
7658            + self.inv.capacity() * std::mem::size_of::<(u64, u32)>()
7659    }
7660}
7661
7662pub fn flat_full_text_search(
7663    batches: &[&RecordBatch],
7664    doc_col: &str,
7665    query: &str,
7666    tokenizer: Option<Box<dyn LanceTokenizer>>,
7667) -> Result<Vec<u64>> {
7668    if batches.is_empty() {
7669        return Ok(vec![]);
7670    }
7671
7672    if is_phrase_query(query) {
7673        return Err(Error::invalid_input(
7674            "phrase query is not supported for flat full text search, try using FTS index",
7675        ));
7676    }
7677
7678    match batches[0][doc_col].data_type() {
7679        DataType::Utf8 => do_flat_full_text_search::<i32>(batches, doc_col, query, tokenizer),
7680        DataType::LargeUtf8 => do_flat_full_text_search::<i64>(batches, doc_col, query, tokenizer),
7681        DataType::List(_) => {
7682            do_flat_full_text_search_list::<i32>(batches, doc_col, query, tokenizer)
7683        }
7684        DataType::LargeList(_) => {
7685            do_flat_full_text_search_list::<i64>(batches, doc_col, query, tokenizer)
7686        }
7687        data_type => Err(Error::invalid_input(format!(
7688            "unsupported data type {} for inverted index",
7689            data_type
7690        ))),
7691    }
7692}
7693
7694fn do_flat_full_text_search<Offset: OffsetSizeTrait>(
7695    batches: &[&RecordBatch],
7696    doc_col: &str,
7697    query: &str,
7698    tokenizer: Option<Box<dyn LanceTokenizer>>,
7699) -> Result<Vec<u64>> {
7700    let mut results = Vec::new();
7701    let mut tokenizer =
7702        tokenizer.unwrap_or_else(|| InvertedIndexParams::default().build().unwrap());
7703    let query_tokens = collect_query_tokens(query, &mut tokenizer);
7704
7705    for batch in batches {
7706        let row_id_array = batch[ROW_ID].as_primitive::<UInt64Type>();
7707        let doc_array = batch[doc_col].as_string::<Offset>();
7708        for i in 0..row_id_array.len() {
7709            let doc = doc_array.value(i);
7710            if has_query_token(doc, &mut tokenizer, &query_tokens) {
7711                results.push(row_id_array.value(i));
7712                // What is this assertion for?  Why would doc contain query?  Don't we reach
7713                // here only if they share at least one token?  Why is it not debug_assert?
7714                assert!(doc.contains(query));
7715            }
7716        }
7717    }
7718
7719    Ok(results)
7720}
7721
7722fn do_flat_full_text_search_list<ListOffset: OffsetSizeTrait>(
7723    batches: &[&RecordBatch],
7724    doc_col: &str,
7725    query: &str,
7726    tokenizer: Option<Box<dyn LanceTokenizer>>,
7727) -> Result<Vec<u64>> {
7728    let mut results = Vec::new();
7729    let mut tokenizer =
7730        tokenizer.unwrap_or_else(|| InvertedIndexParams::default().build().unwrap());
7731    let query_tokens = collect_query_tokens(query, &mut tokenizer);
7732
7733    for batch in batches {
7734        let row_id_array = batch[ROW_ID].as_primitive::<UInt64Type>();
7735        let doc_array = batch[doc_col].as_list::<ListOffset>();
7736        match doc_array.value_type() {
7737            DataType::Utf8 | DataType::LargeUtf8 => {}
7738            data_type => {
7739                return Err(Error::invalid_input(format!(
7740                    "unsupported list item data type {} for inverted index",
7741                    data_type
7742                )));
7743            }
7744        }
7745        for i in 0..row_id_array.len() {
7746            if doc_array.is_null(i) {
7747                continue;
7748            }
7749            let elements = doc_array.value(i);
7750            if iter_str_array(elements.as_ref())
7751                .flatten()
7752                .any(|element| has_query_token(element, &mut tokenizer, &query_tokens))
7753            {
7754                results.push(row_id_array.value(i));
7755            }
7756        }
7757    }
7758
7759    Ok(results)
7760}
7761
7762const FLAT_ROW_ID_COL_IDX: usize = 0;
7763const FLAT_ALL_TOKENS_COL_IDX: usize = 1;
7764const FLAT_QUERY_TOKEN_COUNTS_COL_IDX: usize = 2;
7765
7766/// If we accumulate this many bytes we warn the user they probably want to use an FTS index instead.
7767const BYTES_ACCUMULATED_WARNING_THRESHOLD: u64 = 1024 * 1024 * 1024; // 1GB
7768
7769/// Consumes a stream of record batches and produces token counts
7770///
7771/// The resulting batch will have three columns:
7772/// - row_id: the row id of the document
7773/// - all_tokens: the total number of tokens in the document
7774/// - query_token_counts: a fixed size list of the count of each query token in the document
7775///
7776/// This is an unbounded accumulation, however, for most queries, the per-row
7777/// growth will be fairly small.  As a result we can process millions of tokens
7778/// with fairly modest memory usage.
7779///
7780/// However, it is unwise to do a flat search across billions of rows.  An FTS
7781/// index should be created instead.
7782async fn tokenize_and_count(
7783    input: impl Stream<Item = DataFusionResult<RecordBatch>> + Send,
7784    tokenizer: Box<dyn LanceTokenizer>,
7785    query_tokens: Arc<Tokens>,
7786    doc_col_idx: usize,
7787    elapsed_compute: Option<Time>,
7788) -> DataFusionResult<RecordBatch> {
7789    let output_schema = Arc::new(Schema::new(vec![
7790        ROW_ID_FIELD.clone(),
7791        Field::new("all_tokens", DataType::UInt64, false),
7792        Field::new(
7793            "query_token_counts",
7794            DataType::FixedSizeList(
7795                Arc::new(Field::new("item", DataType::UInt64, true)),
7796                query_tokens.len() as i32,
7797            ),
7798            false,
7799        ),
7800    ]));
7801    let output_schema_clone = output_schema.clone();
7802    let query_token_indices = Arc::new(query_token_indices(query_tokens.as_ref()));
7803    let bytes_accumulated = Arc::new(AtomicU64::new(0));
7804    let bytes_warning_emitted = Arc::new(AtomicBool::new(false));
7805
7806    let batches = input
7807        .map(move |batch| {
7808            let mut tokenizer = tokenizer.box_clone();
7809            let output_schema = output_schema.clone();
7810            let query_tokens = query_tokens.clone();
7811            let query_token_indices = query_token_indices.clone();
7812            let bytes_accumulated = bytes_accumulated.clone();
7813            let bytes_warning_emitted = bytes_warning_emitted.clone();
7814            let elapsed_compute = elapsed_compute.clone();
7815            spawn_cpu(move || {
7816                // Time the per-batch CPU work so callers can attribute it to
7817                // `elapsed_compute` on a metric handle (the spawn_cpu worker
7818                // thread is invisible to the caller's poll timer otherwise).
7819                let start = std::time::Instant::now();
7820                let batch = batch?;
7821                let row_id_array = batch[ROW_ID].as_primitive::<UInt64Type>();
7822                let mut row_ids = UInt64Builder::with_capacity(batch.num_rows());
7823                let mut all_token_counts = UInt64Builder::with_capacity(batch.num_rows());
7824                let mut query_token_counts = FixedSizeListBuilder::with_capacity(
7825                    UInt64Builder::with_capacity(batch.num_rows() * query_tokens.len()),
7826                    query_tokens.len() as i32,
7827                    batch.num_rows(),
7828                );
7829                let mut temp_query_token_counts = Vec::with_capacity(query_tokens.len());
7830                let mut count_text = |doc: &str, temp_query_token_counts: &mut Vec<u64>| -> u64 {
7831                    let mut stream = tokenizer.token_stream_for_doc(doc);
7832                    let mut all_tokens = 0;
7833                    while let Some(token) = stream.next() {
7834                        all_tokens += 1;
7835                        if let Some(token_indices) = query_token_indices.get(&token.text) {
7836                            for token_index in token_indices {
7837                                temp_query_token_counts[*token_index] += 1;
7838                            }
7839                        }
7840                    }
7841                    all_tokens
7842                };
7843                let mut append_counts =
7844                    |row_id: u64, all_tokens: u64, temp_query_token_counts: &[u64]| {
7845                        row_ids.append_value(row_id);
7846                        all_token_counts.append_value(all_tokens);
7847                        for count in temp_query_token_counts.iter().copied() {
7848                            query_token_counts.values().append_value(count);
7849                        }
7850                        query_token_counts.append(true);
7851                    };
7852                match batch.column(doc_col_idx).data_type() {
7853                    DataType::Utf8 | DataType::LargeUtf8 => {
7854                        let doc_iter = iter_str_array(batch.column(doc_col_idx));
7855                        for (doc, row_id) in doc_iter.zip(row_id_array.values().iter()) {
7856                            temp_query_token_counts.clear();
7857                            temp_query_token_counts
7858                                .extend(std::iter::repeat_n(0, query_tokens.len()));
7859
7860                            let Some(doc) = doc else {
7861                                continue;
7862                            };
7863
7864                            let all_tokens = count_text(doc, &mut temp_query_token_counts);
7865                            if all_tokens > 0 {
7866                                append_counts(*row_id, all_tokens, &temp_query_token_counts);
7867                            }
7868                        }
7869                    }
7870                    DataType::List(_) => {
7871                        tokenize_and_count_list::<i32>(
7872                            batch.column(doc_col_idx),
7873                            row_id_array,
7874                            &mut count_text,
7875                            &mut append_counts,
7876                            &mut temp_query_token_counts,
7877                            query_tokens.len(),
7878                        )?;
7879                    }
7880                    DataType::LargeList(_) => {
7881                        tokenize_and_count_list::<i64>(
7882                            batch.column(doc_col_idx),
7883                            row_id_array,
7884                            &mut count_text,
7885                            &mut append_counts,
7886                            &mut temp_query_token_counts,
7887                            query_tokens.len(),
7888                        )?;
7889                    }
7890                    data_type => {
7891                        return DataFusionResult::Err(datafusion_common::DataFusionError::Execution(
7892                            format!("unsupported data type {} for flat full text search", data_type),
7893                        ));
7894                    }
7895                }
7896                let row_ids = row_ids.finish();
7897                let all_token_counts = all_token_counts.finish();
7898                let query_token_counts = query_token_counts.finish();
7899                let result_batch = RecordBatch::try_new(
7900                    output_schema,
7901                    vec![
7902                        Arc::new(row_ids) as ArrayRef,
7903                        Arc::new(all_token_counts) as ArrayRef,
7904                        Arc::new(query_token_counts) as ArrayRef,
7905                    ],
7906                )?;
7907                let bytes_accumulated = bytes_accumulated.fetch_add(result_batch.get_array_memory_size() as u64, Ordering::Relaxed);
7908                if bytes_accumulated > BYTES_ACCUMULATED_WARNING_THRESHOLD && !bytes_warning_emitted.swap(true, Ordering::Relaxed) {
7909                    tracing::warn!("Flat full text search is accumulating a large number of bytes.  Consider using an FTS index instead.");
7910                }
7911
7912                if let Some(t) = &elapsed_compute {
7913                    t.add_duration(start.elapsed());
7914                }
7915                DataFusionResult::Ok(result_batch)
7916            })
7917        })
7918        .buffered(get_num_compute_intensive_cpus())
7919        .try_collect::<Vec<_>>()
7920        .await?;
7921
7922    Ok(arrow::compute::concat_batches(
7923        &output_schema_clone,
7924        &batches,
7925    )?)
7926}
7927
7928fn tokenize_and_count_list<ListOffset: OffsetSizeTrait>(
7929    doc_col: &ArrayRef,
7930    row_id_array: &arrow_array::PrimitiveArray<UInt64Type>,
7931    count_text: &mut impl FnMut(&str, &mut Vec<u64>) -> u64,
7932    append_counts: &mut impl FnMut(u64, u64, &[u64]),
7933    temp_query_token_counts: &mut Vec<u64>,
7934    query_tokens_len: usize,
7935) -> DataFusionResult<()> {
7936    let doc_array = doc_col.as_list::<ListOffset>();
7937    match doc_array.value_type() {
7938        DataType::Utf8 | DataType::LargeUtf8 => {}
7939        data_type => {
7940            return Err(datafusion_common::DataFusionError::Execution(format!(
7941                "unsupported list item data type {} for flat full text search",
7942                data_type
7943            )));
7944        }
7945    }
7946
7947    for i in 0..row_id_array.len() {
7948        if doc_array.is_null(i) {
7949            continue;
7950        }
7951
7952        temp_query_token_counts.clear();
7953        temp_query_token_counts.extend(std::iter::repeat_n(0, query_tokens_len));
7954
7955        let elements = doc_array.value(i);
7956        let mut all_tokens = 0;
7957        for element in iter_str_array(elements.as_ref()).flatten() {
7958            all_tokens += count_text(element, temp_query_token_counts);
7959        }
7960
7961        if all_tokens > 0 {
7962            append_counts(row_id_array.value(i), all_tokens, temp_query_token_counts);
7963        }
7964    }
7965
7966    Ok(())
7967}
7968
7969fn query_token_indices(query_tokens: &Tokens) -> HashMap<String, Vec<usize>> {
7970    let mut indices = HashMap::new();
7971    for idx in 0..query_tokens.len() {
7972        indices
7973            .entry(query_tokens.get_token(idx).to_string())
7974            .or_insert_with(Vec::new)
7975            .push(idx);
7976    }
7977    indices
7978}
7979
7980/// Initialize the BM25 scorer
7981///
7982/// In order to calculate BM25 scores we need to know token counts for the entire corpus.  We extract these from the
7983/// counted input of the flat search combined with any counts recorded for the indexed portion.
7984fn initialize_scorer(
7985    base_scorer: Option<&MemBM25Scorer>,
7986    query_tokens: &Tokens,
7987    counted_input: &RecordBatch,
7988) -> MemBM25Scorer {
7989    let mut total_tokens = 0;
7990    let mut num_docs = 0;
7991    let mut all_token_counts = vec![0; query_tokens.len()];
7992
7993    if let Some(base_scorer) = base_scorer {
7994        total_tokens += base_scorer.total_tokens;
7995        num_docs += base_scorer.num_docs;
7996        for (token_index, token) in query_tokens.into_iter().enumerate() {
7997            all_token_counts[token_index] = base_scorer.num_docs_containing_token(token) as u64;
7998        }
7999    }
8000
8001    num_docs += counted_input.num_rows();
8002    total_tokens += arrow::compute::sum(
8003        counted_input
8004            .column(FLAT_ALL_TOKENS_COL_IDX)
8005            .as_primitive::<UInt64Type>(),
8006    )
8007    .unwrap_or_default();
8008
8009    let mut input_token_counters = counted_input
8010        .column(FLAT_QUERY_TOKEN_COUNTS_COL_IDX)
8011        .as_fixed_size_list()
8012        .values()
8013        .as_primitive::<UInt64Type>()
8014        .values()
8015        .iter()
8016        .copied();
8017
8018    for _ in 0..counted_input.num_rows() {
8019        for token_count in all_token_counts.iter_mut() {
8020            if input_token_counters.next().unwrap_or_default() > 0 {
8021                *token_count += 1;
8022            }
8023        }
8024    }
8025
8026    let token_counts_map = all_token_counts
8027        .into_iter()
8028        .enumerate()
8029        .map(|(token_index, count)| {
8030            (
8031                query_tokens.get_token(token_index).to_string(),
8032                count as usize,
8033            )
8034        })
8035        .collect::<HashMap<String, usize>>();
8036    MemBM25Scorer::new(total_tokens, num_docs, token_counts_map)
8037}
8038
8039fn flat_bm25_score(
8040    query_tokens: &Tokens,
8041    counted_input: &RecordBatch,
8042    scorer: &MemBM25Scorer,
8043    operator: Operator,
8044) -> Result<RecordBatch> {
8045    let mut row_ids_builder = UInt64Builder::with_capacity(counted_input.num_rows());
8046    let mut scores_builder = Float32Builder::with_capacity(counted_input.num_rows());
8047    let query_groups = query_position_groups(query_tokens);
8048
8049    let mut row_ids_iter = counted_input
8050        .column(FLAT_ROW_ID_COL_IDX)
8051        .as_primitive::<UInt64Type>()
8052        .values()
8053        .iter()
8054        .copied();
8055    let mut all_token_counts_iter = counted_input
8056        .column(FLAT_ALL_TOKENS_COL_IDX)
8057        .as_primitive::<UInt64Type>()
8058        .values()
8059        .iter()
8060        .copied();
8061    let mut query_token_counts_iter = counted_input
8062        .column(FLAT_QUERY_TOKEN_COUNTS_COL_IDX)
8063        .as_fixed_size_list()
8064        .values()
8065        .as_primitive::<UInt64Type>()
8066        .values()
8067        .iter()
8068        .copied();
8069    for _ in 0..counted_input.num_rows() {
8070        let num_tokens_in_doc = all_token_counts_iter.next().expect_ok()?;
8071        let row_id = row_ids_iter.next().expect_ok()?;
8072        let mut query_token_counts = Vec::with_capacity(query_tokens.len());
8073        for _ in query_tokens {
8074            query_token_counts.push(query_token_counts_iter.next().expect_ok()?);
8075        }
8076        if num_tokens_in_doc == 0 {
8077            continue;
8078        }
8079        if operator == Operator::And
8080            && !query_groups
8081                .iter()
8082                .all(|group| group.iter().any(|idx| query_token_counts[*idx] > 0))
8083        {
8084            continue;
8085        }
8086        let doc_norm = K1 * (1.0 - B + B * num_tokens_in_doc as f32 / scorer.avg_doc_length());
8087        let mut score = 0.0;
8088        for (token, freq) in query_tokens.into_iter().zip(query_token_counts) {
8089            let freq = freq as f32;
8090            let idf = idf(scorer.num_docs_containing_token(token), scorer.num_docs());
8091            score += idf * (freq * (K1 + 1.0) / (freq + doc_norm));
8092        }
8093        if score > 0.0 {
8094            row_ids_builder.append_value(row_id);
8095            scores_builder.append_value(score);
8096        }
8097    }
8098
8099    let row_ids = row_ids_builder.finish();
8100    let scores = scores_builder.finish();
8101    let batch = RecordBatch::try_new(
8102        FTS_SCHEMA.clone(),
8103        vec![Arc::new(row_ids) as ArrayRef, Arc::new(scores) as ArrayRef],
8104    )?;
8105    Ok(batch)
8106}
8107
8108fn query_position_groups(query_tokens: &Tokens) -> Vec<Vec<usize>> {
8109    let mut groups = Vec::new();
8110    let mut current_position = None;
8111    for idx in 0..query_tokens.len() {
8112        let position = query_tokens.position(idx);
8113        if current_position != Some(position) {
8114            current_position = Some(position);
8115            groups.push(Vec::new());
8116        }
8117        groups
8118            .last_mut()
8119            .expect("a group should exist after pushing for position")
8120            .push(idx);
8121    }
8122    groups
8123}
8124
8125#[deprecated(
8126    note = "use `flat_bm25_search_stream_with_metrics` to record CPU compute \
8127            time on a metric handle; pass `None` for the old behavior"
8128)]
8129pub async fn flat_bm25_search_stream(
8130    input: SendableRecordBatchStream,
8131    doc_col: String,
8132    query: String,
8133    tokenizer: Box<dyn LanceTokenizer>,
8134    base_scorer: Option<MemBM25Scorer>,
8135    target_batch_size: usize,
8136) -> DataFusionResult<SendableRecordBatchStream> {
8137    flat_bm25_search_stream_with_metrics(
8138        input,
8139        doc_col,
8140        query,
8141        tokenizer,
8142        base_scorer,
8143        target_batch_size,
8144        None,
8145    )
8146    .await
8147}
8148
8149/// Same as [`flat_bm25_search_stream`] but accepts an optional `Time` handle
8150/// that, if provided, will receive the CPU time spent in (a) per-batch
8151/// tokenization on the `spawn_cpu` worker threads and (b) the synchronous
8152/// scoring phase. This lets a calling `ExecutionPlan` report accurate
8153/// `elapsed_compute` without double-counting upstream poll time.
8154pub async fn flat_bm25_search_stream_with_metrics(
8155    input: SendableRecordBatchStream,
8156    doc_col: String,
8157    query: String,
8158    tokenizer: Box<dyn LanceTokenizer>,
8159    base_scorer: Option<MemBM25Scorer>,
8160    target_batch_size: usize,
8161    elapsed_compute: Option<Time>,
8162) -> DataFusionResult<SendableRecordBatchStream> {
8163    flat_bm25_search_stream_with_metrics_and_operator(
8164        input,
8165        doc_col,
8166        query,
8167        tokenizer,
8168        base_scorer,
8169        target_batch_size,
8170        Operator::Or,
8171        elapsed_compute,
8172    )
8173    .await
8174}
8175
8176/// Same as [`flat_bm25_search_stream_with_metrics`] but applies the provided
8177/// match operator when deciding whether a flat-scanned row is a hit.
8178///
8179/// # Examples
8180///
8181/// ```no_run
8182/// # async fn example(
8183/// #     input: datafusion::execution::SendableRecordBatchStream,
8184/// # ) -> Result<(), Box<dyn std::error::Error>> {
8185/// use lance_index::scalar::inverted::{
8186///     flat_bm25_search_stream_with_metrics_and_operator, query::Operator, InvertedIndexParams,
8187/// };
8188///
8189/// let tokenizer = InvertedIndexParams::code().build()?;
8190/// let _stream = flat_bm25_search_stream_with_metrics_and_operator(
8191///     input,
8192///     "code".to_string(),
8193///     "Result".to_string(),
8194///     tokenizer,
8195///     None,
8196///     1024,
8197///     Operator::And,
8198///     None,
8199/// )
8200/// .await?;
8201/// # Ok(())
8202/// # }
8203/// ```
8204#[allow(clippy::too_many_arguments)]
8205pub async fn flat_bm25_search_stream_with_metrics_and_operator(
8206    input: SendableRecordBatchStream,
8207    doc_col: String,
8208    query: String,
8209    tokenizer: Box<dyn LanceTokenizer>,
8210    base_scorer: Option<MemBM25Scorer>,
8211    target_batch_size: usize,
8212    operator: Operator,
8213    elapsed_compute: Option<Time>,
8214) -> DataFusionResult<SendableRecordBatchStream> {
8215    let mut tokenizer = tokenizer;
8216
8217    // Pre-await synchronous work: query tokenization + chunk-stream setup.
8218    let pre_await_start = std::time::Instant::now();
8219    let query_tokens = Arc::new(collect_query_tokens(&query, &mut tokenizer));
8220
8221    // A query that tokenizes to no terms (e.g. only stop words) has no
8222    // searchable content and matches nothing. Return early rather than
8223    // proceeding. This mirrors the indexed search path, which already
8224    // short-circuits on empty query tokens.
8225    if query_tokens.is_empty() {
8226        return Ok(Box::pin(RecordBatchStreamAdapter::new(
8227            FTS_SCHEMA.clone(),
8228            stream::empty::<DataFusionResult<RecordBatch>>(),
8229        )));
8230    }
8231
8232    let input_schema = input.schema();
8233    let doc_col_idx = input_schema.index_of(&doc_col)?;
8234
8235    // Accumulate small batches until this threshold before dispatching a task.
8236    const ACCUMULATE_BYTES: usize = 256 * 1024;
8237    // Slice oversized batches down to roughly this size.
8238    const SLICE_BYTES: usize = 512 * 1024;
8239
8240    // Phase 1 - rechunk the input stream into appropriately sized chunks.  Tokenization is
8241    // fairly CPU-intensive, and we don't need too much data to justify a new thread task.
8242    let chunked = lance_arrow::stream::rechunk_stream_by_size(
8243        input,
8244        input_schema,
8245        ACCUMULATE_BYTES,
8246        SLICE_BYTES,
8247    );
8248    if let Some(t) = &elapsed_compute {
8249        t.add_duration(pre_await_start.elapsed());
8250    }
8251
8252    // Phase 2 - For each row we need to know the total number of tokens and the count of each
8253    // of the query tokens.  For example, if the query is "book" and the row is "the book shop"
8254    // and we are tokenizing with a whitespace tokenizer, we need to know that there are 3 tokens
8255    // and the token book appears once.
8256    let counted_input = tokenize_and_count(
8257        chunked,
8258        tokenizer,
8259        query_tokens.clone(),
8260        doc_col_idx,
8261        elapsed_compute.clone(),
8262    )
8263    .await?;
8264
8265    // Phase 3 - Calculate final scores (this is fairly cheap, probably don't need to parallelize).
8266    // All post-await work is synchronous; time the scorer + score + slicing loop together.
8267    let post_await_start = std::time::Instant::now();
8268    let scorer = initialize_scorer(base_scorer.as_ref(), query_tokens.as_ref(), &counted_input);
8269    let scores = flat_bm25_score(query_tokens.as_ref(), &counted_input, &scorer, operator)?;
8270
8271    // Finally we emit batches according to the target batch size
8272    let num_out_batches = scores.num_rows().div_ceil(target_batch_size);
8273    let mut batches = Vec::with_capacity(num_out_batches);
8274    for i in 0..num_out_batches {
8275        let start = i * target_batch_size;
8276        let len = (scores.num_rows() - start).min(target_batch_size);
8277        batches.push(Ok(scores.slice(start, len)));
8278    }
8279    if let Some(t) = &elapsed_compute {
8280        t.add_duration(post_await_start.elapsed());
8281    }
8282    Ok(Box::pin(RecordBatchStreamAdapter::new(
8283        FTS_SCHEMA.clone(),
8284        stream::iter(batches),
8285    )))
8286}
8287
8288pub fn is_phrase_query(query: &str) -> bool {
8289    query.starts_with('\"') && query.ends_with('\"')
8290}
8291
8292#[cfg(test)]
8293mod tests {
8294    use crate::scalar::inverted::document_tokenizer::DocType;
8295    use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
8296    use futures::stream;
8297    use lance_core::cache::{LanceCache, QuickCacheBackend};
8298    use lance_core::utils::tempfile::TempObjDir;
8299    use lance_io::object_store::ObjectStore;
8300
8301    use crate::metrics::{LocalMetricsCollector, NoOpMetricsCollector};
8302    use crate::prefilter::NoFilter;
8303    use crate::scalar::ScalarIndex;
8304    use crate::scalar::inverted::builder::{
8305        InnerBuilder, InvertedIndexBuilder, PositionRecorder, doc_file_path, inverted_list_schema,
8306        inverted_list_schema_for_version_with_block_size,
8307        inverted_list_schema_for_version_with_block_size_and_impacts, posting_file_path,
8308        token_file_path,
8309    };
8310    use crate::scalar::inverted::encoding::{
8311        compress_positions, compress_posting_list_with_tail_codec,
8312        decompress_posting_list_with_tail_codec, encode_position_stream_block_into,
8313    };
8314    use crate::scalar::inverted::query::{FtsSearchParams, Operator};
8315    use crate::scalar::lance_format::LanceIndexStore;
8316    use arrow::array::{
8317        AsArray, GenericListBuilder, GenericStringBuilder, Int32Builder, LargeBinaryBuilder,
8318        ListBuilder, UInt32Builder,
8319    };
8320    use arrow::datatypes::{Float32Type, UInt32Type};
8321    use arrow_array::{ArrayRef, Float32Array, RecordBatch, StringArray, UInt32Array, UInt64Array};
8322    use arrow_schema::{DataType, Field, Schema};
8323    use std::collections::HashMap;
8324    use std::sync::Arc;
8325    use std::sync::atomic::{AtomicU32, Ordering};
8326
8327    use crate::scalar::inverted::tokenizer::document_tokenizer::TextTokenizer;
8328    use lance_tokenizer::{Language, SimpleTokenizer, StopWordFilter, TextAnalyzer};
8329
8330    use super::*;
8331
8332    #[test]
8333    fn address_read_concurrency_respects_payload_budget() {
8334        assert_eq!(address_read_concurrency(64, 0), 64);
8335        assert_eq!(address_read_concurrency(64, 8 * 1024 * 1024), 8);
8336        assert_eq!(address_read_concurrency(64, 16 * 1024 * 1024), 4);
8337        assert_eq!(
8338            address_read_concurrency(64, 2 * MAX_CONCURRENT_ADDRESS_READ_BYTES),
8339            1
8340        );
8341    }
8342
8343    #[derive(Debug)]
8344    struct MetadataAccessDeniedStore {
8345        inner: Arc<dyn IndexStore>,
8346    }
8347
8348    impl DeepSizeOf for MetadataAccessDeniedStore {
8349        fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
8350            self.inner.deep_size_of_children(context)
8351        }
8352    }
8353
8354    #[async_trait]
8355    impl IndexStore for MetadataAccessDeniedStore {
8356        fn as_any(&self) -> &dyn std::any::Any {
8357            self
8358        }
8359
8360        fn clone_arc(&self) -> Arc<dyn IndexStore> {
8361            Arc::new(Self {
8362                inner: self.inner.clone(),
8363            })
8364        }
8365
8366        fn io_parallelism(&self) -> usize {
8367            self.inner.io_parallelism()
8368        }
8369
8370        async fn new_index_file(
8371            &self,
8372            name: &str,
8373            schema: Arc<Schema>,
8374        ) -> Result<Box<dyn crate::scalar::IndexWriter>> {
8375            self.inner.new_index_file(name, schema).await
8376        }
8377
8378        async fn open_index_file(&self, name: &str) -> Result<Arc<dyn IndexReader>> {
8379            if name == METADATA_FILE {
8380                Err(Error::io("metadata access denied"))
8381            } else {
8382                self.inner.open_index_file(name).await
8383            }
8384        }
8385
8386        fn with_io_priority(&self, io_priority: u64) -> Arc<dyn IndexStore> {
8387            Arc::new(Self {
8388                inner: self.inner.with_io_priority(io_priority),
8389            })
8390        }
8391
8392        async fn copy_index_file(
8393            &self,
8394            name: &str,
8395            dest_store: &dyn IndexStore,
8396        ) -> Result<crate::scalar::IndexFile> {
8397            self.inner.copy_index_file(name, dest_store).await
8398        }
8399
8400        async fn rename_index_file(
8401            &self,
8402            name: &str,
8403            new_name: &str,
8404        ) -> Result<crate::scalar::IndexFile> {
8405            self.inner.rename_index_file(name, new_name).await
8406        }
8407
8408        async fn delete_index_file(&self, name: &str) -> Result<()> {
8409            self.inner.delete_index_file(name).await
8410        }
8411
8412        async fn list_files_with_sizes(&self) -> Result<Vec<crate::scalar::IndexFile>> {
8413            self.inner.list_files_with_sizes().await
8414        }
8415    }
8416
8417    #[tokio::test]
8418    async fn params_legacy_fallback_probes_tokens_after_metadata_access_denied() {
8419        let tmpdir = TempObjDir::default();
8420        let inner: Arc<dyn IndexStore> = Arc::new(LanceIndexStore::new(
8421            ObjectStore::local().into(),
8422            tmpdir.clone(),
8423            Arc::new(LanceCache::no_cache()),
8424        ));
8425        let expected = InvertedIndexParams::default();
8426        let metadata = HashMap::from([(
8427            "tokenizer".to_owned(),
8428            serde_json::to_string(&expected).unwrap(),
8429        )]);
8430        let mut writer = inner
8431            .new_index_file(TOKENS_FILE, Arc::new(Schema::empty()))
8432            .await
8433            .unwrap();
8434        writer.finish_with_metadata(metadata).await.unwrap();
8435        let store = MetadataAccessDeniedStore { inner };
8436
8437        let actual = InvertedIndex::load_params(&store).await.unwrap();
8438        assert_eq!(
8439            serde_json::to_value(actual).unwrap(),
8440            serde_json::to_value(expected).unwrap()
8441        );
8442    }
8443
8444    #[tokio::test]
8445    async fn params_legacy_probe_preserves_metadata_error_when_tokens_are_missing() {
8446        let tmpdir = TempObjDir::default();
8447        let inner: Arc<dyn IndexStore> = Arc::new(LanceIndexStore::new(
8448            ObjectStore::local().into(),
8449            tmpdir.clone(),
8450            Arc::new(LanceCache::no_cache()),
8451        ));
8452        let store = MetadataAccessDeniedStore { inner };
8453
8454        let error = InvertedIndex::load_params(&store).await.unwrap_err();
8455        assert!(matches!(error, Error::IO { .. }));
8456        assert!(error.to_string().contains("metadata access denied"));
8457    }
8458
8459    #[tokio::test]
8460    async fn params_metadata_ignores_unknown_fields() {
8461        let tmpdir = TempObjDir::default();
8462        let store = Arc::new(LanceIndexStore::new(
8463            ObjectStore::local().into(),
8464            tmpdir.clone(),
8465            Arc::new(LanceCache::no_cache()),
8466        ));
8467        let expected = InvertedIndexParams::default();
8468        let mut params = serde_json::to_value(&expected).unwrap();
8469        let params = params.as_object_mut().unwrap();
8470        params.insert("skip_merge".to_owned(), true.into());
8471        params.insert(
8472            "future_parameter".to_owned(),
8473            serde_json::json!({ "enabled": true }),
8474        );
8475        let metadata =
8476            HashMap::from([("params".to_owned(), serde_json::to_string(params).unwrap())]);
8477        let mut writer = store
8478            .new_index_file(METADATA_FILE, Arc::new(Schema::empty()))
8479            .await
8480            .unwrap();
8481        writer.finish_with_metadata(metadata).await.unwrap();
8482
8483        let actual = InvertedIndex::load_params(store.as_ref()).await.unwrap();
8484        assert_eq!(
8485            serde_json::to_value(actual).unwrap(),
8486            serde_json::to_value(expected).unwrap()
8487        );
8488    }
8489
8490    async fn write_single_partition_index(
8491        store: Arc<LanceIndexStore>,
8492        params: InvertedIndexParams,
8493        token_set_format: TokenSetFormat,
8494        token: &str,
8495        row_id: u64,
8496    ) -> Result<Arc<InvertedIndex>> {
8497        let block_size = params.posting_block_size();
8498        let format_version = params.resolved_format_version();
8499        let mut partition = InnerBuilder::new_with_format_version_and_block_size(
8500            0,
8501            false,
8502            token_set_format,
8503            format_version,
8504            block_size,
8505        );
8506        partition.tokens.add(token.to_owned());
8507        let mut posting_list = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
8508            false,
8509            format_version.posting_tail_codec(),
8510            block_size,
8511        );
8512        posting_list.add(0, PositionRecorder::Count(1));
8513        partition.posting_lists.push(posting_list);
8514        partition.docs.append(row_id, 1);
8515        partition.write(store.as_ref()).await?;
8516
8517        let metadata = HashMap::from([
8518            (
8519                "partitions".to_owned(),
8520                serde_json::to_string(&vec![0_u64]).unwrap(),
8521            ),
8522            ("params".to_owned(), serde_json::to_string(&params).unwrap()),
8523            (
8524                TOKEN_SET_FORMAT_KEY.to_owned(),
8525                token_set_format.to_string(),
8526            ),
8527            (
8528                POSTING_TAIL_CODEC_KEY.to_owned(),
8529                format_version.posting_tail_codec().as_str().to_owned(),
8530            ),
8531            (
8532                FTS_FORMAT_VERSION_KEY.to_owned(),
8533                format_version.index_version().to_string(),
8534            ),
8535            (POSTING_BLOCK_SIZE_KEY.to_owned(), block_size.to_string()),
8536        ]);
8537        let mut writer = store
8538            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
8539            .await?;
8540        writer.finish_with_metadata(metadata).await?;
8541
8542        InvertedIndex::load(store, None, &LanceCache::no_cache()).await
8543    }
8544
8545    fn empty_doc_stream() -> SendableRecordBatchStream {
8546        let schema = Arc::new(Schema::new(vec![
8547            Field::new("doc", DataType::Utf8, true),
8548            Field::new(ROW_ID, DataType::UInt64, false),
8549        ]));
8550        Box::pin(RecordBatchStreamAdapter::new(
8551            schema,
8552            stream::iter(Vec::<datafusion::error::Result<RecordBatch>>::new()),
8553        ))
8554    }
8555
8556    #[test]
8557    fn test_posting_block_size_schema_metadata() {
8558        assert_eq!(parse_posting_block_size(&HashMap::new()).unwrap(), 128);
8559
8560        let metadata = HashMap::from([(POSTING_BLOCK_SIZE_KEY.to_owned(), "512".to_owned())]);
8561        let err = parse_posting_block_size(&metadata).unwrap_err();
8562        assert!(err.to_string().contains("block_size"));
8563
8564        let metadata = HashMap::from([(POSTING_BLOCK_SIZE_KEY.to_owned(), "129".to_owned())]);
8565        let err = parse_posting_block_size(&metadata).unwrap_err();
8566        assert!(err.to_string().contains("block_size"));
8567    }
8568
8569    #[test]
8570    fn test_num_tokens_only_reuses_sliced_arrow_storage() {
8571        let docs = {
8572            let source = UInt32Array::from(vec![999, 7, 16, 1024, 888]);
8573            let sliced = source.slice(1, 3);
8574            let mut docs = DocSet::from_num_tokens_only(&sliced);
8575
8576            let NumTokens::Shared(values) = &docs.num_tokens else {
8577                panic!("num-tokens-only DocSet must retain shared Arrow storage");
8578            };
8579            assert!(values.ptr_eq(sliced.values()));
8580            assert_eq!(values.as_ref(), &[7, 16, 1024]);
8581            assert_eq!(docs.total_tokens_num(), 1047);
8582            docs.set_quantized_scoring(true);
8583            assert_eq!(docs.scoring_norms().unwrap().len(), 3);
8584            assert_eq!(
8585                docs.scoring_num_tokens(0),
8586                dequantize_doc_length(quantize_doc_length(7))
8587            );
8588            assert_eq!(
8589                docs.scoring_num_tokens(2),
8590                dequantize_doc_length(quantize_doc_length(1024))
8591            );
8592            docs
8593        };
8594
8595        assert_eq!(docs.len(), 3);
8596        assert_eq!(docs.num_tokens(0), 7);
8597        assert_eq!(docs.num_tokens(2), 1024);
8598    }
8599
8600    #[test]
8601    fn test_cached_num_tokens_uses_supplied_total_and_full_stays_owned() {
8602        const CACHED_TOTAL_MARKER: u64 = 123_456;
8603
8604        let num_tokens = UInt32Array::from(vec![3, 5, 8]);
8605        let docs = DocSet::from_cached_num_tokens(&num_tokens, CACHED_TOTAL_MARKER);
8606        assert_eq!(docs.total_tokens_num(), CACHED_TOTAL_MARKER);
8607        assert!(matches!(&docs.num_tokens, NumTokens::Shared(_)));
8608
8609        let row_ids = UInt64Array::from(vec![10, 20, 30]);
8610        let full = DocSet::from_columns(&row_ids, &num_tokens, false, None).unwrap();
8611        assert!(matches!(&full.num_tokens, NumTokens::Owned(_)));
8612        assert_eq!(full.total_tokens_num(), 16);
8613        assert_eq!(full.row_id(1), 20);
8614    }
8615
8616    #[test]
8617    fn test_posting_builder_writes_impacts_for_supported_block_sizes() {
8618        for block_size in [128, 256] {
8619            let format_version = default_fts_format_version_for_block_size(block_size).unwrap();
8620            let num_docs = block_size * 33 + 1;
8621            let mut docs = DocSet::default();
8622            let mut posting = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
8623                false,
8624                format_version.posting_tail_codec(),
8625                block_size,
8626            );
8627            for doc_id in 0..num_docs {
8628                docs.append(doc_id as u64, (doc_id % 5 + 1) as u32);
8629                posting.add(
8630                    doc_id as u32,
8631                    PositionRecorder::Count((doc_id % 3 + 1) as u32),
8632                );
8633            }
8634            let schema =
8635                inverted_list_schema_for_version_with_block_size(false, format_version, block_size);
8636            let batch = posting.to_batch_with_docs(&docs, schema).unwrap();
8637            assert!(batch.column_by_name(IMPACT_COL).is_some());
8638            let max_score = batch[MAX_SCORE_COL].as_primitive::<Float32Type>().value(0);
8639            let length = batch[LENGTH_COL].as_primitive::<UInt32Type>().value(0);
8640            let posting = PostingList::from_batch(&batch, Some(max_score), Some(length)).unwrap();
8641            let PostingList::Compressed(posting) = posting else {
8642                panic!("expected compressed posting list");
8643            };
8644            let impacts = posting.impacts.expect("posting should include impacts");
8645            assert_eq!(impacts.level0_len(), posting.blocks.len());
8646            assert_eq!(impacts.level1_len(), posting.blocks.len().div_ceil(32));
8647            assert_eq!(
8648                impacts.entries().len(),
8649                impacts.level0_len() + impacts.level1_len()
8650            );
8651        }
8652    }
8653
8654    #[test]
8655    fn test_posting_builder_without_impact_column_roundtrips_without_impacts() {
8656        let mut posting = PostingListBuilder::new(false);
8657        for doc_id in 0..BLOCK_SIZE + 3 {
8658            posting.add(doc_id as u32, PositionRecorder::Count(1));
8659        }
8660        let batch = posting.to_batch(vec![1.0, 1.0]).unwrap();
8661        assert!(batch.column_by_name(IMPACT_COL).is_none());
8662        let posting =
8663            PostingList::from_batch(&batch, Some(1.0), Some((BLOCK_SIZE + 3) as u32)).unwrap();
8664        assert!(!posting.has_impacts());
8665    }
8666
8667    #[tokio::test]
8668    async fn test_build_search_uses_configured_posting_block_size() {
8669        let tmpdir = TempObjDir::default();
8670        let store = Arc::new(LanceIndexStore::new(
8671            ObjectStore::local().into(),
8672            tmpdir.clone(),
8673            Arc::new(LanceCache::no_cache()),
8674        ));
8675
8676        let params = InvertedIndexParams::default().block_size(256).unwrap();
8677        let format_version = params.resolved_format_version();
8678        let block_size = params.posting_block_size();
8679        let num_docs = block_size + 7;
8680
8681        let mut builder = InnerBuilder::new_with_format_version_and_block_size(
8682            0,
8683            false,
8684            TokenSetFormat::default(),
8685            format_version,
8686            block_size,
8687        );
8688        builder.tokens.add("needle".to_owned());
8689        let mut posting_list = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
8690            false,
8691            format_version.posting_tail_codec(),
8692            block_size,
8693        );
8694        for doc_id in 0..num_docs {
8695            posting_list.add(doc_id as u32, PositionRecorder::Count(1));
8696            builder.docs.append(1_000 + doc_id as u64, 1);
8697        }
8698        builder.posting_lists.push(posting_list);
8699        builder.write(store.as_ref()).await.unwrap();
8700        write_test_metadata(&store, vec![0], params).await;
8701
8702        let cache = Arc::new(LanceCache::with_capacity(4096));
8703        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
8704            .await
8705            .unwrap();
8706        assert_eq!(index.partitions[0].inverted_list.block_size(), block_size);
8707
8708        let posting = index.partitions[0]
8709            .inverted_list
8710            .posting_list(0, false, &NoOpMetricsCollector)
8711            .await
8712            .unwrap();
8713        let PostingList::Compressed(posting) = posting else {
8714            panic!("expected compressed posting list");
8715        };
8716        assert_eq!(posting.block_size, block_size);
8717        assert_eq!(posting.blocks.len(), num_docs.div_ceil(block_size));
8718        let impacts = posting
8719            .impacts
8720            .as_ref()
8721            .expect("newly written posting list should include impacts");
8722        assert_eq!(impacts.level0_len(), posting.blocks.len());
8723        assert_eq!(impacts.level1_len(), posting.blocks.len().div_ceil(32));
8724        assert_eq!(
8725            impacts.entries().len(),
8726            impacts.level0_len() + impacts.level1_len()
8727        );
8728
8729        let tokens = Arc::new(Tokens::new(vec!["needle".to_owned()], DocType::Text));
8730        let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
8731        let prefilter = Arc::new(NoFilter);
8732        let metrics = Arc::new(NoOpMetricsCollector);
8733        let (row_ids, scores) = index
8734            .bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
8735            .await
8736            .unwrap();
8737
8738        assert_eq!(row_ids.len(), 10);
8739        assert_eq!(scores.len(), 10);
8740        assert!(row_ids.iter().all(|row_id| *row_id >= 1_000));
8741    }
8742
8743    #[tokio::test]
8744    async fn test_posting_builder_remap() {
8745        let posting_tail_codec = PostingTailCodec::Fixed32;
8746        let mut builder =
8747            PostingListBuilder::new_with_posting_tail_codec(false, posting_tail_codec);
8748        let n = BLOCK_SIZE + 3;
8749        for i in 0..n {
8750            builder.add(i as u32, PositionRecorder::Count(1));
8751        }
8752        let removed = vec![5, 7];
8753        builder.remap(&removed);
8754
8755        let mut expected =
8756            PostingListBuilder::new_with_posting_tail_codec(false, posting_tail_codec);
8757        for i in 0..n - removed.len() {
8758            expected.add(i as u32, PositionRecorder::Count(1));
8759        }
8760        let expected_entries = expected.iter().collect::<Vec<_>>();
8761        let actual_entries = builder.iter().collect::<Vec<_>>();
8762        assert_eq!(actual_entries, expected_entries);
8763
8764        // BLOCK_SIZE + 3 elements should be reduced to BLOCK_SIZE + 1,
8765        // there are still 2 blocks.
8766        let batch = builder.to_batch(vec![1.0, 2.0]).unwrap();
8767        let (doc_ids, freqs) = decompress_posting_list_with_tail_codec(
8768            (n - removed.len()) as u32,
8769            batch[POSTING_COL]
8770                .as_list::<i32>()
8771                .value(0)
8772                .as_binary::<i64>(),
8773            posting_tail_codec,
8774        )
8775        .unwrap();
8776        assert!(
8777            doc_ids
8778                .iter()
8779                .zip(expected_entries.iter().map(|(doc_id, _, _)| doc_id))
8780                .all(|(a, b)| a == b)
8781        );
8782        assert!(
8783            freqs
8784                .iter()
8785                .zip(expected_entries.iter().map(|(_, freq, _)| freq))
8786                .all(|(a, b)| a == b)
8787        );
8788    }
8789
8790    #[test]
8791    fn test_posting_builder_size_tracking_matches_structure() {
8792        fn tracked_memory_size(builder: &PostingListBuilder) -> u64 {
8793            let encoded_blocks_size = builder
8794                .encoded_blocks
8795                .iter()
8796                .map(|encoded_blocks| std::mem::size_of::<EncodedBlocks>() + encoded_blocks.size())
8797                .sum::<usize>();
8798            let encoded_positions_size = builder
8799                .encoded_position_blocks
8800                .as_ref()
8801                .map(|positions| std::mem::size_of::<EncodedPositionBlocks>() + positions.size())
8802                .unwrap_or(0usize);
8803            (encoded_blocks_size
8804                + builder.tail_entries.capacity() * std::mem::size_of::<RawDocInfo>()
8805                + builder.tail_positions.size()
8806                + encoded_positions_size) as u64
8807        }
8808
8809        let mut builder = PostingListBuilder::new(true);
8810        for doc_id in 0..(BLOCK_SIZE + 5) as u32 {
8811            builder.add(
8812                doc_id,
8813                PositionRecorder::Position(smallvec::smallvec![1, 3, 5]),
8814            );
8815        }
8816
8817        assert_eq!(builder.size(), tracked_memory_size(&builder));
8818    }
8819
8820    #[test]
8821    fn test_posting_builder_flush_releases_tail_position_capacity() {
8822        let mut builder = PostingListBuilder::new(true);
8823        let positions = smallvec::SmallVec::<[u32; 2]>::from_vec((0..1024).collect());
8824        for doc_id in 0..BLOCK_SIZE as u32 {
8825            builder.add(doc_id, PositionRecorder::Position(positions.clone()));
8826        }
8827
8828        assert_eq!(builder.tail_positions.size(), 0);
8829        assert_eq!(builder.size(), {
8830            let encoded_blocks_size = builder
8831                .encoded_blocks
8832                .iter()
8833                .map(|encoded_blocks| std::mem::size_of::<EncodedBlocks>() + encoded_blocks.size())
8834                .sum::<usize>();
8835            let encoded_positions_size = builder
8836                .encoded_position_blocks
8837                .as_ref()
8838                .map(|positions| std::mem::size_of::<EncodedPositionBlocks>() + positions.size())
8839                .unwrap_or(0usize);
8840            (encoded_blocks_size
8841                + builder.tail_entries.capacity() * std::mem::size_of::<RawDocInfo>()
8842                + builder.tail_positions.size()
8843                + encoded_positions_size) as u64
8844        });
8845    }
8846
8847    #[test]
8848    fn test_posting_builder_streamed_positions_roundtrip() {
8849        let mut builder = PostingListBuilder::new(true);
8850        assert!(builder.add_occurrence(0, 1).unwrap());
8851        assert!(!builder.add_occurrence(0, 4).unwrap());
8852        assert!(!builder.add_occurrence(0, 9).unwrap());
8853        builder.finish_open_doc(0).unwrap();
8854
8855        assert!(builder.add_occurrence(2, 3).unwrap());
8856        builder.finish_open_doc(2).unwrap();
8857
8858        let entries = builder.iter().collect::<Vec<_>>();
8859        assert_eq!(
8860            entries,
8861            vec![
8862                (0_u32, 3_u32, Some(vec![1_u32, 4_u32, 9_u32])),
8863                (2_u32, 1_u32, Some(vec![3_u32])),
8864            ]
8865        );
8866    }
8867
8868    #[test]
8869    fn test_shared_position_stream_clone_shares_block_offsets() {
8870        let stream = SharedPositionStream::new(
8871            PositionStreamCodec::PackedDelta,
8872            vec![0_u32, 4, 11],
8873            bytes::Bytes::from_static(b"shared position bytes"),
8874        );
8875        let original_offsets = stream.block_offsets().as_ptr();
8876
8877        let cloned = stream.clone();
8878
8879        assert_eq!(cloned.block_offsets(), stream.block_offsets());
8880        assert_eq!(cloned.block_offsets().as_ptr(), original_offsets);
8881    }
8882
8883    #[test]
8884    fn test_posting_builder_roundtrip_shared_positions() {
8885        let entries = vec![
8886            (0_u32, vec![1_u32, 5]),
8887            (2, vec![0, 4, 9]),
8888            (4, vec![7]),
8889            (8, vec![3, 10]),
8890            (13, vec![2, 11, 30]),
8891        ];
8892        let mut builder =
8893            PostingListBuilder::new_with_posting_tail_codec(true, PostingTailCodec::VarintDelta);
8894        for (doc_id, positions) in &entries {
8895            builder.add(
8896                *doc_id,
8897                PositionRecorder::Position(positions.clone().into()),
8898            );
8899        }
8900
8901        let batch = builder.to_batch(vec![1.0]).unwrap();
8902        assert!(batch.column_by_name(COMPRESSED_POSITION_COL).is_some());
8903        assert!(batch.column_by_name(POSITION_COL).is_none());
8904        assert_eq!(
8905            batch.schema_ref().metadata().get(POSTING_TAIL_CODEC_KEY),
8906            Some(&PostingTailCodec::VarintDelta.as_str().to_owned())
8907        );
8908        assert_eq!(
8909            batch.schema_ref().metadata().get(POSITIONS_LAYOUT_KEY),
8910            Some(&POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned())
8911        );
8912        assert_eq!(
8913            batch.schema_ref().metadata().get(POSITIONS_CODEC_KEY),
8914            Some(&PositionStreamCodec::PackedDelta.as_str().to_owned())
8915        );
8916
8917        let posting =
8918            PostingList::from_batch(&batch, Some(1.0), Some(entries.len() as u32)).unwrap();
8919        let actual = posting
8920            .iter()
8921            .map(|(doc_id, freq, positions)| {
8922                (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
8923            })
8924            .collect::<Vec<_>>();
8925        let expected = entries
8926            .iter()
8927            .map(|(doc_id, positions)| (*doc_id, positions.len() as u32, positions.clone()))
8928            .collect::<Vec<_>>();
8929        assert_eq!(actual, expected);
8930    }
8931
8932    #[test]
8933    fn test_posting_builder_roundtrip_legacy_positions() {
8934        let entries = vec![(0_u32, vec![1_u32, 5]), (2, vec![0, 4, 9]), (4, vec![7])];
8935        let mut builder =
8936            PostingListBuilder::new_with_posting_tail_codec(true, PostingTailCodec::Fixed32);
8937        for (doc_id, positions) in &entries {
8938            builder.add(
8939                *doc_id,
8940                PositionRecorder::Position(positions.clone().into()),
8941            );
8942        }
8943
8944        let batch = builder.to_batch(vec![1.0]).unwrap();
8945        assert!(batch.column_by_name(POSITION_COL).is_some());
8946        assert!(batch.column_by_name(COMPRESSED_POSITION_COL).is_none());
8947        assert_eq!(
8948            batch.schema_ref().metadata().get(POSTING_TAIL_CODEC_KEY),
8949            None
8950        );
8951        assert_eq!(
8952            batch.schema_ref().metadata().get(POSITIONS_LAYOUT_KEY),
8953            None
8954        );
8955        assert_eq!(batch.schema_ref().metadata().get(POSITIONS_CODEC_KEY), None);
8956
8957        let posting =
8958            PostingList::from_batch(&batch, Some(1.0), Some(entries.len() as u32)).unwrap();
8959        let actual = posting
8960            .iter()
8961            .map(|(doc_id, freq, positions)| {
8962                (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
8963            })
8964            .collect::<Vec<_>>();
8965        let expected = entries
8966            .iter()
8967            .map(|(doc_id, positions)| (*doc_id, positions.len() as u32, positions.clone()))
8968            .collect::<Vec<_>>();
8969        assert_eq!(actual, expected);
8970    }
8971
8972    #[test]
8973    fn test_resolve_fts_format_version_defaults_to_v2() {
8974        assert_eq!(
8975            resolve_fts_format_version(None).unwrap(),
8976            InvertedListFormatVersion::V2
8977        );
8978        assert_eq!(
8979            resolve_fts_format_version(Some("2")).unwrap(),
8980            InvertedListFormatVersion::V2
8981        );
8982        assert_eq!(
8983            resolve_fts_format_version(Some("3")).unwrap(),
8984            InvertedListFormatVersion::V3
8985        );
8986        assert!(resolve_fts_format_version(Some("4")).is_err());
8987    }
8988
8989    #[test]
8990    fn test_block_size_256_metadata_resolves_to_v3() {
8991        let metadata = HashMap::from([(POSTING_BLOCK_SIZE_KEY.to_owned(), "256".to_owned())]);
8992        assert_eq!(
8993            parse_format_version_from_metadata(&metadata).unwrap(),
8994            InvertedListFormatVersion::V3
8995        );
8996    }
8997
8998    #[test]
8999    fn test_legacy_compressed_positions_still_readable() {
9000        let doc_ids = [1_u32, 3_u32];
9001        let frequencies = [2_u32, 3_u32];
9002        let posting = compress_posting_list_with_tail_codec(
9003            doc_ids.len(),
9004            doc_ids.iter(),
9005            frequencies.iter(),
9006            std::iter::once(1.0_f32),
9007            PostingTailCodec::Fixed32,
9008        )
9009        .unwrap();
9010
9011        let mut posting_builder = ListBuilder::new(LargeBinaryBuilder::new());
9012        for idx in 0..posting.len() {
9013            posting_builder.values().append_value(posting.value(idx));
9014        }
9015        posting_builder.append(true);
9016
9017        let mut positions_builder = ListBuilder::new(ListBuilder::new(LargeBinaryBuilder::new()));
9018        for positions in [vec![1_u32, 5_u32], vec![0_u32, 4_u32, 9_u32]] {
9019            let compressed = compress_positions(&positions).unwrap();
9020            let doc_builder = positions_builder.values();
9021            for idx in 0..compressed.len() {
9022                doc_builder.values().append_value(compressed.value(idx));
9023            }
9024            doc_builder.append(true);
9025        }
9026        positions_builder.append(true);
9027
9028        let schema = Arc::new(Schema::new(vec![
9029            Field::new(
9030                POSTING_COL,
9031                DataType::List(Arc::new(Field::new("item", DataType::LargeBinary, true))),
9032                false,
9033            ),
9034            Field::new(MAX_SCORE_COL, DataType::Float32, false),
9035            Field::new(LENGTH_COL, DataType::UInt32, false),
9036            Field::new(
9037                POSITION_COL,
9038                DataType::List(Arc::new(Field::new(
9039                    "item",
9040                    DataType::List(Arc::new(Field::new("item", DataType::LargeBinary, true))),
9041                    true,
9042                ))),
9043                false,
9044            ),
9045        ]));
9046        let batch = RecordBatch::try_new(
9047            schema,
9048            vec![
9049                Arc::new(posting_builder.finish()) as ArrayRef,
9050                Arc::new(Float32Array::from(vec![1.0])) as ArrayRef,
9051                Arc::new(UInt32Array::from(vec![doc_ids.len() as u32])) as ArrayRef,
9052                Arc::new(positions_builder.finish()) as ArrayRef,
9053            ],
9054        )
9055        .unwrap();
9056
9057        let posting =
9058            PostingList::from_batch(&batch, Some(1.0), Some(doc_ids.len() as u32)).unwrap();
9059        let actual = posting
9060            .iter()
9061            .map(|(doc_id, freq, positions)| {
9062                (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
9063            })
9064            .collect::<Vec<_>>();
9065        assert_eq!(actual, vec![(1, 2, vec![1, 5]), (3, 3, vec![0, 4, 9]),]);
9066    }
9067
9068    #[test]
9069    fn test_shared_stream_v2_without_codec_still_readable() {
9070        let doc_ids = [1_u32, 3_u32];
9071        let frequencies = [2_u32, 3_u32];
9072        let posting = compress_posting_list_with_tail_codec(
9073            doc_ids.len(),
9074            doc_ids.iter(),
9075            frequencies.iter(),
9076            std::iter::once(1.0_f32),
9077            PostingTailCodec::Fixed32,
9078        )
9079        .unwrap();
9080
9081        let mut posting_builder = ListBuilder::new(LargeBinaryBuilder::new());
9082        for idx in 0..posting.len() {
9083            posting_builder.values().append_value(posting.value(idx));
9084        }
9085        posting_builder.append(true);
9086
9087        let positions = vec![1_u32, 5_u32, 0_u32, 4_u32, 9_u32];
9088        let mut encoded_positions = Vec::new();
9089        encode_position_stream_block_into(
9090            &positions,
9091            &frequencies,
9092            PositionStreamCodec::VarintDocDelta,
9093            &mut encoded_positions,
9094        )
9095        .unwrap();
9096
9097        let mut position_offsets = ListBuilder::new(UInt32Builder::new());
9098        position_offsets.values().append_value(0);
9099        position_offsets.append(true);
9100
9101        let schema = Arc::new(Schema::new_with_metadata(
9102            vec![
9103                Field::new(
9104                    POSTING_COL,
9105                    DataType::List(Arc::new(Field::new("item", DataType::LargeBinary, true))),
9106                    false,
9107                ),
9108                Field::new(MAX_SCORE_COL, DataType::Float32, false),
9109                Field::new(LENGTH_COL, DataType::UInt32, false),
9110                Field::new(COMPRESSED_POSITION_COL, DataType::LargeBinary, false),
9111                Field::new(
9112                    POSITION_BLOCK_OFFSET_COL,
9113                    DataType::List(Arc::new(Field::new("item", DataType::UInt32, true))),
9114                    false,
9115                ),
9116            ],
9117            HashMap::from([(
9118                POSITIONS_LAYOUT_KEY.to_owned(),
9119                POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(),
9120            )]),
9121        ));
9122        let batch = RecordBatch::try_new(
9123            schema,
9124            vec![
9125                Arc::new(posting_builder.finish()) as ArrayRef,
9126                Arc::new(Float32Array::from(vec![1.0])) as ArrayRef,
9127                Arc::new(UInt32Array::from(vec![doc_ids.len() as u32])) as ArrayRef,
9128                Arc::new(arrow_array::LargeBinaryArray::from(vec![Some(
9129                    encoded_positions.as_slice(),
9130                )])) as ArrayRef,
9131                Arc::new(position_offsets.finish()) as ArrayRef,
9132            ],
9133        )
9134        .unwrap();
9135
9136        let posting =
9137            PostingList::from_batch(&batch, Some(1.0), Some(doc_ids.len() as u32)).unwrap();
9138        let actual = posting
9139            .iter()
9140            .map(|(doc_id, freq, positions)| {
9141                (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
9142            })
9143            .collect::<Vec<_>>();
9144        assert_eq!(actual, vec![(1, 2, vec![1, 5]), (3, 3, vec![0, 4, 9]),]);
9145    }
9146
9147    #[test]
9148    fn test_shared_position_stream_is_smaller_for_sparse_positions() {
9149        let mut builder =
9150            PostingListBuilder::new_with_posting_tail_codec(true, PostingTailCodec::VarintDelta);
9151        let mut legacy_positions = Vec::with_capacity(BLOCK_SIZE * 4);
9152        for doc_id in 0..(BLOCK_SIZE * 4) as u32 {
9153            let mut positions = vec![doc_id * 3 + 1];
9154            if doc_id % 8 == 0 {
9155                positions.push(doc_id * 3 + 2);
9156            }
9157            builder.add(doc_id, PositionRecorder::Position(positions.clone().into()));
9158            legacy_positions.push(positions);
9159        }
9160
9161        let batch = builder.to_batch(vec![1.0; 4]).unwrap();
9162        let shared_positions_size = batch[COMPRESSED_POSITION_COL].get_buffer_memory_size()
9163            + batch[POSITION_BLOCK_OFFSET_COL].get_buffer_memory_size();
9164
9165        let mut positions_builder = ListBuilder::new(ListBuilder::new(LargeBinaryBuilder::new()));
9166        for positions in legacy_positions {
9167            let compressed = compress_positions(&positions).unwrap();
9168            let doc_builder = positions_builder.values();
9169            for idx in 0..compressed.len() {
9170                doc_builder.values().append_value(compressed.value(idx));
9171            }
9172            doc_builder.append(true);
9173        }
9174        positions_builder.append(true);
9175        let legacy_positions_size = positions_builder.finish().get_buffer_memory_size();
9176
9177        assert!(
9178            shared_positions_size < legacy_positions_size,
9179            "expected shared position stream to be smaller than legacy per-doc storage, shared={shared_positions_size}, legacy={legacy_positions_size}",
9180        );
9181    }
9182
9183    #[test]
9184    fn test_posting_list_batch_matches_docset_scoring() {
9185        let mut docs = DocSet::default();
9186        let num_docs = BLOCK_SIZE + 3;
9187        for doc_id in 0..num_docs as u32 {
9188            docs.append(doc_id as u64, doc_id % 7 + 1);
9189        }
9190
9191        let doc_ids = (0..num_docs as u32).collect::<Vec<_>>();
9192        let freqs = doc_ids
9193            .iter()
9194            .map(|doc_id| doc_id % 5 + 1)
9195            .collect::<Vec<_>>();
9196
9197        let mut builder_scores = PostingListBuilder::new(false);
9198        let mut builder_docs = PostingListBuilder::new(false);
9199        for (&doc_id, &freq) in doc_ids.iter().zip(freqs.iter()) {
9200            builder_scores.add(doc_id, PositionRecorder::Count(freq));
9201            builder_docs.add(doc_id, PositionRecorder::Count(freq));
9202        }
9203
9204        let block_max_scores = docs.calculate_block_max_scores(doc_ids.iter(), freqs.iter());
9205        let batch_scores = builder_scores.to_batch(block_max_scores).unwrap();
9206        let batch_docs = builder_docs
9207            .to_batch_with_docs(&docs, inverted_list_schema(false))
9208            .unwrap();
9209
9210        let scores_posting = batch_scores[POSTING_COL].as_list::<i32>().value(0);
9211        let scores_posting = scores_posting.as_binary::<i64>();
9212        let docs_posting = batch_docs[POSTING_COL].as_list::<i32>().value(0);
9213        let docs_posting = docs_posting.as_binary::<i64>();
9214        assert_eq!(scores_posting, docs_posting);
9215
9216        let score_left = batch_scores[MAX_SCORE_COL]
9217            .as_primitive::<Float32Type>()
9218            .value(0);
9219        let score_right = batch_docs[MAX_SCORE_COL]
9220            .as_primitive::<Float32Type>()
9221            .value(0);
9222        assert!((score_left - score_right).abs() < 1e-6);
9223
9224        let len_left = batch_scores[LENGTH_COL]
9225            .as_primitive::<UInt32Type>()
9226            .value(0);
9227        let len_right = batch_docs[LENGTH_COL].as_primitive::<UInt32Type>().value(0);
9228        assert_eq!(len_left, len_right);
9229    }
9230
9231    #[tokio::test]
9232    async fn test_remap_to_empty_posting_list() {
9233        let tmpdir = TempObjDir::default();
9234        let store = Arc::new(LanceIndexStore::new(
9235            ObjectStore::local().into(),
9236            tmpdir.clone(),
9237            Arc::new(LanceCache::no_cache()),
9238        ));
9239
9240        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
9241
9242        // index of docs:
9243        // 0: lance
9244        // 1: lake lake
9245        // 2: lake lake lake
9246        builder.tokens.add("lance".to_owned());
9247        builder.tokens.add("lake".to_owned());
9248        builder.posting_lists.push(PostingListBuilder::new(false));
9249        builder.posting_lists.push(PostingListBuilder::new(false));
9250        builder.posting_lists[0].add(0, PositionRecorder::Count(1));
9251        builder.posting_lists[1].add(1, PositionRecorder::Count(2));
9252        builder.posting_lists[1].add(2, PositionRecorder::Count(3));
9253        builder.docs.append(0, 1);
9254        builder.docs.append(1, 1);
9255        builder.docs.append(2, 1);
9256        builder.write(store.as_ref()).await.unwrap();
9257
9258        let index = InvertedPartition::load(
9259            store.clone(),
9260            0,
9261            None,
9262            &LanceCache::no_cache(),
9263            TokenSetFormat::default(),
9264        )
9265        .await
9266        .unwrap();
9267        let mut builder = index.into_builder().await.unwrap();
9268
9269        let mapping = HashMap::from([(0, None), (2, Some(3))]);
9270        builder.remap(&RowAddrRemap::direct(mapping)).await.unwrap();
9271
9272        // after remap, the doc 0 is removed, and the doc 2 is updated to 3
9273        assert_eq!(builder.tokens.len(), 1);
9274        assert_eq!(builder.tokens.get("lake"), Some(0));
9275        assert_eq!(builder.posting_lists.len(), 1);
9276        assert_eq!(builder.posting_lists[0].len(), 2);
9277        assert_eq!(builder.docs.len(), 2);
9278        assert_eq!(builder.docs.row_id(0), 1);
9279        assert_eq!(builder.docs.row_id(1), 3);
9280
9281        builder.write(store.as_ref()).await.unwrap();
9282
9283        // remap to delete all docs
9284        let mapping = HashMap::from([(1, None), (3, None)]);
9285        builder.remap(&RowAddrRemap::direct(mapping)).await.unwrap();
9286
9287        assert_eq!(builder.tokens.len(), 0);
9288        assert_eq!(builder.posting_lists.len(), 0);
9289        assert_eq!(builder.docs.len(), 0);
9290
9291        builder.write(store.as_ref()).await.unwrap();
9292    }
9293
9294    #[tokio::test]
9295    async fn test_posting_cache_conflict_across_partitions() {
9296        let tmpdir = TempObjDir::default();
9297        let store = Arc::new(LanceIndexStore::new(
9298            ObjectStore::local().into(),
9299            tmpdir.clone(),
9300            Arc::new(LanceCache::no_cache()),
9301        ));
9302
9303        // Create first partition with one token and posting list length 1
9304        let mut builder1 = InnerBuilder::new(0, false, TokenSetFormat::default());
9305        builder1.tokens.add("test".to_owned());
9306        builder1.posting_lists.push(PostingListBuilder::new(false));
9307        builder1.posting_lists[0].add(0, PositionRecorder::Count(1));
9308        builder1.docs.append(100, 1); // row_id=100, num_tokens=1
9309        builder1.write(store.as_ref()).await.unwrap();
9310
9311        // Create second partition with one token and posting list length 4
9312        let mut builder2 = InnerBuilder::new(1, false, TokenSetFormat::default());
9313        builder2.tokens.add("test".to_owned()); // Use same token to test cache prefix fix
9314        builder2.posting_lists.push(PostingListBuilder::new(false));
9315        builder2.posting_lists[0].add(0, PositionRecorder::Count(2));
9316        builder2.posting_lists[0].add(1, PositionRecorder::Count(1));
9317        builder2.posting_lists[0].add(2, PositionRecorder::Count(3));
9318        builder2.posting_lists[0].add(3, PositionRecorder::Count(1));
9319        builder2.docs.append(200, 2); // row_id=200, num_tokens=2
9320        builder2.docs.append(201, 1); // row_id=201, num_tokens=1
9321        builder2.docs.append(202, 3); // row_id=202, num_tokens=3
9322        builder2.docs.append(203, 1); // row_id=203, num_tokens=1
9323        builder2.write(store.as_ref()).await.unwrap();
9324
9325        // Create metadata file with both partitions
9326        let metadata = std::collections::HashMap::from_iter(vec![
9327            (
9328                "partitions".to_owned(),
9329                serde_json::to_string(&vec![0u64, 1u64]).unwrap(),
9330            ),
9331            (
9332                "params".to_owned(),
9333                serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
9334            ),
9335            (
9336                TOKEN_SET_FORMAT_KEY.to_owned(),
9337                TokenSetFormat::default().to_string(),
9338            ),
9339        ]);
9340        let mut writer = store
9341            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
9342            .await
9343            .unwrap();
9344        writer.finish_with_metadata(metadata).await.unwrap();
9345
9346        // Load the inverted index
9347        let cache = Arc::new(LanceCache::with_capacity(4096));
9348        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
9349            .await
9350            .unwrap();
9351
9352        // Verify the index structure
9353        assert_eq!(index.partitions.len(), 2);
9354        assert_eq!(index.partitions[0].tokens.len(), 1);
9355        assert_eq!(index.partitions[1].tokens.len(), 1);
9356
9357        // Verify the partitions were loaded correctly
9358
9359        // Verify posting list lengths (note: partition order may differ from creation order).
9360        // `posting_len_for_token` works for both legacy and v2 layouts without
9361        // forcing the V2-only bulk metadata load.
9362        let pl_0_0 = index.partitions[0]
9363            .inverted_list
9364            .posting_len_for_token(0, None)
9365            .await
9366            .unwrap();
9367        let pl_1_0 = index.partitions[1]
9368            .inverted_list
9369            .posting_len_for_token(0, None)
9370            .await
9371            .unwrap();
9372        if index.partitions[0].id() == 0 {
9373            assert_eq!(pl_0_0, 1);
9374            assert_eq!(pl_1_0, 4);
9375            assert_eq!(index.partitions[0].docs.len(), 1);
9376            assert_eq!(index.partitions[1].docs.len(), 4);
9377        } else {
9378            assert_eq!(pl_0_0, 4);
9379            assert_eq!(pl_1_0, 1);
9380            assert_eq!(index.partitions[0].docs.len(), 4);
9381            assert_eq!(index.partitions[1].docs.len(), 1);
9382        }
9383
9384        // Prewarm the inverted index (this loads posting lists into cache)
9385        index.prewarm().await.unwrap();
9386
9387        let tokens = Arc::new(Tokens::new(vec!["test".to_string()], DocType::Text));
9388        let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
9389        let prefilter = Arc::new(NoFilter);
9390        let metrics = Arc::new(NoOpMetricsCollector);
9391
9392        let (row_ids, scores) = index
9393            .bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
9394            .await
9395            .unwrap();
9396
9397        // Verify that we got search results
9398        // Expected to find 5 documents: 1 from first partition, 4 from second partition
9399        assert_eq!(row_ids.len(), 5, "row_ids: {:?}", row_ids);
9400        assert!(!row_ids.is_empty(), "Should find at least some documents");
9401        assert_eq!(row_ids.len(), scores.len());
9402
9403        // All scores should be positive since all documents contain the search token
9404        for &score in &scores {
9405            assert!(score > 0.0, "All scores should be positive");
9406        }
9407
9408        // Check that we got results from both partitions
9409        assert!(
9410            row_ids.contains(&100),
9411            "Should contain row_id from partition 0"
9412        );
9413        assert!(
9414            row_ids.iter().any(|&id| id >= 200),
9415            "Should contain row_id from partition 1"
9416        );
9417    }
9418
9419    #[tokio::test]
9420    async fn test_modern_prewarm_packs_group_with_shared_posting_buffer() {
9421        let tmpdir = TempObjDir::default();
9422        let store = Arc::new(LanceIndexStore::new(
9423            ObjectStore::local().into(),
9424            tmpdir.clone(),
9425            Arc::new(LanceCache::no_cache()),
9426        ));
9427
9428        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
9429        builder.tokens.add("alpha".to_owned());
9430        builder.tokens.add("beta".to_owned());
9431        builder.posting_lists.push(PostingListBuilder::new(false));
9432        builder.posting_lists.push(PostingListBuilder::new(false));
9433        builder.posting_lists[0].add(0, PositionRecorder::Count(1));
9434        builder.posting_lists[0].add(1, PositionRecorder::Count(2));
9435        builder.posting_lists[1].add(2, PositionRecorder::Count(3));
9436        builder.posting_lists[1].add(3, PositionRecorder::Count(4));
9437        builder.docs.append(100, 1);
9438        builder.docs.append(101, 2);
9439        builder.docs.append(102, 3);
9440        builder.docs.append(103, 4);
9441        builder.write(store.as_ref()).await.unwrap();
9442
9443        let metadata = std::collections::HashMap::from_iter(vec![
9444            (
9445                "partitions".to_owned(),
9446                serde_json::to_string(&vec![0u64]).unwrap(),
9447            ),
9448            (
9449                "params".to_owned(),
9450                serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
9451            ),
9452            (
9453                TOKEN_SET_FORMAT_KEY.to_owned(),
9454                TokenSetFormat::default().to_string(),
9455            ),
9456        ]);
9457        let mut writer = store
9458            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
9459            .await
9460            .unwrap();
9461        writer.finish_with_metadata(metadata).await.unwrap();
9462
9463        let cache = Arc::new(LanceCache::with_capacity(4096));
9464        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
9465            .await
9466            .unwrap();
9467        let inverted_list = &index.partitions[0].inverted_list;
9468        assert!(
9469            !inverted_list.is_legacy_layout(),
9470            "test should use modern posting layout"
9471        );
9472        assert!(
9473            inverted_list.has_impacts,
9474            "modern posting fixture should include impact skip data"
9475        );
9476
9477        inverted_list.prewarm_posting_lists(false, 2).await.unwrap();
9478
9479        // The two tiny tokens land in a single cache group [0, 2) (issue
9480        // #7040); both postings are read out of that group entry.
9481        let (start, end) = inverted_list.group_range_for_token(0).unwrap();
9482        let group = inverted_list
9483            .index_cache
9484            .get_with_key(&posting_list_group_cache_key(
9485                start,
9486                end,
9487                inverted_list.has_impacts,
9488            ))
9489            .await
9490            .unwrap();
9491
9492        assert!(
9493            group.is_packed(),
9494            "no-position prewarm should pack v2 groups"
9495        );
9496        assert!(
9497            group.needs_external_metadata(),
9498            "prewarmed packed groups must not duplicate reader score/length metadata"
9499        );
9500        let (alpha_score, alpha_len) = inverted_list.bulk_metadata_for_token(0);
9501        let PostingList::Compressed(alpha) = group
9502            .posting_list(0, alpha_score, alpha_len)
9503            .unwrap()
9504            .unwrap()
9505        else {
9506            panic!("expected compressed posting list for token 0");
9507        };
9508        let PostingList::Compressed(alpha_again) = group
9509            .posting_list(0, alpha_score, alpha_len)
9510            .unwrap()
9511            .unwrap()
9512        else {
9513            panic!("expected compressed posting list for repeated token 0 access");
9514        };
9515        let (beta_score, beta_len) = inverted_list.bulk_metadata_for_token(1);
9516        let PostingList::Compressed(beta) = group
9517            .posting_list(1, beta_score, beta_len)
9518            .unwrap()
9519            .unwrap()
9520        else {
9521            panic!("expected compressed posting list for token 1");
9522        };
9523
9524        assert!(
9525            alpha.impacts.is_some() && beta.impacts.is_some(),
9526            "packed prewarm must preserve impact skip data"
9527        );
9528        assert!(
9529            alpha
9530                .impacts
9531                .as_ref()
9532                .unwrap()
9533                .shares_derived_state_with(alpha_again.impacts.as_ref().unwrap()),
9534            "repeated packed slot access must share decoded impact state"
9535        );
9536        assert!(
9537            alpha.shares_first_docs_with(&alpha_again),
9538            "repeated packed slot access must share decoded block heads"
9539        );
9540        assert_eq!(
9541            alpha.block_first_docs().as_ptr(),
9542            alpha_again.block_first_docs().as_ptr(),
9543            "packed block heads should be decoded only once per slot"
9544        );
9545        assert_eq!(
9546            alpha.blocks.values().as_ptr(),
9547            beta.blocks.values().as_ptr(),
9548            "packed posting views should share the group's values buffer"
9549        );
9550    }
9551
9552    #[tokio::test]
9553    async fn test_packed_prewarm_groups_do_not_retain_the_full_chunk() {
9554        let tmpdir = TempObjDir::default();
9555        let store = Arc::new(LanceIndexStore::new(
9556            ObjectStore::local().into(),
9557            tmpdir.clone(),
9558            Arc::new(LanceCache::no_cache()),
9559        ));
9560
9561        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
9562        for token_id in 0..4u32 {
9563            builder.tokens.add(format!("t{token_id}"));
9564            let mut posting = PostingListBuilder::new(false);
9565            posting.add(token_id, PositionRecorder::Count(1));
9566            builder.posting_lists.push(posting);
9567            builder.docs.append(1000 + token_id as u64, 1);
9568        }
9569        builder.write(store.as_ref()).await.unwrap();
9570
9571        let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
9572        let cache = LanceCache::with_capacity(1 << 20);
9573        let mut posting_reader = PostingListReader::try_new(reader, &cache).await.unwrap();
9574        posting_reader.grouping = PostingGrouping::SyntheticFixed { group_size: 2 };
9575
9576        assert_eq!(
9577            posting_reader
9578                .prewarm_posting_lists_chunked(false, Some(4), 1)
9579                .await
9580                .unwrap(),
9581            1,
9582            "the test must read both groups in one prewarm chunk"
9583        );
9584
9585        let first_group = posting_reader
9586            .index_cache
9587            .get_with_key(&posting_list_group_cache_key(
9588                0,
9589                2,
9590                posting_reader.has_impacts,
9591            ))
9592            .await
9593            .unwrap();
9594        let second_group = posting_reader
9595            .index_cache
9596            .get_with_key(&posting_list_group_cache_key(
9597                2,
9598                4,
9599                posting_reader.has_impacts,
9600            ))
9601            .await
9602            .unwrap();
9603        let (first_score, first_len) = posting_reader.bulk_metadata_for_token(0);
9604        let PostingList::Compressed(first) = first_group
9605            .posting_list(0, first_score, first_len)
9606            .unwrap()
9607            .unwrap()
9608        else {
9609            panic!("expected compressed posting list in first group");
9610        };
9611        let (neighbor_score, neighbor_len) = posting_reader.bulk_metadata_for_token(1);
9612        let PostingList::Compressed(first_neighbor) = first_group
9613            .posting_list(1, neighbor_score, neighbor_len)
9614            .unwrap()
9615            .unwrap()
9616        else {
9617            panic!("expected compressed posting list in first group");
9618        };
9619        let (second_score, second_len) = posting_reader.bulk_metadata_for_token(2);
9620        let PostingList::Compressed(second) = second_group
9621            .posting_list(0, second_score, second_len)
9622            .unwrap()
9623            .unwrap()
9624        else {
9625            panic!("expected compressed posting list in second group");
9626        };
9627
9628        assert_eq!(
9629            first.blocks.values().as_ptr(),
9630            first_neighbor.blocks.values().as_ptr(),
9631            "postings in one group should share the group's values buffer"
9632        );
9633        assert_ne!(
9634            first.blocks.values().as_ptr(),
9635            second.blocks.values().as_ptr(),
9636            "each group must own a compact buffer instead of retaining the full chunk"
9637        );
9638    }
9639
9640    #[test]
9641    fn test_prewarm_chunk_ranges_preserve_group_boundaries() {
9642        let grouping = PostingGrouping::SyntheticFixed { group_size: 4 };
9643        assert_eq!(
9644            prewarm_chunk_ranges(&grouping, 13, 5),
9645            vec![(0, 4), (4, 8), (8, 13)],
9646            "grouped chunks may contain multiple groups but must never split one"
9647        );
9648        assert_eq!(
9649            prewarm_chunk_ranges(&PostingGrouping::None, 13, 5),
9650            vec![(0, 5), (5, 10), (10, 13)],
9651            "ungrouped chunk ranges should use plain token ranges"
9652        );
9653    }
9654
9655    #[test]
9656    fn test_synthetic_grouping_preserves_fixed_boundaries() {
9657        let grouping = PostingGrouping::SyntheticFixed { group_size: 4 };
9658        assert_eq!(
9659            grouping.range_for_token(5, 10),
9660            Some((4, 8)),
9661            "synthetic token groups should be fixed-size ranges"
9662        );
9663        assert_eq!(
9664            grouping.range_for_token(9, 10),
9665            Some((8, 10)),
9666            "the final synthetic group should end at token_count"
9667        );
9668        assert_eq!(
9669            prewarm_chunk_ranges(&grouping, 10, 6),
9670            vec![(0, 4), (4, 10)],
9671            "prewarm chunks may contain multiple synthetic groups but must not split one"
9672        );
9673        assert_eq!(
9674            grouping.ranges_for_chunk(4, 10, 10),
9675            vec![(4, 8), (8, 10)],
9676            "publish selection should enumerate synthetic groups in a chunk"
9677        );
9678    }
9679
9680    /// Prewarming a large partition in multiple chunks must end up holding exactly the
9681    /// same per-token posting lists (doc ids and frequencies) as the whole-file path.
9682    /// Parametrized over layout: the legacy-v1 chunk path rebases global offsets to
9683    /// chunk-local rows, while the modern one-row-per-token path covers both
9684    /// legacy-sized v2 and 256-doc v3 posting blocks.
9685    #[rstest::rstest]
9686    #[case::v1(InvertedListFormatVersion::V1, LEGACY_BLOCK_SIZE)]
9687    #[case::v2(InvertedListFormatVersion::V2, LEGACY_BLOCK_SIZE)]
9688    #[case::v3(InvertedListFormatVersion::V3, 256)]
9689    #[tokio::test]
9690    async fn test_prewarm_streams_in_chunks_preserves_content(
9691        #[case] format_version: InvertedListFormatVersion,
9692        #[case] block_size: usize,
9693    ) {
9694        let tmpdir = TempObjDir::default();
9695        let store = Arc::new(LanceIndexStore::new(
9696            ObjectStore::local().into(),
9697            tmpdir.clone(),
9698            Arc::new(LanceCache::no_cache()),
9699        ));
9700
9701        // One partition with enough tokens to span multiple runtime synthetic
9702        // groups and several docs per token.
9703        let num_tokens = runtime_posting_group_tokens() as u32 + 4;
9704        const DOCS_PER_TOKEN: u32 = 3;
9705        let posting_tail_codec = format_version.posting_tail_codec();
9706        let mut builder = InnerBuilder::new_with_format_version_and_block_size(
9707            0,
9708            false,
9709            TokenSetFormat::default(),
9710            format_version,
9711            block_size,
9712        );
9713        // expected[token] = [(doc_id, frequency)] in stored (doc-id) order.
9714        let mut expected: Vec<Vec<(u32, u32)>> = Vec::new();
9715        let mut doc_id = 0u64;
9716        for t in 0..num_tokens {
9717            builder.tokens.add(format!("tok_{t:03}"));
9718            let mut posting = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
9719                false,
9720                posting_tail_codec,
9721                block_size,
9722            );
9723            let mut docs = Vec::new();
9724            for _ in 0..DOCS_PER_TOKEN {
9725                posting.add(doc_id as u32, PositionRecorder::Count(1));
9726                builder.docs.append(doc_id, 1);
9727                docs.push((doc_id as u32, 1));
9728                doc_id += 1;
9729            }
9730            expected.push(docs);
9731            builder.posting_lists.push(posting);
9732        }
9733        builder.write(store.as_ref()).await.unwrap();
9734
9735        let params = InvertedIndexParams::default()
9736            .block_size(block_size)
9737            .unwrap();
9738        let metadata = std::collections::HashMap::from_iter(vec![
9739            (
9740                "partitions".to_owned(),
9741                serde_json::to_string(&vec![0u64]).unwrap(),
9742            ),
9743            ("params".to_owned(), serde_json::to_string(&params).unwrap()),
9744            (
9745                TOKEN_SET_FORMAT_KEY.to_owned(),
9746                TokenSetFormat::default().to_string(),
9747            ),
9748            (
9749                POSTING_TAIL_CODEC_KEY.to_owned(),
9750                posting_tail_codec.as_str().to_owned(),
9751            ),
9752            (
9753                FTS_FORMAT_VERSION_KEY.to_owned(),
9754                format_version.index_version().to_string(),
9755            ),
9756            (POSTING_BLOCK_SIZE_KEY.to_owned(), block_size.to_string()),
9757        ]);
9758        let mut writer = store
9759            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
9760            .await
9761            .unwrap();
9762        writer.finish_with_metadata(metadata).await.unwrap();
9763
9764        let cache = Arc::new(LanceCache::with_capacity(1 << 20));
9765        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
9766            .await
9767            .unwrap();
9768        let inverted_list = &index.partitions[0].inverted_list;
9769        assert_eq!(inverted_list.len(), num_tokens as usize);
9770        assert_eq!(inverted_list.block_size(), block_size);
9771
9772        // Force a small target chunk. Since CHUNK_TOKENS is below the runtime
9773        // group size, synthetic group alignment should still split only at
9774        // group boundaries.
9775        const CHUNK_TOKENS: usize = 6;
9776        let chunk_count = inverted_list
9777            .prewarm_posting_lists_chunked(false, Some(CHUNK_TOKENS), 2)
9778            .await
9779            .unwrap();
9780
9781        // (1) The partition was streamed in multiple chunks. The exact count is
9782        // group-alignment-dependent (chunks snap to whole groups), so just
9783        // require more than one.
9784        assert!(
9785            chunk_count > 1,
9786            "single partition must be streamed in more than one chunk, got {chunk_count}"
9787        );
9788
9789        if block_size == 256 {
9790            let (start, end) = inverted_list.group_range_for_token(0).unwrap();
9791            let group = inverted_list
9792                .index_cache
9793                .get_with_key(&posting_list_group_cache_key(
9794                    start,
9795                    end,
9796                    inverted_list.has_impacts,
9797                ))
9798                .await
9799                .expect("256-document blocks should populate the packed group cache");
9800            assert!(group.is_packed());
9801            let (max_score, length) = inverted_list.bulk_metadata_for_token(0);
9802            let PostingList::Compressed(posting) =
9803                group.posting_list(0, max_score, length).unwrap().unwrap()
9804            else {
9805                panic!("expected compressed posting list");
9806            };
9807            assert_eq!(posting.block_size, 256);
9808            assert!(
9809                posting.impacts.is_some(),
9810                "packed prewarm must preserve impact skip data"
9811            );
9812        }
9813
9814        // (2) Correctness: every token's posting list round-trips with exactly
9815        // the doc ids and frequencies of the whole-file path.
9816        for token_id in 0..num_tokens {
9817            let actual = inverted_list
9818                .posting_list(token_id, false, &NoOpMetricsCollector)
9819                .await
9820                .unwrap()
9821                .iter()
9822                .map(|(doc_id, freq, _positions)| (doc_id as u32, freq))
9823                .collect::<Vec<_>>();
9824            assert_eq!(
9825                actual, expected[token_id as usize],
9826                "token {token_id} posting list mismatch after chunked prewarm"
9827            );
9828        }
9829    }
9830
9831    /// With positions, the chunked prewarm must strip positions into their own
9832    /// per-token cache entries (leaving the posting cache positions-free) and still
9833    /// round-trip exact doc ids, frequencies, and positions across chunk boundaries.
9834    #[tokio::test]
9835    async fn test_prewarm_streams_in_chunks_with_positions() {
9836        let tmpdir = TempObjDir::default();
9837        let store = Arc::new(LanceIndexStore::new(
9838            ObjectStore::local().into(),
9839            tmpdir.clone(),
9840            Arc::new(LanceCache::no_cache()),
9841        ));
9842
9843        let format_version = InvertedListFormatVersion::V2;
9844        let posting_tail_codec = format_version.posting_tail_codec();
9845        let num_tokens = runtime_posting_group_tokens() as u32 + 4;
9846        const DOCS_PER_TOKEN: u32 = 3;
9847        let mut builder = InnerBuilder::new_with_format_version(
9848            0,
9849            true,
9850            TokenSetFormat::default(),
9851            format_version,
9852        );
9853        // expected[token] = [(doc_id, frequency, positions)].
9854        let mut expected: Vec<Vec<(u32, u32, Vec<u32>)>> = Vec::new();
9855        let mut doc_id = 0u64;
9856        for t in 0..num_tokens {
9857            builder.tokens.add(format!("tok_{t:03}"));
9858            let mut posting =
9859                PostingListBuilder::new_with_posting_tail_codec(true, posting_tail_codec);
9860            let mut docs = Vec::new();
9861            for _ in 0..DOCS_PER_TOKEN {
9862                let positions = vec![t % 3, t % 3 + 2, t % 3 + 5];
9863                posting.add(
9864                    doc_id as u32,
9865                    PositionRecorder::Position(positions.clone().into()),
9866                );
9867                builder.docs.append(doc_id, positions.len() as u32);
9868                docs.push((doc_id as u32, positions.len() as u32, positions));
9869                doc_id += 1;
9870            }
9871            expected.push(docs);
9872            builder.posting_lists.push(posting);
9873        }
9874        builder.write(store.as_ref()).await.unwrap();
9875
9876        let metadata = std::collections::HashMap::from_iter(vec![
9877            (
9878                "partitions".to_owned(),
9879                serde_json::to_string(&vec![0u64]).unwrap(),
9880            ),
9881            (
9882                "params".to_owned(),
9883                serde_json::to_string(&InvertedIndexParams::default().with_position(true)).unwrap(),
9884            ),
9885            (
9886                TOKEN_SET_FORMAT_KEY.to_owned(),
9887                TokenSetFormat::default().to_string(),
9888            ),
9889            (
9890                POSTING_TAIL_CODEC_KEY.to_owned(),
9891                posting_tail_codec.as_str().to_owned(),
9892            ),
9893            (
9894                POSITIONS_LAYOUT_KEY.to_owned(),
9895                POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(),
9896            ),
9897            (
9898                POSITIONS_CODEC_KEY.to_owned(),
9899                PositionStreamCodec::PackedDelta.as_str().to_owned(),
9900            ),
9901        ]);
9902        let mut writer = store
9903            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
9904            .await
9905            .unwrap();
9906        writer.finish_with_metadata(metadata).await.unwrap();
9907
9908        let cache = Arc::new(LanceCache::with_capacity(1 << 20));
9909        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
9910            .await
9911            .unwrap();
9912        let inverted_list = &index.partitions[0].inverted_list;
9913
9914        const CHUNK_TOKENS: usize = 5;
9915        let chunk_count = inverted_list
9916            .prewarm_posting_lists_chunked(true, Some(CHUNK_TOKENS), 2)
9917            .await
9918            .unwrap();
9919        assert!(
9920            chunk_count > 1,
9921            "partition must be streamed in more than one chunk, got {chunk_count}"
9922        );
9923
9924        for token_id in 0..num_tokens {
9925            // The prewarmed posting cache entry is positions-free.
9926            let (start, end) = inverted_list.group_range_for_token(token_id).unwrap();
9927            let group = inverted_list
9928                .index_cache
9929                .get_with_key(&posting_list_group_cache_key(
9930                    start,
9931                    end,
9932                    inverted_list.has_impacts,
9933                ))
9934                .await
9935                .unwrap();
9936            let slot = (token_id - start) as usize;
9937            assert!(
9938                !group.is_packed(),
9939                "with-position prewarm should retain the materialized fallback"
9940            );
9941            assert!(
9942                !group
9943                    .posting_list(slot, None, None)
9944                    .unwrap()
9945                    .unwrap()
9946                    .has_position(),
9947                "token {token_id} posting cache entry must be positions-free after prewarm"
9948            );
9949
9950            // Full content (doc ids, frequencies, positions) round-trips; the
9951            // positions come from the dedicated per-token cache prewarm populated.
9952            let actual = inverted_list
9953                .posting_list(token_id, true, &NoOpMetricsCollector)
9954                .await
9955                .unwrap()
9956                .iter()
9957                .map(|(doc_id, freq, positions)| {
9958                    (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
9959                })
9960                .collect::<Vec<_>>();
9961            assert_eq!(
9962                actual, expected[token_id as usize],
9963                "token {token_id} posting list / positions mismatch after chunked prewarm"
9964            );
9965        }
9966    }
9967
9968    /// IO accounting for the IO-counting stats test below: tracks bytes
9969    /// pulled from the posting file so we can assert that the stats path is
9970    /// O(1) in num_unique_tokens.
9971    #[derive(Debug, Default)]
9972    struct PostingMetadataCounter {
9973        rows_read: std::sync::atomic::AtomicUsize,
9974        metadata_rows_read: std::sync::atomic::AtomicUsize,
9975        read_range_calls: std::sync::atomic::AtomicUsize,
9976    }
9977
9978    impl PostingMetadataCounter {
9979        fn rows_read(&self) -> usize {
9980            self.rows_read.load(std::sync::atomic::Ordering::Relaxed)
9981        }
9982        fn metadata_rows_read(&self) -> usize {
9983            self.metadata_rows_read
9984                .load(std::sync::atomic::Ordering::Relaxed)
9985        }
9986        fn read_range_calls(&self) -> usize {
9987            self.read_range_calls
9988                .load(std::sync::atomic::Ordering::Relaxed)
9989        }
9990    }
9991
9992    struct CountingPostingReader {
9993        inner: Arc<dyn IndexReader>,
9994        counter: Arc<PostingMetadataCounter>,
9995    }
9996
9997    #[async_trait]
9998    impl IndexReader for CountingPostingReader {
9999        async fn read_record_batch(&self, n: u64, batch_size: u64) -> Result<RecordBatch> {
10000            self.inner.read_record_batch(n, batch_size).await
10001        }
10002        async fn read_global_buffer(&self, index: u32) -> Result<bytes::Bytes> {
10003            self.inner.read_global_buffer(index).await
10004        }
10005        async fn read_range(
10006            &self,
10007            range: std::ops::Range<usize>,
10008            projection: Option<&[&str]>,
10009        ) -> Result<RecordBatch> {
10010            let n = range.end - range.start;
10011            self.counter
10012                .read_range_calls
10013                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
10014            self.counter
10015                .rows_read
10016                .fetch_add(n, std::sync::atomic::Ordering::Relaxed);
10017            let touches_metadata = projection
10018                .map(|cols| cols.contains(&MAX_SCORE_COL) || cols.contains(&LENGTH_COL))
10019                .unwrap_or(false);
10020            if touches_metadata {
10021                self.counter
10022                    .metadata_rows_read
10023                    .fetch_add(n, std::sync::atomic::Ordering::Relaxed);
10024            }
10025            self.inner.read_range(range, projection).await
10026        }
10027        async fn num_batches(&self, batch_size: u64) -> u32 {
10028            self.inner.num_batches(batch_size).await
10029        }
10030        fn num_rows(&self) -> usize {
10031            self.inner.num_rows()
10032        }
10033        fn schema(&self) -> &lance_core::datatypes::Schema {
10034            self.inner.schema()
10035        }
10036    }
10037
10038    #[derive(Debug)]
10039    struct CountingStore {
10040        inner: Arc<dyn IndexStore>,
10041        posting_file: String,
10042        counter: Arc<PostingMetadataCounter>,
10043    }
10044
10045    impl DeepSizeOf for CountingStore {
10046        fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
10047            self.inner.deep_size_of_children(context)
10048        }
10049    }
10050
10051    #[async_trait]
10052    impl IndexStore for CountingStore {
10053        fn as_any(&self) -> &dyn std::any::Any {
10054            self
10055        }
10056        fn clone_arc(&self) -> Arc<dyn IndexStore> {
10057            Arc::new(Self {
10058                inner: self.inner.clone(),
10059                posting_file: self.posting_file.clone(),
10060                counter: self.counter.clone(),
10061            })
10062        }
10063        fn io_parallelism(&self) -> usize {
10064            self.inner.io_parallelism()
10065        }
10066        fn with_io_priority(&self, io_priority: u64) -> Arc<dyn IndexStore> {
10067            Arc::new(Self {
10068                inner: self.inner.with_io_priority(io_priority),
10069                posting_file: self.posting_file.clone(),
10070                counter: self.counter.clone(),
10071            })
10072        }
10073        async fn new_index_file(
10074            &self,
10075            name: &str,
10076            schema: Arc<arrow_schema::Schema>,
10077        ) -> Result<Box<dyn crate::scalar::IndexWriter>> {
10078            self.inner.new_index_file(name, schema).await
10079        }
10080        async fn open_index_file(&self, name: &str) -> Result<Arc<dyn IndexReader>> {
10081            let reader = self.inner.open_index_file(name).await?;
10082            if name == self.posting_file {
10083                Ok(Arc::new(CountingPostingReader {
10084                    inner: reader,
10085                    counter: self.counter.clone(),
10086                }))
10087            } else {
10088                Ok(reader)
10089            }
10090        }
10091        async fn copy_index_file(
10092            &self,
10093            name: &str,
10094            dest_store: &dyn IndexStore,
10095        ) -> Result<crate::scalar::IndexFile> {
10096            self.inner.copy_index_file(name, dest_store).await
10097        }
10098        async fn copy_index_file_to(
10099            &self,
10100            name: &str,
10101            new_name: &str,
10102            dest_store: &dyn IndexStore,
10103        ) -> Result<crate::scalar::IndexFile> {
10104            self.inner
10105                .copy_index_file_to(name, new_name, dest_store)
10106                .await
10107        }
10108        async fn rename_index_file(
10109            &self,
10110            name: &str,
10111            new_name: &str,
10112        ) -> Result<crate::scalar::IndexFile> {
10113            self.inner.rename_index_file(name, new_name).await
10114        }
10115        async fn delete_index_file(&self, name: &str) -> Result<()> {
10116            self.inner.delete_index_file(name).await
10117        }
10118        async fn list_files_with_sizes(&self) -> Result<Vec<crate::scalar::IndexFile>> {
10119            self.inner.list_files_with_sizes().await
10120        }
10121    }
10122
10123    // Returns the `TempObjDir` guard so callers keep the backing store alive
10124    // for the index's lifetime: the deferred DocSet re-opens the docs file on
10125    // demand (it does not pin an open handle), so the files must still exist
10126    // when the test exercises a scoring path.
10127    async fn load_counted_v2_index(
10128        num_tokens: usize,
10129        cache: LanceCache,
10130    ) -> (Arc<InvertedIndex>, Arc<PostingMetadataCounter>, TempObjDir) {
10131        let tmpdir = TempObjDir::default();
10132        let inner_store = Arc::new(LanceIndexStore::new(
10133            ObjectStore::local().into(),
10134            tmpdir.clone(),
10135            Arc::new(LanceCache::no_cache()),
10136        ));
10137
10138        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
10139        for i in 0..num_tokens {
10140            builder.tokens.add(format!("t{}", i));
10141            let mut pl = PostingListBuilder::new(false);
10142            pl.add(i as u32, PositionRecorder::Count(1));
10143            builder.posting_lists.push(pl);
10144            builder.docs.append(i as u64, 1);
10145        }
10146        builder.write(inner_store.as_ref()).await.unwrap();
10147
10148        let metadata = HashMap::from([
10149            (
10150                "partitions".to_owned(),
10151                serde_json::to_string(&vec![0u64]).unwrap(),
10152            ),
10153            (
10154                "params".to_owned(),
10155                serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
10156            ),
10157            (
10158                TOKEN_SET_FORMAT_KEY.to_owned(),
10159                TokenSetFormat::default().to_string(),
10160            ),
10161        ]);
10162        let mut writer = inner_store
10163            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
10164            .await
10165            .unwrap();
10166        writer.finish_with_metadata(metadata).await.unwrap();
10167
10168        let counter = Arc::new(PostingMetadataCounter::default());
10169        let counting_store: Arc<dyn IndexStore> = Arc::new(CountingStore {
10170            inner: inner_store,
10171            posting_file: posting_file_path(0),
10172            counter: counter.clone(),
10173        });
10174        let index = InvertedIndex::load(counting_store, None, &cache)
10175            .await
10176            .unwrap();
10177        (index, counter, tmpdir)
10178    }
10179
10180    /// IO regression test for the lazy posting-metadata refactor. Builds a
10181    /// v2 InvertedIndex with `num_tokens` tokens in a single partition,
10182    /// wraps the IndexStore so reads against the posting file are counted,
10183    /// then asserts:
10184    ///
10185    /// * `InvertedIndex::load` does not touch the posting file at all
10186    ///   (`InvertedPartition::load` only needs the token file and docs file).
10187    /// * `bm25_stats_for_terms(["t0"])` reads exactly one metadata row from
10188    ///   the posting file for token 0 regardless of how many unique tokens the
10189    ///   partition has.
10190    ///
10191    /// Before this refactor, `PostingListReader::try_new` did
10192    /// `read_range(0..num_rows, [MAX_SCORE_COL, LENGTH_COL])`, so the
10193    /// `metadata_rows_read` figure scaled linearly with `num_tokens` even
10194    /// when nobody asked for those stats. The cases below exercise that
10195    /// scaling explicitly.
10196    #[rstest::rstest]
10197    #[case::tokens_10(10)]
10198    #[case::tokens_100(100)]
10199    #[case::tokens_1000(1000)]
10200    #[tokio::test]
10201    async fn test_bm25_stats_for_terms_is_lazy(#[case] num_tokens: usize) {
10202        let (index, counter, _tmpdir) =
10203            load_counted_v2_index(num_tokens, LanceCache::no_cache()).await;
10204        assert!(
10205            !index.partitions[0].inverted_list.is_legacy_layout(),
10206            "this test only proves the lazy path for v2 indexes",
10207        );
10208
10209        // Opening the partition must not pull anything from the posting file.
10210        // Pre-fix, `PostingListReader::try_new` issued one read_range here for
10211        // [MAX_SCORE_COL, LENGTH_COL] covering every unique token.
10212        assert_eq!(
10213            counter.read_range_calls(),
10214            0,
10215            "InvertedIndex::load must not read the posting file (was {} calls)",
10216            counter.read_range_calls(),
10217        );
10218        assert_eq!(counter.rows_read(), 0);
10219
10220        let (total_tokens, num_docs, dfs) = index
10221            .bm25_stats_for_terms(&["t0".to_string()], None)
10222            .await
10223            .unwrap();
10224        assert_eq!(total_tokens, num_tokens as u64);
10225        assert_eq!(num_docs, num_tokens);
10226        assert_eq!(dfs, vec![1]);
10227
10228        // Stats must pull a constant number of metadata rows from the posting
10229        // file regardless of how many tokens the partition has. One term, one
10230        // partition, one row.
10231        assert_eq!(
10232            counter.metadata_rows_read(),
10233            1,
10234            "stats path should read exactly 1 metadata row per (term, partition); \
10235             got {} (read_range_calls={}, rows_read={}, num_tokens={})",
10236            counter.metadata_rows_read(),
10237            counter.read_range_calls(),
10238            counter.rows_read(),
10239            num_tokens,
10240        );
10241    }
10242
10243    #[tokio::test]
10244    async fn test_bm25_stats_for_terms_reuses_posting_metadata_cache() {
10245        let cache = LanceCache::with_capacity(1024 * 1024);
10246        let (index, counter, _tmpdir) = load_counted_v2_index(100, cache.clone()).await;
10247
10248        let terms = ["t0".to_string()];
10249        let first = index.bm25_stats_for_terms(&terms, None).await.unwrap();
10250        assert_eq!(first, (100, 100, vec![1]));
10251        assert_eq!(counter.metadata_rows_read(), 1);
10252
10253        let second = index.bm25_stats_for_terms(&terms, None).await.unwrap();
10254        assert_eq!(second, first);
10255        assert_eq!(
10256            counter.metadata_rows_read(),
10257            1,
10258            "repeated stats for the same token should reuse cached posting metadata",
10259        );
10260    }
10261
10262    #[tokio::test]
10263    async fn test_bm25_stats_for_terms_records_metadata_cache_stats() {
10264        let cache = LanceCache::with_capacity(1024 * 1024);
10265        let (index, _counter, _tmpdir) = load_counted_v2_index(100, cache.clone()).await;
10266        assert!(
10267            !index.partitions[0].inverted_list.is_legacy_layout(),
10268            "this test only proves the v2 metadata boundary",
10269        );
10270
10271        let terms = ["t0".to_string(), "t1".to_string(), "t2".to_string()];
10272        let cold = LocalMetricsCollector::default();
10273        let cold_stats = index
10274            .bm25_stats_for_terms(&terms, Some(&cold))
10275            .await
10276            .unwrap();
10277        assert_eq!(cold_stats.2, vec![1, 1, 1]);
10278        assert_eq!(cold.index_cache_misses(), terms.len());
10279        assert_eq!(cold.index_cache_hits(), 0);
10280
10281        let warm = LocalMetricsCollector::default();
10282        let warm_stats = index
10283            .bm25_stats_for_terms(&terms, Some(&warm))
10284            .await
10285            .unwrap();
10286        assert_eq!(warm_stats, cold_stats);
10287        assert_eq!(warm.index_cache_misses(), 0);
10288        assert_eq!(warm.index_cache_hits(), terms.len());
10289    }
10290
10291    #[tokio::test]
10292    async fn test_aggregate_corpus_stats_reuses_cached_value() {
10293        let (index, _counter, _tmpdir) = load_counted_v2_index(100, LanceCache::no_cache()).await;
10294        assert!(index.corpus_stats.get().is_none());
10295
10296        let first = index.aggregate_corpus_stats().await.unwrap();
10297        assert_eq!(first, (100, 100));
10298        assert_eq!(index.corpus_stats.get().copied(), Some(first));
10299
10300        let second = index.aggregate_corpus_stats().await.unwrap();
10301        assert_eq!(second, first);
10302    }
10303
10304    #[tokio::test]
10305    async fn test_persisted_stats_do_not_load_document_columns() {
10306        let (index, _counter, _tmpdir) = load_counted_v2_index(100, LanceCache::no_cache()).await;
10307        assert!(!index.is_legacy());
10308        let partition = index.partitions[0].clone();
10309        let documents = partition.docs.modern().unwrap();
10310
10311        assert_eq!(index.aggregate_corpus_stats().await.unwrap(), (100, 100));
10312        assert_eq!(documents.cached_stats().unwrap().total_tokens, 100);
10313        assert!(!documents.lengths_loaded());
10314        assert!(!documents.projection_loaded());
10315
10316        let views = futures::future::join_all((0..8).map(|_| documents.lengths()))
10317            .await
10318            .into_iter()
10319            .collect::<Result<Vec<_>>>()
10320            .unwrap();
10321        let first = &views[0];
10322        assert!(views.iter().all(|view| Arc::ptr_eq(first, view)));
10323        assert_eq!(first.total_tokens(), 100);
10324
10325        let all_rows = RowAddrMask::all_rows();
10326        assert!(matches!(
10327            documents
10328                .visibility(Arc::new(all_rows), false)
10329                .await
10330                .unwrap(),
10331            DocVisibility::All
10332        ));
10333        assert!(!documents.projection_loaded());
10334
10335        let filtered = RowAddrMask::allow_nothing();
10336        let visibility = documents
10337            .visibility(Arc::new(filtered), true)
10338            .await
10339            .unwrap();
10340        assert!(visibility.is_empty());
10341        assert!(!documents.projection_loaded());
10342        assert_eq!(
10343            documents
10344                .resolve_addresses(&[DocId::new(0), DocId::new(99)])
10345                .await
10346                .unwrap(),
10347            [0, 99]
10348        );
10349    }
10350
10351    #[tokio::test]
10352    async fn test_no_hit_partition_does_not_load_document_columns() {
10353        let (index, _counter, _tmpdir) = load_counted_v2_index(100, LanceCache::no_cache()).await;
10354        let documents = index.partitions[0].docs.modern().unwrap();
10355        assert!(!documents.lengths_loaded());
10356        assert!(!documents.projection_loaded());
10357
10358        let tokens = Arc::new(Tokens::new(vec!["missing-token".to_owned()], DocType::Text));
10359        let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
10360        let (row_ids, scores) = index
10361            .bm25_search(
10362                tokens,
10363                params,
10364                Operator::Or,
10365                Arc::new(NoFilter),
10366                Arc::new(NoOpMetricsCollector),
10367                None,
10368            )
10369            .await
10370            .unwrap();
10371
10372        assert!(row_ids.is_empty());
10373        assert!(scores.is_empty());
10374        assert!(!documents.lengths_loaded());
10375        assert!(!documents.projection_loaded());
10376    }
10377
10378    #[tokio::test]
10379    async fn test_concurrent_stats_and_lengths_initialization() {
10380        let (index, _counter, _tmpdir) = load_counted_v2_index(100, LanceCache::no_cache()).await;
10381        let docs = index.partitions[0].docs.modern().unwrap().clone();
10382
10383        let stats = futures::future::join_all((0..8).map(|_| docs.stats()));
10384        let views = futures::future::join_all((0..8).map(|_| docs.lengths()));
10385        let (stats, views) = tokio::join!(stats, views);
10386
10387        let stats = stats.into_iter().collect::<Result<Vec<_>>>().unwrap();
10388        assert!(stats.iter().all(|stats| stats.total_tokens == 100));
10389        let views = views.into_iter().collect::<Result<Vec<_>>>().unwrap();
10390        let first = &views[0];
10391        assert!(views.iter().all(|view| Arc::ptr_eq(first, view)));
10392        assert_eq!(docs.cached_stats().unwrap().total_tokens, 100);
10393    }
10394
10395    #[tokio::test]
10396    async fn test_grouped_posting_lists_read_one_group_per_neighborhood() {
10397        // Cold-start scoring must not bulk-read the full `0..num_tokens`
10398        // metadata table. With small-posting grouping (issue #7040), scoring
10399        // K adjacent cold tokens shares a single group cache entry: one
10400        // read_range bounded by the group size, independent of the partition's
10401        // total token count.
10402        let runtime_group_size = runtime_posting_group_tokens().max(1);
10403        let queried_token_count = runtime_group_size.min(4);
10404        let queried_tokens = (0..queried_token_count as u32).collect::<Vec<_>>();
10405        let num_tokens = runtime_group_size
10406            .saturating_mul(2)
10407            .max(queried_token_count + 1)
10408            .min(1024);
10409        let (index, counter, _tmpdir) =
10410            load_counted_v2_index(num_tokens, LanceCache::no_cache()).await;
10411        let inverted_list = index.partitions[0].inverted_list.clone();
10412        assert!(
10413            !inverted_list.is_legacy_layout(),
10414            "this test only proves the lazy path for v2 indexes",
10415        );
10416        assert!(
10417            matches!(
10418                &inverted_list.grouping,
10419                PostingGrouping::SyntheticFixed { .. }
10420            ),
10421            "freshly written v2 index should use runtime synthetic groups",
10422        );
10423
10424        // This fixture uses a no-op cache, so each call re-reads; that isolates
10425        // the per-query read shape. Each posting_list call reads exactly its
10426        // own group — bounded by the group size, never the full token table.
10427        let metrics = Arc::new(NoOpMetricsCollector);
10428        for &token_id in &queried_tokens {
10429            inverted_list
10430                .posting_list(token_id, false, metrics.as_ref())
10431                .await
10432                .unwrap();
10433        }
10434
10435        let (start, end) = inverted_list.group_range_for_token(0).unwrap();
10436        let group_len = (end - start) as usize;
10437        assert!(
10438            (queried_tokens.len()..=num_tokens).contains(&group_len),
10439            "group [{start}, {end}) should cover the queried neighborhood and \
10440             stay bounded by the {num_tokens}-token table",
10441        );
10442        assert_eq!(
10443            counter.read_range_calls(),
10444            queried_tokens.len(),
10445            "each cold token should read exactly its own group, no bulk read",
10446        );
10447        assert_eq!(
10448            counter.metadata_rows_read(),
10449            queried_tokens.len() * group_len,
10450            "each query reads one group's metadata rows ({group_len}), not the \
10451             full {num_tokens}-row table",
10452        );
10453    }
10454
10455    /// Build a single-partition v2 index where every token's posting list spans
10456    /// `docs_per_token` docs. Runtime grouping packs consecutive token rows
10457    /// into shared cache groups.
10458    async fn load_v2_index_with_grouped_postings(
10459        num_tokens: usize,
10460        docs_per_token: usize,
10461    ) -> (Arc<InvertedIndex>, Arc<LanceCache>) {
10462        let tmpdir = TempObjDir::default();
10463        let store = Arc::new(LanceIndexStore::new(
10464            ObjectStore::local().into(),
10465            tmpdir.clone(),
10466            Arc::new(LanceCache::no_cache()),
10467        ));
10468
10469        let num_docs = num_tokens * docs_per_token;
10470        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
10471        for token_id in 0..num_tokens {
10472            builder.tokens.add(format!("t{token_id}"));
10473            let mut pl = PostingListBuilder::new(false);
10474            for d in 0..docs_per_token {
10475                let doc_id = (token_id * docs_per_token + d) as u32;
10476                pl.add(doc_id, PositionRecorder::Count(1));
10477            }
10478            builder.posting_lists.push(pl);
10479        }
10480        for doc in 0..num_docs {
10481            builder.docs.append(doc as u64, 1);
10482        }
10483        builder.write(store.as_ref()).await.unwrap();
10484
10485        let metadata = HashMap::from([
10486            (
10487                "partitions".to_owned(),
10488                serde_json::to_string(&vec![0u64]).unwrap(),
10489            ),
10490            (
10491                "params".to_owned(),
10492                serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
10493            ),
10494            (
10495                TOKEN_SET_FORMAT_KEY.to_owned(),
10496                TokenSetFormat::default().to_string(),
10497            ),
10498        ]);
10499        let mut writer = store
10500            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
10501            .await
10502            .unwrap();
10503        writer.finish_with_metadata(metadata).await.unwrap();
10504
10505        // The inverted list keeps only a `WeakLanceCache`, so the caller must
10506        // hold this `Arc<LanceCache>` alive for the cache to stay usable.
10507        let cache = Arc::new(LanceCache::with_capacity(1 << 30));
10508        let index = InvertedIndex::load(store, None, cache.as_ref())
10509            .await
10510            .unwrap();
10511        (index, cache)
10512    }
10513
10514    /// Packed groups charge their Arrow buffers and contiguous metadata once,
10515    /// avoiding the per-member enum/array object graph of a materialized group.
10516    #[tokio::test]
10517    async fn test_packed_group_deep_size_is_smaller_than_materialized_graph() {
10518        let (index, _cache) = load_v2_index_with_grouped_postings(512, 1).await;
10519        let inverted_list = index.partitions[0].inverted_list.clone();
10520        assert!(!inverted_list.is_legacy_layout(), "expected v2 layout");
10521        assert!(
10522            matches!(
10523                &inverted_list.grouping,
10524                PostingGrouping::SyntheticFixed { .. }
10525            ),
10526            "expected grouped posting lists"
10527        );
10528
10529        // Populate the group cache via the same path a query uses.
10530        inverted_list
10531            .posting_list(0, false, &NoOpMetricsCollector)
10532            .await
10533            .unwrap();
10534        let (start, end) = inverted_list.group_range_for_token(0).unwrap();
10535        let group = inverted_list
10536            .index_cache
10537            .get_with_key(&posting_list_group_cache_key(
10538                start,
10539                end,
10540                inverted_list.has_impacts,
10541            ))
10542            .await
10543            .unwrap();
10544        assert!(group.is_packed(), "cold v2 group should use packed storage");
10545        inverted_list.ensure_metadata_loaded().await.unwrap();
10546
10547        let mut distinct_buffers = std::collections::HashSet::new();
10548        let mut materialized = Vec::with_capacity(group.len());
10549        for slot in 0..group.len() {
10550            let (max_score, length) = inverted_list.bulk_metadata_for_token(start + slot as u32);
10551            let posting = group
10552                .posting_list(slot, max_score, length)
10553                .unwrap()
10554                .unwrap();
10555            let PostingList::Compressed(compressed) = posting else {
10556                panic!("expected compressed posting lists");
10557            };
10558            distinct_buffers.insert(compressed.blocks.values().as_ptr());
10559            materialized.push(PostingList::Compressed(compressed));
10560        }
10561        let posting_count = materialized.len();
10562
10563        assert!(
10564            posting_count > 1,
10565            "default grouping should pack multiple tiny postings into one group"
10566        );
10567        assert_eq!(
10568            distinct_buffers.len(),
10569            1,
10570            "read-path postings in a group should share one backing buffer"
10571        );
10572        let packed_size = group.deep_size_of();
10573        let materialized_size = PostingListGroup::new(materialized).deep_size_of();
10574        assert!(
10575            packed_size * 4 < materialized_size * 3,
10576            "packed group deep_size_of {packed_size}B should be at least 25% smaller than the \
10577             {materialized_size}B materialized graph for {posting_count} postings"
10578        );
10579    }
10580
10581    // ===========================================================================
10582    // Regression tests for index-cache size accounting of cached posting lists.
10583    //
10584    // A cached posting list is a *slice* of a buffer read for a whole posting-list
10585    // group, so its `DeepSizeOf` impl must charge only the bytes the slice
10586    // references, not the full shared backing buffer. These lock that in: each
10587    // builds an array that references a small slice of a much larger buffer and
10588    // asserts `deep_size_of()` tracks the slice, not the buffer.
10589    // ===========================================================================
10590
10591    /// Build a `List<Int32>` of `num_sublists` x `ints_per_sublist`, then return
10592    /// the slice `[off, off + len)`. The returned array shares the full backing
10593    /// buffers, so `values().get_buffer_memory_size()` still reports the whole
10594    /// thing — the slicing-unaware over-count the fix targets.
10595    fn sliced_int32_list(
10596        num_sublists: usize,
10597        ints_per_sublist: usize,
10598        off: usize,
10599        len: usize,
10600    ) -> ListArray {
10601        let mut builder = ListBuilder::new(Int32Builder::new());
10602        for s in 0..num_sublists {
10603            for i in 0..ints_per_sublist {
10604                builder
10605                    .values()
10606                    .append_value((s * ints_per_sublist + i) as i32);
10607            }
10608            builder.append(true);
10609        }
10610        builder.finish().slice(off, len)
10611    }
10612
10613    #[test]
10614    fn test_compressed_posting_deep_size_counts_only_referenced_blocks_slice() {
10615        const ELEM_BYTES: usize = 256;
10616        const TOTAL_ELEMS: usize = 64;
10617        const SLICE_OFF: usize = 10;
10618        const SLICE_LEN: usize = 2;
10619
10620        let mut builder = LargeBinaryBuilder::new();
10621        for _ in 0..TOTAL_ELEMS {
10622            builder.append_value(vec![7u8; ELEM_BYTES]);
10623        }
10624        let full = builder.finish();
10625        let blocks = full.slice(SLICE_OFF, SLICE_LEN);
10626
10627        let posting = CompressedPostingList::new(
10628            blocks,
10629            1.0,
10630            SLICE_LEN as u32,
10631            PostingTailCodec::Fixed32,
10632            LEGACY_BLOCK_SIZE,
10633            None,
10634            None,
10635        );
10636
10637        let full_backing = full.get_buffer_memory_size();
10638        let slice_bytes = SLICE_LEN * ELEM_BYTES;
10639        let reported = posting.deep_size_of();
10640
10641        assert!(
10642            reported < full_backing / 4,
10643            "deep_size_of {reported}B must not count the {full_backing}B shared buffer"
10644        );
10645        assert!(
10646            reported <= slice_bytes * 2,
10647            "deep_size_of {reported}B should track the ~{slice_bytes}B referenced slice"
10648        );
10649    }
10650
10651    #[test]
10652    fn test_plain_posting_deep_size_counts_only_referenced_positions_slice() {
10653        const SUBLISTS: usize = 64;
10654        const INTS: usize = 64;
10655        const SLICE_LEN: usize = 2;
10656
10657        let positions = sliced_int32_list(SUBLISTS, INTS, 10, SLICE_LEN);
10658        let row_ids = ScalarBuffer::from(vec![0u64, 1]);
10659        let frequencies = ScalarBuffer::from(vec![1.0f32, 1.0]);
10660        let posting =
10661            PlainPostingList::new(row_ids, frequencies, Some(1.0), Some(positions.clone()));
10662
10663        let full_backing = positions.values().get_buffer_memory_size();
10664        let slice_bytes = SLICE_LEN * INTS * std::mem::size_of::<i32>();
10665        let reported = posting.deep_size_of();
10666
10667        assert!(
10668            reported < full_backing / 4,
10669            "deep_size_of {reported}B must not count the {full_backing}B shared positions buffer"
10670        );
10671        assert!(
10672            reported <= slice_bytes * 2 + 64,
10673            "deep_size_of {reported}B should track the ~{slice_bytes}B referenced slice"
10674        );
10675    }
10676
10677    #[test]
10678    fn test_legacy_per_doc_positions_deep_size_counts_only_referenced_slice() {
10679        const SUBLISTS: usize = 64;
10680        const INTS: usize = 64;
10681        const SLICE_LEN: usize = 2;
10682
10683        let positions = sliced_int32_list(SUBLISTS, INTS, 10, SLICE_LEN);
10684        let full_backing = positions.values().get_buffer_memory_size();
10685        let slice_bytes = SLICE_LEN * INTS * std::mem::size_of::<i32>();
10686
10687        let storage = CompressedPositionStorage::LegacyPerDoc(positions);
10688        let reported = storage.deep_size_of();
10689        assert!(
10690            reported < full_backing / 4,
10691            "CompressedPositionStorage deep_size_of {reported}B must not count the \
10692             {full_backing}B shared buffer"
10693        );
10694        assert!(
10695            reported <= slice_bytes * 2 + 64,
10696            "deep_size_of {reported}B should track the ~{slice_bytes}B referenced slice"
10697        );
10698
10699        // The `Positions` cache wrapper must report the same slice-aware size.
10700        let wrapped = Positions(storage).deep_size_of();
10701        assert!(
10702            wrapped < full_backing / 4,
10703            "Positions deep_size_of {wrapped}B must not count the {full_backing}B shared buffer"
10704        );
10705    }
10706
10707    #[tokio::test]
10708    async fn test_prewarm_with_positions_populates_separate_position_cache() {
10709        let tmpdir = TempObjDir::default();
10710        let store = Arc::new(LanceIndexStore::new(
10711            ObjectStore::local().into(),
10712            tmpdir.clone(),
10713            Arc::new(LanceCache::no_cache()),
10714        ));
10715
10716        let mut builder = InnerBuilder::new_with_format_version(
10717            0,
10718            true,
10719            TokenSetFormat::default(),
10720            InvertedListFormatVersion::V1,
10721        );
10722        builder.tokens.add("hello".to_owned());
10723        builder.tokens.add("world".to_owned());
10724        builder
10725            .posting_lists
10726            .push(PostingListBuilder::new_with_posting_tail_codec(
10727                true,
10728                PostingTailCodec::Fixed32,
10729            ));
10730        builder
10731            .posting_lists
10732            .push(PostingListBuilder::new_with_posting_tail_codec(
10733                true,
10734                PostingTailCodec::Fixed32,
10735            ));
10736        builder.posting_lists[0].add(0, PositionRecorder::Position(vec![0].into()));
10737        builder.posting_lists[1].add(0, PositionRecorder::Position(vec![1].into()));
10738        builder.posting_lists[0].add(1, PositionRecorder::Position(vec![0].into()));
10739        builder.posting_lists[1].add(1, PositionRecorder::Position(vec![2].into()));
10740        builder.docs.append(100, 2);
10741        builder.docs.append(101, 2);
10742        builder.write(store.as_ref()).await.unwrap();
10743
10744        let metadata = std::collections::HashMap::from_iter(vec![
10745            (
10746                "partitions".to_owned(),
10747                serde_json::to_string(&vec![0_u64]).unwrap(),
10748            ),
10749            (
10750                "params".to_owned(),
10751                serde_json::to_string(&InvertedIndexParams::default().with_position(true)).unwrap(),
10752            ),
10753            (
10754                TOKEN_SET_FORMAT_KEY.to_owned(),
10755                TokenSetFormat::default().to_string(),
10756            ),
10757        ]);
10758        let mut writer = store
10759            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
10760            .await
10761            .unwrap();
10762        writer.finish_with_metadata(metadata).await.unwrap();
10763
10764        let cache = Arc::new(LanceCache::with_backend(Arc::new(
10765            QuickCacheBackend::with_capacity(4096),
10766        )));
10767        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
10768            .await
10769            .unwrap();
10770
10771        index
10772            .prewarm_with_options(&FtsPrewarmOptions::new().with_position(true))
10773            .await
10774            .unwrap();
10775
10776        let inverted_list = &index.partitions[0].inverted_list;
10777        // The posting cache entry is grouped (issue #7040); the group holds
10778        // positions-free lists while positions live in their own per-token
10779        // entries.
10780        let (start, end) = inverted_list.group_range_for_token(0).unwrap();
10781        let group = inverted_list
10782            .index_cache
10783            .get_with_key(&posting_list_group_cache_key(
10784                start,
10785                end,
10786                inverted_list.has_impacts,
10787            ))
10788            .await
10789            .unwrap();
10790        assert!(
10791            !group.is_packed(),
10792            "with-position prewarm should retain the materialized fallback"
10793        );
10794        assert!(
10795            !group
10796                .posting_list(0, None, None)
10797                .unwrap()
10798                .unwrap()
10799                .has_position(),
10800            "posting cache should remain positions-free after prewarm"
10801        );
10802
10803        let positions = inverted_list
10804            .index_cache
10805            .get_with_key(&PositionKey { token_id: 0 })
10806            .await
10807            .unwrap();
10808        assert!(
10809            matches!(
10810                positions.as_ref().0,
10811                CompressedPositionStorage::LegacyPerDoc(_)
10812            ),
10813            "positions should be stored in the dedicated position cache"
10814        );
10815
10816        drop(positions);
10817        drop(group);
10818        cache.clear().await;
10819        assert!(
10820            inverted_list
10821                .index_cache
10822                .get_with_key(&PositionKey { token_id: 0 })
10823                .await
10824                .is_none()
10825        );
10826
10827        index
10828            .prewarm_with_options(&FtsPrewarmOptions::default())
10829            .await
10830            .unwrap();
10831        assert!(index.prewarm_state.lock().await.satisfies(true));
10832        assert!(
10833            inverted_list
10834                .index_cache
10835                .get_with_key(&PositionKey { token_id: 0 })
10836                .await
10837                .is_some(),
10838            "re-prewarm after eviction must preserve the strongest requested mode"
10839        );
10840    }
10841
10842    #[tokio::test]
10843    async fn test_prewarm_with_v2_positions_preserves_shared_stream_codec() {
10844        let tmpdir = TempObjDir::default();
10845        let store = Arc::new(LanceIndexStore::new(
10846            ObjectStore::local().into(),
10847            tmpdir.clone(),
10848            Arc::new(LanceCache::no_cache()),
10849        ));
10850
10851        let format_version = InvertedListFormatVersion::V2;
10852        let posting_tail_codec = format_version.posting_tail_codec();
10853        let mut builder = InnerBuilder::new_with_format_version(
10854            0,
10855            true,
10856            TokenSetFormat::default(),
10857            format_version,
10858        );
10859        builder.tokens.add("body".to_owned());
10860
10861        let mut posting_list =
10862            PostingListBuilder::new_with_posting_tail_codec(true, posting_tail_codec);
10863        let expected = (0..(BLOCK_SIZE + 5) as u32)
10864            .map(|doc_id| {
10865                let positions = vec![doc_id % 3, doc_id % 3 + 2, doc_id % 3 + 5];
10866                posting_list.add(doc_id, PositionRecorder::Position(positions.clone().into()));
10867                builder.docs.append(30_000 + doc_id as u64, 20 + doc_id % 7);
10868                (doc_id, positions.len() as u32, positions)
10869            })
10870            .collect::<Vec<_>>();
10871        builder.posting_lists.push(posting_list);
10872        builder.write(store.as_ref()).await.unwrap();
10873
10874        let metadata = HashMap::from([
10875            (
10876                "partitions".to_owned(),
10877                serde_json::to_string(&vec![0_u64]).unwrap(),
10878            ),
10879            (
10880                "params".to_owned(),
10881                serde_json::to_string(&InvertedIndexParams::default().with_position(true)).unwrap(),
10882            ),
10883            (
10884                TOKEN_SET_FORMAT_KEY.to_owned(),
10885                TokenSetFormat::default().to_string(),
10886            ),
10887            (
10888                POSTING_TAIL_CODEC_KEY.to_owned(),
10889                posting_tail_codec.as_str().to_owned(),
10890            ),
10891            (
10892                POSITIONS_LAYOUT_KEY.to_owned(),
10893                POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(),
10894            ),
10895            (
10896                POSITIONS_CODEC_KEY.to_owned(),
10897                PositionStreamCodec::PackedDelta.as_str().to_owned(),
10898            ),
10899        ]);
10900        let mut writer = store
10901            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
10902            .await
10903            .unwrap();
10904        writer.finish_with_metadata(metadata).await.unwrap();
10905
10906        let cache = Arc::new(LanceCache::with_capacity(4096));
10907        let index = InvertedIndex::load(store, None, cache.as_ref())
10908            .await
10909            .unwrap();
10910        index
10911            .prewarm_with_options(&FtsPrewarmOptions::new().with_position(true))
10912            .await
10913            .unwrap();
10914
10915        let actual = index.partitions[0]
10916            .inverted_list
10917            .posting_list(0, true, &NoOpMetricsCollector)
10918            .await
10919            .unwrap()
10920            .iter()
10921            .map(|(doc_id, freq, positions)| {
10922                (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
10923            })
10924            .collect::<Vec<_>>();
10925
10926        assert_eq!(actual, expected);
10927    }
10928
10929    #[test]
10930    fn test_block_max_scores_capacity_matches_block_count() {
10931        let mut docs = DocSet::default();
10932        let num_docs = BLOCK_SIZE * 3 + 7;
10933        let doc_ids = (0..num_docs as u32).collect::<Vec<_>>();
10934        for doc_id in &doc_ids {
10935            docs.append(*doc_id as u64, 1);
10936        }
10937
10938        let freqs = vec![1_u32; doc_ids.len()];
10939        let block_max_scores = docs.calculate_block_max_scores(doc_ids.iter(), freqs.iter());
10940        let expected_blocks = doc_ids.len().div_ceil(BLOCK_SIZE);
10941
10942        assert_eq!(block_max_scores.len(), expected_blocks);
10943        assert_eq!(block_max_scores.capacity(), expected_blocks);
10944    }
10945
10946    #[tokio::test]
10947    async fn test_bm25_search_uses_global_idf() {
10948        let tmpdir = TempObjDir::default();
10949        let store = Arc::new(LanceIndexStore::new(
10950            ObjectStore::local().into(),
10951            tmpdir.clone(),
10952            Arc::new(LanceCache::no_cache()),
10953        ));
10954
10955        // Partition 0: 3 docs, only one contains "alpha".
10956        let mut builder0 = InnerBuilder::new(0, false, TokenSetFormat::default());
10957        builder0.tokens.add("alpha".to_owned());
10958        builder0.tokens.add("beta".to_owned());
10959        builder0.posting_lists.push(PostingListBuilder::new(false));
10960        builder0.posting_lists.push(PostingListBuilder::new(false));
10961        builder0.posting_lists[0].add(0, PositionRecorder::Count(1));
10962        builder0.posting_lists[1].add(1, PositionRecorder::Count(1));
10963        builder0.posting_lists[1].add(2, PositionRecorder::Count(1));
10964        builder0.docs.append(100, 1);
10965        builder0.docs.append(101, 1);
10966        builder0.docs.append(102, 1);
10967        builder0.write(store.as_ref()).await.unwrap();
10968
10969        // Partition 1: 1 doc, contains "alpha".
10970        let mut builder1 = InnerBuilder::new(1, false, TokenSetFormat::default());
10971        builder1.tokens.add("alpha".to_owned());
10972        builder1.posting_lists.push(PostingListBuilder::new(false));
10973        builder1.posting_lists[0].add(0, PositionRecorder::Count(1));
10974        builder1.docs.append(200, 1);
10975        builder1.write(store.as_ref()).await.unwrap();
10976
10977        let metadata = std::collections::HashMap::from_iter(vec![
10978            (
10979                "partitions".to_owned(),
10980                serde_json::to_string(&vec![0u64, 1u64]).unwrap(),
10981            ),
10982            (
10983                "params".to_owned(),
10984                serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
10985            ),
10986            (
10987                TOKEN_SET_FORMAT_KEY.to_owned(),
10988                TokenSetFormat::default().to_string(),
10989            ),
10990        ]);
10991        let mut writer = store
10992            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
10993            .await
10994            .unwrap();
10995        writer.finish_with_metadata(metadata).await.unwrap();
10996
10997        let cache = Arc::new(LanceCache::with_capacity(4096));
10998        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
10999            .await
11000            .unwrap();
11001
11002        let tokens = Arc::new(Tokens::new(vec!["alpha".to_string()], DocType::Text));
11003        let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
11004        let prefilter = Arc::new(NoFilter);
11005        let metrics = Arc::new(NoOpMetricsCollector);
11006
11007        let (row_ids, scores) = index
11008            .bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
11009            .await
11010            .unwrap();
11011
11012        assert_eq!(row_ids.len(), 2);
11013        assert!(row_ids.contains(&100));
11014        assert!(row_ids.contains(&200));
11015        assert_eq!(row_ids.len(), scores.len());
11016
11017        let expected_idf = idf(2, 4);
11018        for score in scores {
11019            assert!(
11020                (score - expected_idf).abs() < 1e-6,
11021                "score: {}, expected: {}",
11022                score,
11023                expected_idf
11024            );
11025        }
11026    }
11027
11028    async fn write_test_metadata(
11029        store: &Arc<LanceIndexStore>,
11030        partition_ids: Vec<u64>,
11031        params: InvertedIndexParams,
11032    ) {
11033        let format_version = params.resolved_format_version();
11034        let metadata = HashMap::from([
11035            (
11036                "partitions".to_owned(),
11037                serde_json::to_string(&partition_ids).unwrap(),
11038            ),
11039            ("params".to_owned(), serde_json::to_string(&params).unwrap()),
11040            (
11041                TOKEN_SET_FORMAT_KEY.to_owned(),
11042                TokenSetFormat::default().to_string(),
11043            ),
11044            (
11045                POSTING_TAIL_CODEC_KEY.to_owned(),
11046                format_version.posting_tail_codec().as_str().to_owned(),
11047            ),
11048            (
11049                FTS_FORMAT_VERSION_KEY.to_owned(),
11050                format_version.index_version().to_string(),
11051            ),
11052            (
11053                POSTING_BLOCK_SIZE_KEY.to_owned(),
11054                params.posting_block_size().to_string(),
11055            ),
11056        ]);
11057        let mut writer = store
11058            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
11059            .await
11060            .unwrap();
11061        writer.finish_with_metadata(metadata).await.unwrap();
11062    }
11063
11064    async fn write_test_partition_with_optional_impacts(
11065        store: &Arc<LanceIndexStore>,
11066        partition_id: u64,
11067        mut builder: InnerBuilder,
11068        token_set_format: TokenSetFormat,
11069        with_impacts: bool,
11070    ) {
11071        let format_version = InvertedListFormatVersion::V1;
11072        let block_size = LEGACY_BLOCK_SIZE;
11073        let docs = std::mem::take(&mut builder.docs);
11074        let schema = inverted_list_schema_for_version_with_block_size_and_impacts(
11075            false,
11076            format_version,
11077            block_size,
11078            with_impacts,
11079        );
11080
11081        let mut posting_writer = store
11082            .new_index_file(&posting_file_path(partition_id), schema.clone())
11083            .await
11084            .unwrap();
11085        for posting_list in std::mem::take(&mut builder.posting_lists) {
11086            let batch = posting_list
11087                .to_batch_with_docs(&docs, schema.clone())
11088                .unwrap();
11089            posting_writer.write_record_batch(batch).await.unwrap();
11090        }
11091        posting_writer.finish().await.unwrap();
11092
11093        let token_batch = std::mem::take(&mut builder.tokens)
11094            .to_batch(token_set_format)
11095            .unwrap();
11096        let mut token_writer = store
11097            .new_index_file(&token_file_path(partition_id), token_batch.schema())
11098            .await
11099            .unwrap();
11100        token_writer.write_record_batch(token_batch).await.unwrap();
11101        token_writer.finish().await.unwrap();
11102
11103        let doc_batch = docs.to_batch().unwrap();
11104        let mut doc_writer = store
11105            .new_index_file(&doc_file_path(partition_id), doc_batch.schema())
11106            .await
11107            .unwrap();
11108        doc_writer.write_record_batch(doc_batch).await.unwrap();
11109        doc_writer.finish().await.unwrap();
11110    }
11111
11112    async fn load_global_scoring_test_index(
11113        first_partition_has_impacts: bool,
11114        second_partition_has_impacts: bool,
11115    ) -> (TempObjDir, Arc<LanceCache>, Arc<InvertedIndex>) {
11116        let tmpdir = TempObjDir::default();
11117        let store = Arc::new(LanceIndexStore::new(
11118            ObjectStore::local().into(),
11119            tmpdir.clone(),
11120            Arc::new(LanceCache::no_cache()),
11121        ));
11122        let partition_specs = [
11123            (0, 100, 5_000, 101..111, 5_000, first_partition_has_impacts),
11124            (1, 200, 1_000, 201..301, 1, second_partition_has_impacts),
11125        ];
11126        for (
11127            partition_id,
11128            matching_row_id,
11129            matching_doc_length,
11130            other_row_ids,
11131            other_doc_length,
11132            with_impacts,
11133        ) in partition_specs
11134        {
11135            let mut builder = InnerBuilder::new_with_format_version(
11136                partition_id,
11137                false,
11138                TokenSetFormat::default(),
11139                InvertedListFormatVersion::V1,
11140            );
11141            builder.tokens.add("alpha".to_owned());
11142            builder
11143                .posting_lists
11144                .push(PostingListBuilder::new_with_posting_tail_codec(
11145                    false,
11146                    InvertedListFormatVersion::V1.posting_tail_codec(),
11147                ));
11148            builder.posting_lists[0].add(0, PositionRecorder::Count(1));
11149            builder.docs.append(matching_row_id, matching_doc_length);
11150            for row_id in other_row_ids {
11151                builder.docs.append(row_id, other_doc_length);
11152            }
11153            write_test_partition_with_optional_impacts(
11154                &store,
11155                partition_id,
11156                builder,
11157                TokenSetFormat::default(),
11158                with_impacts,
11159            )
11160            .await;
11161        }
11162
11163        write_test_metadata(&store, vec![0, 1], InvertedIndexParams::default()).await;
11164        let cache = Arc::new(LanceCache::with_backend(Arc::new(
11165            QuickCacheBackend::with_capacity(4096),
11166        )));
11167        let index = InvertedIndex::load(store, None, cache.as_ref())
11168            .await
11169            .unwrap();
11170        (tmpdir, cache, index)
11171    }
11172
11173    #[tokio::test]
11174    async fn test_chunked_modern_search_preserves_cold_and_prewarmed_results() {
11175        let tmpdir = TempObjDir::default();
11176        let store = Arc::new(LanceIndexStore::new(
11177            ObjectStore::local().into(),
11178            tmpdir.clone(),
11179            Arc::new(LanceCache::no_cache()),
11180        ));
11181        let matching_partitions = 17_u64;
11182        for partition_id in 0..matching_partitions {
11183            let mut builder = InnerBuilder::new(partition_id, false, TokenSetFormat::default());
11184            builder.tokens.add("pipeline".to_owned());
11185            builder.posting_lists.push(PostingListBuilder::new(false));
11186            builder.posting_lists[0].add(0, PositionRecorder::Count(1));
11187            builder.docs.append(partition_id * 1_000 + 7, 1);
11188            builder.write(store.as_ref()).await.unwrap();
11189        }
11190        let unmatched_partition = matching_partitions;
11191        let mut builder = InnerBuilder::new(unmatched_partition, false, TokenSetFormat::default());
11192        builder.tokens.add("unrelated".to_owned());
11193        builder.posting_lists.push(PostingListBuilder::new(false));
11194        builder.posting_lists[0].add(0, PositionRecorder::Count(1));
11195        builder.docs.append(999_999, 1);
11196        builder.write(store.as_ref()).await.unwrap();
11197
11198        write_test_metadata(
11199            &store,
11200            (0..=unmatched_partition).collect(),
11201            InvertedIndexParams::default(),
11202        )
11203        .await;
11204        let cache = Arc::new(LanceCache::with_capacity(64 * 1024 * 1024));
11205        let index = InvertedIndex::load(store, None, cache.as_ref())
11206            .await
11207            .unwrap();
11208        let tokens = Arc::new(Tokens::new(vec!["pipeline".to_owned()], DocType::Text));
11209        let params =
11210            Arc::new(FtsSearchParams::new().with_limit(Some(matching_partitions as usize)));
11211
11212        let search = || {
11213            index.bm25_search(
11214                tokens.clone(),
11215                params.clone(),
11216                Operator::Or,
11217                Arc::new(NoFilter),
11218                Arc::new(NoOpMetricsCollector),
11219                None,
11220            )
11221        };
11222        let (mut cold_row_ids, cold_scores) = search().await.unwrap();
11223        cold_row_ids.sort_unstable();
11224        let expected = (0..matching_partitions)
11225            .map(|partition_id| partition_id * 1_000 + 7)
11226            .collect::<Vec<_>>();
11227        assert_eq!(cold_row_ids, expected);
11228        assert_eq!(cold_scores.len(), expected.len());
11229
11230        index
11231            .prewarm_with_options(&FtsPrewarmOptions::default())
11232            .await
11233            .unwrap();
11234        let (mut prewarmed_row_ids, prewarmed_scores) = search().await.unwrap();
11235        prewarmed_row_ids.sort_unstable();
11236        assert_eq!(prewarmed_row_ids, expected);
11237        assert_eq!(prewarmed_scores, cold_scores);
11238    }
11239
11240    #[tokio::test]
11241    async fn test_prewarmed_modern_search_uses_resident_address_projection() {
11242        let (_tmpdir, cache, index) = load_global_scoring_test_index(true, true).await;
11243        let tokens = Arc::new(Tokens::new(vec!["alpha".to_owned()], DocType::Text));
11244        let params = Arc::new(FtsSearchParams::new().with_limit(Some(2)));
11245
11246        assert!(!index.has_resident_document_projections());
11247        let deferred = index
11248            .bm25_search(
11249                tokens.clone(),
11250                params.clone(),
11251                Operator::Or,
11252                Arc::new(NoFilter),
11253                Arc::new(NoOpMetricsCollector),
11254                None,
11255            )
11256            .await
11257            .unwrap();
11258        assert!(!index.has_resident_document_projections());
11259
11260        index.partitions[0]
11261            .docs
11262            .modern()
11263            .unwrap()
11264            .prewarm()
11265            .await
11266            .unwrap();
11267        assert!(index.partitions[0].docs.query_ready());
11268        assert!(!index.has_resident_document_projections());
11269        let partially_resident = index
11270            .bm25_search(
11271                tokens.clone(),
11272                params.clone(),
11273                Operator::Or,
11274                Arc::new(NoFilter),
11275                Arc::new(NoOpMetricsCollector),
11276                None,
11277            )
11278            .await
11279            .unwrap();
11280        assert_eq!(partially_resident, deferred);
11281
11282        let prewarm_options = FtsPrewarmOptions::default();
11283        futures::future::join_all((0..8).map(|_| index.prewarm_with_options(&prewarm_options)))
11284            .await
11285            .into_iter()
11286            .collect::<Result<Vec<_>>>()
11287            .unwrap();
11288        assert!(index.document_projections_resident.load(Ordering::Acquire));
11289        assert!(index.has_resident_document_projections());
11290        assert!(index.corpus_stats.initialized());
11291        assert!(index.partitions.iter().all(|partition| {
11292            partition.docs.query_ready()
11293                && partition.inverted_list.modern_posting_validation_ready()
11294        }));
11295        assert!(index.prewarm_state.lock().await.satisfies(false));
11296
11297        let resident = index
11298            .bm25_search(
11299                tokens.clone(),
11300                params.clone(),
11301                Operator::Or,
11302                Arc::new(NoFilter),
11303                Arc::new(NoOpMetricsCollector),
11304                None,
11305            )
11306            .await
11307            .unwrap();
11308        assert_eq!(resident, deferred);
11309        assert_eq!(resident.0.len(), 2);
11310        assert!(resident.0.contains(&100));
11311        assert!(resident.0.contains(&200));
11312
11313        cache.clear().await;
11314        assert!(index.document_projections_resident.load(Ordering::Acquire));
11315        assert_eq!(cache.size().await, 0);
11316        let resident_address_owners = index
11317            .partitions
11318            .iter()
11319            .map(|partition| {
11320                partition
11321                    .docs
11322                    .modern()
11323                    .unwrap()
11324                    .address_buffer_handle()
11325                    .strong_count()
11326            })
11327            .collect::<Vec<_>>();
11328        assert_eq!(resident_address_owners, vec![0, 0]);
11329        assert!(
11330            index
11331                .partitions
11332                .iter()
11333                .all(|partition| { !partition.docs.modern().unwrap().projection_resident() })
11334        );
11335
11336        let after_eviction = index
11337            .bm25_search(
11338                tokens.clone(),
11339                params.clone(),
11340                Operator::Or,
11341                Arc::new(NoFilter),
11342                Arc::new(NoOpMetricsCollector),
11343                None,
11344            )
11345            .await
11346            .unwrap();
11347        assert_eq!(after_eviction, deferred);
11348        assert!(!index.document_projections_resident.load(Ordering::Acquire));
11349
11350        cache.clear().await;
11351        index.prewarm_with_options(&prewarm_options).await.unwrap();
11352        assert!(index.document_projections_resident_now());
11353        assert!(index.document_projections_resident.load(Ordering::Acquire));
11354
11355        let re_prewarms_after_eviction = index
11356            .bm25_search(
11357                tokens,
11358                params,
11359                Operator::Or,
11360                Arc::new(NoFilter),
11361                Arc::new(NoOpMetricsCollector),
11362                None,
11363            )
11364            .await
11365            .unwrap();
11366        assert_eq!(re_prewarms_after_eviction, deferred);
11367    }
11368
11369    #[tokio::test]
11370    async fn test_resident_modern_search_loads_partition_stats_without_global_stats() {
11371        let (_tmpdir, _cache, index) = load_global_scoring_test_index(true, false).await;
11372        assert!(index.corpus_stats.get().is_none());
11373        assert!(
11374            index
11375                .partitions
11376                .iter()
11377                .all(|partition| partition.docs.cached_stats().is_none())
11378        );
11379
11380        for partition in &index.partitions {
11381            partition
11382                .docs
11383                .modern()
11384                .unwrap()
11385                .address_projection()
11386                .await
11387                .unwrap();
11388        }
11389        assert!(index.has_resident_document_projections());
11390
11391        let scorer = MemBM25Scorer::new(56_100, 112, HashMap::from([("alpha".to_owned(), 2)]));
11392        let result = index
11393            .bm25_search(
11394                Arc::new(Tokens::new(vec!["alpha".to_owned()], DocType::Text)),
11395                Arc::new(FtsSearchParams::new().with_limit(Some(2))),
11396                Operator::Or,
11397                Arc::new(NoFilter),
11398                Arc::new(NoOpMetricsCollector),
11399                Some(&scorer),
11400            )
11401            .await
11402            .unwrap();
11403
11404        assert_eq!(result.0.len(), 2);
11405        assert!(result.0.contains(&100));
11406        assert!(result.0.contains(&200));
11407        assert!(index.corpus_stats.get().is_none());
11408        assert!(
11409            index
11410                .partitions
11411                .iter()
11412                .all(|partition| partition.docs.cached_stats().is_some())
11413        );
11414    }
11415
11416    async fn search_test_impact_partition(
11417        partition: &InvertedPartition,
11418        tokens: &Tokens,
11419        params: &FtsSearchParams,
11420        scorer: Arc<MemBM25Scorer>,
11421        shared_threshold: Arc<AtomicU32>,
11422    ) -> Vec<DocCandidate<DocId>> {
11423        let LoadedPostings {
11424            postings,
11425            grouped_expansions,
11426            impact_safe,
11427            exact_scoring_required,
11428        } = partition
11429            .load_posting_lists(
11430                tokens,
11431                params,
11432                Operator::Or,
11433                scorer.as_ref(),
11434                &NoOpMetricsCollector,
11435            )
11436            .await
11437            .unwrap();
11438        assert!(impact_safe);
11439        assert!(!exact_scoring_required);
11440        assert!(grouped_expansions.is_empty());
11441
11442        let documents = partition.docs.modern().unwrap();
11443        let lengths = documents.lengths().await.unwrap();
11444        let visibility = documents.visibility(NoFilter.mask(), false).await.unwrap();
11445        partition
11446            .bm25_search_modern(
11447                lengths.as_ref(),
11448                &visibility,
11449                params,
11450                Operator::Or,
11451                postings,
11452                Some(scorer),
11453                &NoOpMetricsCollector,
11454                shared_threshold,
11455            )
11456            .unwrap()
11457    }
11458
11459    #[tokio::test]
11460    async fn test_impact_partitions_share_global_threshold_without_pruning_winner() {
11461        // Partition 0 wins under its local corpus statistics but loses under
11462        // the global statistics. If its local score escapes into the shared
11463        // floor, partition 1 will incorrectly prune the real global winner.
11464        let (_tmpdir, _cache, index) = load_global_scoring_test_index(true, true).await;
11465        let first_partition = index
11466            .partitions
11467            .iter()
11468            .find(|partition| partition.id() == 0)
11469            .unwrap();
11470        let second_partition = index
11471            .partitions
11472            .iter()
11473            .find(|partition| partition.id() == 1)
11474            .unwrap();
11475
11476        let tokens = Arc::new(Tokens::new(vec!["alpha".to_owned()], DocType::Text));
11477        let params = Arc::new(FtsSearchParams::new().with_limit(Some(1)));
11478        let scorer = Arc::new(
11479            index
11480                .bm25_base_scorer(tokens.as_ref(), params.as_ref(), None)
11481                .await
11482                .unwrap(),
11483        );
11484        first_partition
11485            .inverted_list
11486            .ensure_metadata_loaded()
11487            .await
11488            .unwrap();
11489        second_partition
11490            .inverted_list
11491            .ensure_metadata_loaded()
11492            .await
11493            .unwrap();
11494        let first_local_scorer = IndexBM25Scorer::new(std::iter::once(first_partition.as_ref()));
11495        let second_local_scorer = IndexBM25Scorer::new(std::iter::once(second_partition.as_ref()));
11496        let first_local_score =
11497            first_local_scorer.query_weight("alpha") * first_local_scorer.doc_weight(1, 5_000);
11498        let second_local_score =
11499            second_local_scorer.query_weight("alpha") * second_local_scorer.doc_weight(1, 1_000);
11500        assert!(first_local_score > second_local_score);
11501        let shared_threshold = Arc::new(AtomicU32::new(f32::NEG_INFINITY.to_bits()));
11502
11503        // Search sequentially so partition 0 deterministically publishes its
11504        // score before partition 1 evaluates its impact upper bound.
11505        let first_candidates = search_test_impact_partition(
11506            first_partition,
11507            tokens.as_ref(),
11508            params.as_ref(),
11509            scorer.clone(),
11510            shared_threshold.clone(),
11511        )
11512        .await;
11513        assert_eq!(first_candidates.len(), 1);
11514        assert_eq!(first_candidates[0].document, DocId::new(0));
11515        let first_score =
11516            scorer.query_weight("alpha") * scorer.doc_weight(1, first_candidates[0].doc_length);
11517        let published_threshold = f32::from_bits(shared_threshold.load(Ordering::Relaxed));
11518        assert!(
11519            (published_threshold - first_score).abs() < 1e-6,
11520            "published threshold: {published_threshold}, expected global score: {first_score}"
11521        );
11522
11523        let second_candidates = search_test_impact_partition(
11524            second_partition,
11525            tokens.as_ref(),
11526            params.as_ref(),
11527            scorer.clone(),
11528            shared_threshold.clone(),
11529        )
11530        .await;
11531        assert_eq!(second_candidates.len(), 1);
11532        assert_eq!(second_candidates[0].document, DocId::new(0));
11533        let second_score =
11534            scorer.query_weight("alpha") * scorer.doc_weight(1, second_candidates[0].doc_length);
11535        assert!(
11536            second_score > first_score,
11537            "second score: {second_score}, first score: {first_score}"
11538        );
11539        assert!(
11540            (f32::from_bits(shared_threshold.load(Ordering::Relaxed)) - second_score).abs() < 1e-6
11541        );
11542
11543        let (row_ids, scores) = index
11544            .bm25_search(
11545                tokens,
11546                params,
11547                Operator::Or,
11548                Arc::new(NoFilter),
11549                Arc::new(NoOpMetricsCollector),
11550                None,
11551            )
11552            .await
11553            .unwrap();
11554        assert_eq!(row_ids, vec![200]);
11555        assert_eq!(scores.len(), 1);
11556        assert!((scores[0] - second_score).abs() < 1e-6);
11557    }
11558
11559    #[tokio::test]
11560    async fn test_mixed_impact_and_legacy_partitions_use_global_final_scores() {
11561        let (_tmpdir, _cache, index) = load_global_scoring_test_index(true, false).await;
11562
11563        let impact_partition = index
11564            .partitions
11565            .iter()
11566            .find(|partition| partition.id() == 0)
11567            .unwrap();
11568        let legacy_partition = index
11569            .partitions
11570            .iter()
11571            .find(|partition| partition.id() == 1)
11572            .unwrap();
11573
11574        let impact_posting = impact_partition
11575            .inverted_list
11576            .posting_list(0, false, &NoOpMetricsCollector)
11577            .await
11578            .unwrap();
11579        assert!(impact_posting.has_impacts());
11580
11581        let legacy_posting = legacy_partition
11582            .inverted_list
11583            .posting_list(0, false, &NoOpMetricsCollector)
11584            .await
11585            .unwrap();
11586        assert!(!legacy_posting.has_impacts());
11587
11588        let tokens = Arc::new(Tokens::new(vec!["alpha".to_string()], DocType::Text));
11589        let params = Arc::new(FtsSearchParams::new().with_limit(Some(1)));
11590        let (row_ids, scores) = index
11591            .bm25_search(
11592                tokens.clone(),
11593                params.clone(),
11594                Operator::Or,
11595                Arc::new(NoFilter),
11596                Arc::new(NoOpMetricsCollector),
11597                None,
11598            )
11599            .await
11600            .unwrap();
11601
11602        assert_eq!(row_ids, vec![200]);
11603        assert_eq!(row_ids.len(), scores.len());
11604
11605        let scorer = index
11606            .bm25_base_scorer(tokens.as_ref(), params.as_ref(), None)
11607            .await
11608            .unwrap();
11609        let expected_score = scorer.query_weight("alpha") * scorer.doc_weight(1, 1_000);
11610        assert!(
11611            (scores[0] - expected_score).abs() < 1e-6,
11612            "score: {}, expected: {}",
11613            scores[0],
11614            expected_score
11615        );
11616    }
11617
11618    #[tokio::test]
11619    async fn test_two_legacy_partitions_keep_private_thresholds() {
11620        // Legacy BM25 scores use partition-local statistics, so sharing one
11621        // pruning floor across partitions can discard the global winner.
11622        let (_tmpdir, _cache, index) = load_global_scoring_test_index(false, false).await;
11623        for partition in index.partitions.iter() {
11624            let posting = partition
11625                .inverted_list
11626                .posting_list(0, false, &NoOpMetricsCollector)
11627                .await
11628                .unwrap();
11629            assert!(!posting.has_impacts());
11630        }
11631
11632        let tokens = Arc::new(Tokens::new(vec!["alpha".to_string()], DocType::Text));
11633        let params = Arc::new(FtsSearchParams::new().with_limit(Some(1)));
11634        let (row_ids, scores) = index
11635            .bm25_search(
11636                tokens.clone(),
11637                params.clone(),
11638                Operator::Or,
11639                Arc::new(NoFilter),
11640                Arc::new(NoOpMetricsCollector),
11641                None,
11642            )
11643            .await
11644            .unwrap();
11645
11646        assert_eq!(row_ids, vec![200]);
11647        assert_eq!(scores.len(), 1);
11648        let scorer = index
11649            .bm25_base_scorer(tokens.as_ref(), params.as_ref(), None)
11650            .await
11651            .unwrap();
11652        let expected_score = scorer.query_weight("alpha") * scorer.doc_weight(1, 1_000);
11653        assert!(
11654            (scores[0] - expected_score).abs() < 1e-6,
11655            "score: {}, expected global score: {}",
11656            scores[0],
11657            expected_score
11658        );
11659    }
11660
11661    #[tokio::test]
11662    async fn test_and_query_returns_empty_when_exact_term_missing() {
11663        let tmpdir = TempObjDir::default();
11664        let store = Arc::new(LanceIndexStore::new(
11665            ObjectStore::local().into(),
11666            tmpdir.clone(),
11667            Arc::new(LanceCache::no_cache()),
11668        ));
11669
11670        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
11671        builder.tokens.add("alpha".to_owned());
11672        builder.posting_lists.push(PostingListBuilder::new(false));
11673        builder.posting_lists[0].add(0, PositionRecorder::Count(1));
11674        builder.docs.append(100, 1);
11675        builder.write(store.as_ref()).await.unwrap();
11676
11677        write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
11678        let cache = Arc::new(LanceCache::with_capacity(4096));
11679        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
11680            .await
11681            .unwrap();
11682
11683        let tokens = Arc::new(Tokens::new(
11684            vec!["alpha".to_owned(), "missing".to_owned()],
11685            DocType::Text,
11686        ));
11687        let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
11688        let prefilter = Arc::new(NoFilter);
11689        let metrics = Arc::new(NoOpMetricsCollector);
11690
11691        let (and_row_ids, _) = index
11692            .bm25_search(
11693                tokens.clone(),
11694                params.clone(),
11695                Operator::And,
11696                prefilter.clone(),
11697                metrics.clone(),
11698                None,
11699            )
11700            .await
11701            .unwrap();
11702        assert!(
11703            and_row_ids.is_empty(),
11704            "AND must not match when any required term is missing"
11705        );
11706
11707        let (or_row_ids, _) = index
11708            .bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
11709            .await
11710            .unwrap();
11711        assert_eq!(
11712            or_row_ids,
11713            vec![100],
11714            "OR should still match the present term"
11715        );
11716    }
11717
11718    #[tokio::test]
11719    async fn test_and_query_accepts_same_position_alternatives() {
11720        let tmpdir = TempObjDir::default();
11721        let store = Arc::new(LanceIndexStore::new(
11722            ObjectStore::local().into(),
11723            tmpdir.clone(),
11724            Arc::new(LanceCache::no_cache()),
11725        ));
11726
11727        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
11728        for token in ["getusername", "get", "user", "name"] {
11729            builder.tokens.add(token.to_owned());
11730            builder.posting_lists.push(PostingListBuilder::new(false));
11731        }
11732        // Doc 0 only has the split words. Doc 1 has both the complete
11733        // identifier and split words. A grouped AND query should accept either
11734        // `getusername` or `get` at position 0.
11735        builder.posting_lists[1].add(0, PositionRecorder::Count(1));
11736        builder.posting_lists[2].add(0, PositionRecorder::Count(1));
11737        builder.posting_lists[3].add(0, PositionRecorder::Count(1));
11738        builder.docs.append(100, 3);
11739
11740        builder.posting_lists[0].add(1, PositionRecorder::Count(1));
11741        builder.posting_lists[1].add(1, PositionRecorder::Count(1));
11742        builder.posting_lists[2].add(1, PositionRecorder::Count(1));
11743        builder.posting_lists[3].add(1, PositionRecorder::Count(1));
11744        builder.docs.append(101, 4);
11745        builder.write(store.as_ref()).await.unwrap();
11746
11747        write_test_metadata(&store, vec![0], InvertedIndexParams::code()).await;
11748        let index = InvertedIndex::load(store.clone(), None, &LanceCache::no_cache())
11749            .await
11750            .unwrap();
11751
11752        let tokens = Arc::new(Tokens::with_positions(
11753            vec![
11754                "getusername".to_string(),
11755                "get".to_string(),
11756                "user".to_string(),
11757                "name".to_string(),
11758            ],
11759            vec![0, 0, 1, 2],
11760            DocType::Text,
11761        ));
11762        let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
11763        let (mut row_ids, _) = index
11764            .bm25_search(
11765                tokens,
11766                params,
11767                Operator::And,
11768                Arc::new(NoFilter),
11769                Arc::new(NoOpMetricsCollector),
11770                None,
11771            )
11772            .await
11773            .unwrap();
11774        row_ids.sort_unstable();
11775        assert_eq!(row_ids, vec![100, 101]);
11776    }
11777
11778    #[tokio::test]
11779    async fn test_phrase_query_accepts_same_position_alternatives() {
11780        let tmpdir = TempObjDir::default();
11781        let store = Arc::new(LanceIndexStore::new(
11782            ObjectStore::local().into(),
11783            tmpdir.clone(),
11784            Arc::new(LanceCache::no_cache()),
11785        ));
11786
11787        let mut builder = InnerBuilder::new(0, true, TokenSetFormat::default());
11788        for token in ["getusername", "get", "user", "name"] {
11789            builder.tokens.add(token.to_owned());
11790            builder.posting_lists.push(PostingListBuilder::new(true));
11791        }
11792        // Doc 0 only has split words. Doc 1 has both the complete identifier
11793        // and split words at the same position. Doc 2 has the terms but not as
11794        // an exact phrase.
11795        builder.posting_lists[1].add(0, PositionRecorder::Position(vec![0].into()));
11796        builder.posting_lists[2].add(0, PositionRecorder::Position(vec![1].into()));
11797        builder.posting_lists[3].add(0, PositionRecorder::Position(vec![2].into()));
11798        builder.docs.append(100, 3);
11799
11800        builder.posting_lists[0].add(1, PositionRecorder::Position(vec![0].into()));
11801        builder.posting_lists[1].add(1, PositionRecorder::Position(vec![0].into()));
11802        builder.posting_lists[2].add(1, PositionRecorder::Position(vec![1].into()));
11803        builder.posting_lists[3].add(1, PositionRecorder::Position(vec![2].into()));
11804        builder.docs.append(101, 3);
11805
11806        builder.posting_lists[0].add(2, PositionRecorder::Position(vec![0].into()));
11807        builder.posting_lists[2].add(2, PositionRecorder::Position(vec![2].into()));
11808        builder.posting_lists[3].add(2, PositionRecorder::Position(vec![3].into()));
11809        builder.docs.append(102, 3);
11810
11811        builder.write(store.as_ref()).await.unwrap();
11812
11813        write_test_metadata(
11814            &store,
11815            vec![0],
11816            InvertedIndexParams::code().with_position(true),
11817        )
11818        .await;
11819        let index = InvertedIndex::load(store.clone(), None, &LanceCache::no_cache())
11820            .await
11821            .unwrap();
11822
11823        let tokens = Arc::new(Tokens::with_positions(
11824            vec![
11825                "getusername".to_string(),
11826                "get".to_string(),
11827                "user".to_string(),
11828                "name".to_string(),
11829            ],
11830            vec![0, 0, 1, 2],
11831            DocType::Text,
11832        ));
11833        let params = Arc::new(
11834            FtsSearchParams::new()
11835                .with_limit(Some(10))
11836                .with_phrase_slop(Some(0)),
11837        );
11838        let (mut row_ids, _) = index
11839            .bm25_search(
11840                tokens,
11841                params,
11842                Operator::And,
11843                Arc::new(NoFilter),
11844                Arc::new(NoOpMetricsCollector),
11845                None,
11846            )
11847            .await
11848            .unwrap();
11849        row_ids.sort_unstable();
11850        assert_eq!(row_ids, vec![100, 101]);
11851    }
11852
11853    // Enough distinct tokens that `write_posting_lists` emits several posting-list
11854    // batches (the default batch size is 256 rows), exercising the restructured
11855    // producer and async send path.
11856    const MANY_BATCH_TOKENS: u64 = 1000;
11857    const MANY_BATCH_ROW_ID_BASE: u64 = 1000;
11858
11859    // Writes a single partition whose posting lists span many output batches. Each
11860    // token `tok{i:05}` maps to row id `MANY_BATCH_ROW_ID_BASE + i`.
11861    async fn write_partition_spanning_many_batches(store: &dyn IndexStore) {
11862        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
11863        for i in 0..MANY_BATCH_TOKENS {
11864            // Zero-padded so tokens are inserted in sorted order, as the set expects.
11865            builder.tokens.add(format!("tok{i:05}"));
11866            let doc_id = builder.docs.append(MANY_BATCH_ROW_ID_BASE + i, 1);
11867            let mut posting_list = PostingListBuilder::new(false);
11868            posting_list.add(doc_id, PositionRecorder::Count(1));
11869            builder.posting_lists.push(posting_list);
11870        }
11871        builder
11872            .write(store)
11873            .await
11874            .expect("writing posting lists should succeed");
11875    }
11876
11877    // Correctness guard for the restructured posting-list writer. The producer now
11878    // builds each batch in its own `spawn_cpu` call, handing the builder and the
11879    // remaining posting lists back out so state (the cross-batch cache-group
11880    // accumulator) is preserved, and dispatches with an async `send().await`. This
11881    // verifies that path over many batches by checking representative tokens after
11882    // they cross the producer/consumer boundary.
11883    //
11884    // Note: this does not reproduce the single-thread-pool deadlock the async send
11885    // fixes -- that requires a 1-thread CPU pool (a process-global singleton) plus
11886    // ~8MB of buffered posting data to trigger a consumer-side encoder flush, which
11887    // is impractical as a lightweight unit test.
11888    #[tokio::test]
11889    async fn test_write_many_posting_list_batches_preserves_all_batches() {
11890        let tmpdir = TempObjDir::default();
11891        let store = Arc::new(LanceIndexStore::new(
11892            ObjectStore::local().into(),
11893            tmpdir.clone(),
11894            Arc::new(LanceCache::no_cache()),
11895        ));
11896
11897        write_partition_spanning_many_batches(store.as_ref()).await;
11898
11899        write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
11900        let cache = Arc::new(LanceCache::with_capacity(4096));
11901        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
11902            .await
11903            .unwrap();
11904
11905        // Probe tokens from the first, a middle, and the last batch to confirm they
11906        // remain queryable after crossing the producer/consumer boundary.
11907        for token_idx in [0u64, MANY_BATCH_TOKENS / 2, MANY_BATCH_TOKENS - 1] {
11908            let tokens = Arc::new(Tokens::new(
11909                vec![format!("tok{token_idx:05}")],
11910                DocType::Text,
11911            ));
11912            let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
11913            let (row_ids, _) = index
11914                .bm25_search(
11915                    tokens,
11916                    params,
11917                    Operator::Or,
11918                    Arc::new(NoFilter),
11919                    Arc::new(NoOpMetricsCollector),
11920                    None,
11921                )
11922                .await
11923                .unwrap();
11924            assert_eq!(
11925                row_ids,
11926                vec![MANY_BATCH_ROW_ID_BASE + token_idx],
11927                "token tok{token_idx:05} should map to its single document"
11928            );
11929        }
11930    }
11931
11932    #[tokio::test]
11933    async fn test_and_query_skips_partition_missing_required_term() {
11934        let tmpdir = TempObjDir::default();
11935        let store = Arc::new(LanceIndexStore::new(
11936            ObjectStore::local().into(),
11937            tmpdir.clone(),
11938            Arc::new(LanceCache::no_cache()),
11939        ));
11940
11941        let mut builder0 = InnerBuilder::new(0, false, TokenSetFormat::default());
11942        builder0.tokens.add("alpha".to_owned());
11943        builder0.posting_lists.push(PostingListBuilder::new(false));
11944        builder0.posting_lists[0].add(0, PositionRecorder::Count(1));
11945        builder0.docs.append(100, 1);
11946        builder0.write(store.as_ref()).await.unwrap();
11947
11948        let mut builder1 = InnerBuilder::new(1, false, TokenSetFormat::default());
11949        builder1.tokens.add("alpha".to_owned());
11950        builder1.tokens.add("beta".to_owned());
11951        builder1.posting_lists.push(PostingListBuilder::new(false));
11952        builder1.posting_lists.push(PostingListBuilder::new(false));
11953        builder1.posting_lists[0].add(0, PositionRecorder::Count(1));
11954        builder1.posting_lists[1].add(0, PositionRecorder::Count(1));
11955        builder1.docs.append(200, 2);
11956        builder1.write(store.as_ref()).await.unwrap();
11957
11958        write_test_metadata(&store, vec![0, 1], InvertedIndexParams::default()).await;
11959        let cache = Arc::new(LanceCache::with_capacity(4096));
11960        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
11961            .await
11962            .unwrap();
11963
11964        let tokens = Arc::new(Tokens::new(
11965            vec!["alpha".to_owned(), "beta".to_owned()],
11966            DocType::Text,
11967        ));
11968        let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
11969        let (mut row_ids, _) = index
11970            .bm25_search(
11971                tokens,
11972                params,
11973                Operator::And,
11974                Arc::new(NoFilter),
11975                Arc::new(NoOpMetricsCollector),
11976                None,
11977            )
11978            .await
11979            .unwrap();
11980        row_ids.sort_unstable();
11981        assert_eq!(
11982            row_ids,
11983            vec![200],
11984            "partition missing beta must not contribute alpha-only hits"
11985        );
11986    }
11987
11988    #[tokio::test]
11989    async fn test_fuzzy_and_groups_expansions_by_original_position() {
11990        let tmpdir = TempObjDir::default();
11991        let store = Arc::new(LanceIndexStore::new(
11992            ObjectStore::local().into(),
11993            tmpdir.clone(),
11994            Arc::new(LanceCache::no_cache()),
11995        ));
11996
11997        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
11998        builder.tokens.add("alpha".to_owned());
11999        builder.tokens.add("alphi".to_owned());
12000        builder.tokens.add("beta".to_owned());
12001        builder.posting_lists.push(PostingListBuilder::new(false));
12002        builder.posting_lists.push(PostingListBuilder::new(false));
12003        builder.posting_lists.push(PostingListBuilder::new(false));
12004        builder.posting_lists[0].add(0, PositionRecorder::Count(1));
12005        builder.posting_lists[1].add(1, PositionRecorder::Count(1));
12006        builder.posting_lists[2].add(0, PositionRecorder::Count(1));
12007        builder.posting_lists[2].add(1, PositionRecorder::Count(1));
12008        builder.docs.append(100, 2);
12009        builder.docs.append(101, 2);
12010        builder.write(store.as_ref()).await.unwrap();
12011
12012        write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
12013        let cache = Arc::new(LanceCache::with_capacity(4096));
12014        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
12015            .await
12016            .unwrap();
12017        let params = Arc::new(
12018            FtsSearchParams::new()
12019                .with_limit(Some(10))
12020                .with_fuzziness(Some(1)),
12021        );
12022
12023        let missing_position_tokens = Arc::new(Tokens::new(
12024            vec!["betx".to_owned(), "zzzzz".to_owned()],
12025            DocType::Text,
12026        ));
12027        let (missing_and_row_ids, _) = index
12028            .bm25_search(
12029                missing_position_tokens.clone(),
12030                params.clone(),
12031                Operator::And,
12032                Arc::new(NoFilter),
12033                Arc::new(NoOpMetricsCollector),
12034                None,
12035            )
12036            .await
12037            .unwrap();
12038        assert!(
12039            missing_and_row_ids.is_empty(),
12040            "fuzzy AND must require at least one expansion for every original position"
12041        );
12042
12043        let (mut or_row_ids, _) = index
12044            .bm25_search(
12045                missing_position_tokens,
12046                params.clone(),
12047                Operator::Or,
12048                Arc::new(NoFilter),
12049                Arc::new(NoOpMetricsCollector),
12050                None,
12051            )
12052            .await
12053            .unwrap();
12054        or_row_ids.sort_unstable();
12055        assert_eq!(
12056            or_row_ids,
12057            vec![100, 101],
12058            "OR should still match present fuzzy expansions"
12059        );
12060
12061        let grouped_tokens = Arc::new(Tokens::new(
12062            vec!["alphx".to_owned(), "betx".to_owned()],
12063            DocType::Text,
12064        ));
12065        let (mut grouped_row_ids, _) = index
12066            .bm25_search(
12067                grouped_tokens,
12068                params,
12069                Operator::And,
12070                Arc::new(NoFilter),
12071                Arc::new(NoOpMetricsCollector),
12072                None,
12073            )
12074            .await
12075            .unwrap();
12076        grouped_row_ids.sort_unstable();
12077        assert_eq!(
12078            grouped_row_ids,
12079            vec![100, 101],
12080            "each original fuzzy position should match any one of its expansions"
12081        );
12082    }
12083
12084    #[tokio::test]
12085    async fn test_fuzzy_expansion_cap_applies_to_whole_query() {
12086        let tmpdir = TempObjDir::default();
12087        let store = Arc::new(LanceIndexStore::new(
12088            ObjectStore::local().into(),
12089            tmpdir.clone(),
12090            Arc::new(LanceCache::no_cache()),
12091        ));
12092
12093        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12094        for token in ["alpha", "alphi", "beta", "beti"] {
12095            builder.tokens.add(token.to_owned());
12096            builder.posting_lists.push(PostingListBuilder::new(false));
12097        }
12098        for token_id in 0..4 {
12099            builder.posting_lists[token_id].add(token_id as u32, PositionRecorder::Count(1));
12100            builder.docs.append(100 + token_id as u64, 1);
12101        }
12102        builder.write(store.as_ref()).await.unwrap();
12103
12104        write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
12105        let cache = Arc::new(LanceCache::with_capacity(4096));
12106        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
12107            .await
12108            .unwrap();
12109        let partition = index.partitions[0].clone();
12110        let params = FtsSearchParams::new()
12111            .with_fuzziness(Some(1))
12112            .with_max_expansions(3);
12113        let tokens = Tokens::new(vec!["alphx".to_owned(), "betx".to_owned()], DocType::Text);
12114
12115        let expanded = partition.expand_fuzzy(&tokens, &params).unwrap();
12116        let expanded_terms = (0..expanded.len())
12117            .map(|idx| (expanded.get_token(idx).to_owned(), expanded.position(idx)))
12118            .collect::<Vec<_>>();
12119
12120        assert_eq!(
12121            expanded_terms,
12122            vec![
12123                ("alpha".to_owned(), 0),
12124                ("alphi".to_owned(), 0),
12125                ("beta".to_owned(), 1),
12126            ],
12127            "max_expansions should cap the whole fuzzy query, not each token"
12128        );
12129    }
12130
12131    /// Write one partition holding `variants` in order, with one
12132    /// single-token doc per variant taken from `row_ids`.
12133    async fn write_variant_partition(
12134        store: &Arc<LanceIndexStore>,
12135        partition_id: u64,
12136        variants: &[&str],
12137        row_ids: &[u64],
12138    ) {
12139        let mut builder = InnerBuilder::new(partition_id, false, TokenSetFormat::default());
12140        for token in variants {
12141            builder.tokens.add((*token).to_owned());
12142            builder.posting_lists.push(PostingListBuilder::new(false));
12143        }
12144        for (local_idx, row_id) in row_ids.iter().enumerate() {
12145            builder.posting_lists[local_idx].add(local_idx as u32, PositionRecorder::Count(1));
12146            builder.docs.append(*row_id, 1);
12147        }
12148        builder.write(store.as_ref()).await.unwrap();
12149    }
12150
12151    #[tokio::test]
12152    async fn test_fuzzy_expansion_cap_is_global_across_partitions() {
12153        let tmpdir = TempObjDir::default();
12154        let store = Arc::new(LanceIndexStore::new(
12155            ObjectStore::local().into(),
12156            tmpdir.clone(),
12157            Arc::new(LanceCache::no_cache()),
12158        ));
12159
12160        write_variant_partition(&store, 0, &["alpha", "alphb"], &[100, 101]).await;
12161        write_variant_partition(&store, 1, &["alphc", "alphd"], &[102, 103]).await;
12162        write_test_metadata(&store, vec![0, 1], InvertedIndexParams::default()).await;
12163        let cache = Arc::new(LanceCache::with_capacity(4096));
12164        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
12165            .await
12166            .unwrap();
12167
12168        let params = FtsSearchParams::new()
12169            .with_fuzziness(Some(1))
12170            .with_max_expansions(3);
12171        let tokens = Tokens::new(vec!["alphx".to_owned()], DocType::Text);
12172
12173        let expanded = index.expand_fuzzy_tokens(&tokens, &params).unwrap();
12174        let expanded_terms = (0..expanded.len())
12175            .map(|idx| expanded.get_token(idx).to_owned())
12176            .collect::<Vec<_>>();
12177        assert_eq!(
12178            expanded_terms,
12179            vec!["alpha".to_owned(), "alphb".to_owned(), "alphc".to_owned()],
12180            "max_expansions must cap the whole query across partitions, \
12181             in lexicographic order"
12182        );
12183    }
12184
12185    #[tokio::test]
12186    async fn test_fuzzy_results_independent_of_partition_shape() {
12187        // The same four single-variant docs, laid out as one partition and
12188        // as two. With a binding max_expansions the two shapes must still
12189        // match the same documents with the same scores.
12190        let single_dir = TempObjDir::default();
12191        let single_store = Arc::new(LanceIndexStore::new(
12192            ObjectStore::local().into(),
12193            single_dir.clone(),
12194            Arc::new(LanceCache::no_cache()),
12195        ));
12196        write_variant_partition(
12197            &single_store,
12198            0,
12199            &["alpha", "alphb", "alphc", "alphd"],
12200            &[100, 101, 102, 103],
12201        )
12202        .await;
12203        write_test_metadata(&single_store, vec![0], InvertedIndexParams::default()).await;
12204
12205        let split_dir = TempObjDir::default();
12206        let split_store = Arc::new(LanceIndexStore::new(
12207            ObjectStore::local().into(),
12208            split_dir.clone(),
12209            Arc::new(LanceCache::no_cache()),
12210        ));
12211        write_variant_partition(&split_store, 0, &["alpha", "alphb"], &[100, 101]).await;
12212        write_variant_partition(&split_store, 1, &["alphc", "alphd"], &[102, 103]).await;
12213        write_test_metadata(&split_store, vec![0, 1], InvertedIndexParams::default()).await;
12214
12215        let params = Arc::new(
12216            FtsSearchParams::new()
12217                .with_limit(Some(10))
12218                .with_fuzziness(Some(1))
12219                .with_max_expansions(3),
12220        );
12221
12222        let mut results = Vec::new();
12223        for store in [single_store, split_store] {
12224            let cache = LanceCache::with_capacity(4096);
12225            let index = InvertedIndex::load(store, None, &cache).await.unwrap();
12226            let tokens = Arc::new(Tokens::new(vec!["alphx".to_owned()], DocType::Text));
12227            let (row_ids, scores) = index
12228                .bm25_search(
12229                    tokens,
12230                    params.clone(),
12231                    Operator::Or,
12232                    Arc::new(NoFilter),
12233                    Arc::new(NoOpMetricsCollector),
12234                    None,
12235                )
12236                .await
12237                .unwrap();
12238            let mut scored = row_ids.into_iter().zip(scores).collect::<Vec<_>>();
12239            scored.sort_unstable_by_key(|(row_id, _)| *row_id);
12240            results.push(scored);
12241        }
12242
12243        assert_eq!(
12244            results[0]
12245                .iter()
12246                .map(|(row_id, _)| *row_id)
12247                .collect::<Vec<_>>(),
12248            vec![100, 101, 102],
12249            "a binding cap keeps the three lexicographically smallest variants"
12250        );
12251        assert_eq!(
12252            results[0], results[1],
12253            "fuzzy results must not depend on the partition shape"
12254        );
12255    }
12256
12257    #[tokio::test]
12258    async fn test_fuzzy_and_scores_grouped_expansions_by_matched_token() {
12259        let tmpdir = TempObjDir::default();
12260        let store = Arc::new(LanceIndexStore::new(
12261            ObjectStore::local().into(),
12262            tmpdir.clone(),
12263            Arc::new(LanceCache::no_cache()),
12264        ));
12265
12266        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12267        builder.tokens.add("alpha".to_owned());
12268        builder.tokens.add("alphi".to_owned());
12269        builder.tokens.add("beta".to_owned());
12270        builder.posting_lists.push(PostingListBuilder::new(false));
12271        builder.posting_lists.push(PostingListBuilder::new(false));
12272        builder.posting_lists.push(PostingListBuilder::new(false));
12273        builder.posting_lists[0].add(0, PositionRecorder::Count(1));
12274        builder.posting_lists[0].add(2, PositionRecorder::Count(1));
12275        builder.posting_lists[0].add(3, PositionRecorder::Count(1));
12276        builder.posting_lists[0].add(4, PositionRecorder::Count(1));
12277        builder.posting_lists[0].add(5, PositionRecorder::Count(1));
12278        builder.posting_lists[1].add(1, PositionRecorder::Count(1));
12279        builder.posting_lists[2].add(0, PositionRecorder::Count(1));
12280        builder.posting_lists[2].add(1, PositionRecorder::Count(1));
12281        builder.docs.append(100, 2);
12282        builder.docs.append(101, 2);
12283        builder.docs.append(102, 1);
12284        builder.docs.append(103, 1);
12285        builder.docs.append(104, 1);
12286        builder.docs.append(105, 1);
12287        builder.write(store.as_ref()).await.unwrap();
12288
12289        write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
12290        let cache = Arc::new(LanceCache::with_capacity(4096));
12291        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
12292            .await
12293            .unwrap();
12294
12295        let tokens = Arc::new(Tokens::new(
12296            vec!["alphx".to_owned(), "betx".to_owned()],
12297            DocType::Text,
12298        ));
12299        let params = Arc::new(
12300            FtsSearchParams::new()
12301                .with_limit(Some(1))
12302                .with_fuzziness(Some(1)),
12303        );
12304        let (row_ids, _scores) = index
12305            .bm25_search(
12306                tokens,
12307                params,
12308                Operator::And,
12309                Arc::new(NoFilter),
12310                Arc::new(NoOpMetricsCollector),
12311                None,
12312            )
12313            .await
12314            .unwrap();
12315
12316        assert_eq!(
12317            row_ids,
12318            vec![101],
12319            "the rare matched expansion should outrank the common expansion"
12320        );
12321    }
12322
12323    #[rstest::rstest]
12324    #[case::and(Operator::And)]
12325    #[case::or(Operator::Or)]
12326    #[tokio::test]
12327    async fn test_grouped_scoring_keeps_exact_winner_outside_proxy_window(
12328        #[case] operator: Operator,
12329    ) {
12330        let tmpdir = TempObjDir::default();
12331        let store = Arc::new(LanceIndexStore::new(
12332            ObjectStore::local().into(),
12333            tmpdir.clone(),
12334            Arc::new(LanceCache::no_cache()),
12335        ));
12336
12337        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12338        builder.tokens.add("common".to_owned());
12339        builder.tokens.add("rare".to_owned());
12340        builder.posting_lists.push(PostingListBuilder::new(false));
12341        builder.posting_lists.push(PostingListBuilder::new(false));
12342        for doc_id in 0..3 {
12343            builder.posting_lists[0].add(doc_id, PositionRecorder::Count(1));
12344            builder.docs.append(100 + doc_id as u64, 1);
12345        }
12346        builder.posting_lists[1].add(3, PositionRecorder::Count(1));
12347        builder.docs.append(103, 2);
12348        builder.write(store.as_ref()).await.unwrap();
12349
12350        write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
12351        let cache = Arc::new(LanceCache::with_capacity(4096));
12352        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
12353            .await
12354            .unwrap();
12355
12356        let tokens = Arc::new(Tokens::with_positions(
12357            vec!["common".to_owned(), "rare".to_owned()],
12358            vec![0, 0],
12359            DocType::Text,
12360        ));
12361        let params = Arc::new(FtsSearchParams::new().with_limit(Some(1)));
12362        let (row_ids, _scores) = index
12363            .bm25_search(
12364                tokens,
12365                params,
12366                operator,
12367                Arc::new(NoFilter),
12368                Arc::new(NoOpMetricsCollector),
12369                None,
12370            )
12371            .await
12372            .unwrap();
12373
12374        assert_eq!(
12375            row_ids,
12376            vec![103],
12377            "the rare term's exact IDF must win even when proxy scoring ranks it outside the old candidate cushion"
12378        );
12379    }
12380
12381    #[tokio::test]
12382    async fn test_fuzzy_and_grouped_rescore_keeps_wand_limit_bounded() {
12383        let tmpdir = TempObjDir::default();
12384        let store = Arc::new(LanceIndexStore::new(
12385            ObjectStore::local().into(),
12386            tmpdir.clone(),
12387            Arc::new(LanceCache::no_cache()),
12388        ));
12389
12390        let num_docs = BLOCK_SIZE * 2 + 4;
12391        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12392        builder.tokens.add("alpha".to_owned());
12393        builder.tokens.add("alphi".to_owned());
12394        builder.tokens.add("beta".to_owned());
12395        builder.posting_lists.push(PostingListBuilder::new(false));
12396        builder.posting_lists.push(PostingListBuilder::new(false));
12397        builder.posting_lists.push(PostingListBuilder::new(false));
12398
12399        builder.posting_lists[0].add(0, PositionRecorder::Count(1));
12400        builder.posting_lists[1].add(1, PositionRecorder::Count(1));
12401        for doc_id in 0..num_docs {
12402            builder.posting_lists[2].add(doc_id as u32, PositionRecorder::Count(1));
12403            if doc_id >= 2 {
12404                builder.posting_lists[0].add(doc_id as u32, PositionRecorder::Count(1));
12405            }
12406            let num_tokens = if doc_id < 2 { 2 } else { 100 };
12407            builder.docs.append(100 + doc_id as u64, num_tokens);
12408        }
12409        builder.write(store.as_ref()).await.unwrap();
12410
12411        write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
12412        let cache = Arc::new(LanceCache::with_capacity(4096));
12413        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
12414            .await
12415            .unwrap();
12416
12417        let tokens = Arc::new(Tokens::new(
12418            vec!["alphx".to_owned(), "betx".to_owned()],
12419            DocType::Text,
12420        ));
12421        let params = Arc::new(
12422            FtsSearchParams::new()
12423                .with_limit(Some(1))
12424                .with_fuzziness(Some(1)),
12425        );
12426        let metrics = Arc::new(LocalMetricsCollector::default());
12427        let (row_ids, _scores) = index
12428            .bm25_search(
12429                tokens,
12430                params,
12431                Operator::And,
12432                Arc::new(NoFilter),
12433                metrics.clone(),
12434                None,
12435            )
12436            .await
12437            .unwrap();
12438
12439        assert_eq!(
12440            row_ids,
12441            vec![101],
12442            "final rescoring should still rank by the matched expansion"
12443        );
12444        let comparisons = metrics.comparisons.load(Ordering::Relaxed);
12445        assert!(
12446            comparisons < num_docs,
12447            "grouped fuzzy AND should not clear the WAND top-k bound and scan every candidate; comparisons={comparisons}, num_docs={num_docs}"
12448        );
12449    }
12450
12451    #[tokio::test]
12452    async fn test_phrase_query_reads_legacy_per_doc_positions() {
12453        let tmpdir = TempObjDir::default();
12454        let store = Arc::new(LanceIndexStore::new(
12455            ObjectStore::local().into(),
12456            tmpdir.clone(),
12457            Arc::new(LanceCache::no_cache()),
12458        ));
12459
12460        let mut builder = InnerBuilder::new_with_format_version(
12461            0,
12462            true,
12463            TokenSetFormat::default(),
12464            InvertedListFormatVersion::V1,
12465        );
12466        builder.tokens.add("hello".to_owned());
12467        builder.tokens.add("world".to_owned());
12468        builder
12469            .posting_lists
12470            .push(PostingListBuilder::new_with_posting_tail_codec(
12471                true,
12472                PostingTailCodec::Fixed32,
12473            ));
12474        builder
12475            .posting_lists
12476            .push(PostingListBuilder::new_with_posting_tail_codec(
12477                true,
12478                PostingTailCodec::Fixed32,
12479            ));
12480        builder.posting_lists[0].add(0, PositionRecorder::Position(vec![0].into()));
12481        builder.posting_lists[1].add(0, PositionRecorder::Position(vec![1].into()));
12482        builder.posting_lists[0].add(1, PositionRecorder::Position(vec![0].into()));
12483        builder.posting_lists[1].add(1, PositionRecorder::Position(vec![2].into()));
12484        builder.docs.append(100, 2);
12485        builder.docs.append(101, 2);
12486        builder.write(store.as_ref()).await.unwrap();
12487
12488        let metadata = std::collections::HashMap::from_iter(vec![
12489            (
12490                "partitions".to_owned(),
12491                serde_json::to_string(&vec![0_u64]).unwrap(),
12492            ),
12493            (
12494                "params".to_owned(),
12495                serde_json::to_string(&InvertedIndexParams::default().with_position(true)).unwrap(),
12496            ),
12497            (
12498                TOKEN_SET_FORMAT_KEY.to_owned(),
12499                TokenSetFormat::default().to_string(),
12500            ),
12501        ]);
12502        let mut writer = store
12503            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
12504            .await
12505            .unwrap();
12506        writer.finish_with_metadata(metadata).await.unwrap();
12507
12508        let cache = Arc::new(LanceCache::with_capacity(4096));
12509        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
12510            .await
12511            .unwrap();
12512
12513        let tokens = Arc::new(Tokens::new(
12514            vec!["hello".to_owned(), "world".to_owned()],
12515            DocType::Text,
12516        ));
12517        let params = Arc::new(
12518            FtsSearchParams::new()
12519                .with_limit(Some(10))
12520                .with_phrase_slop(Some(0)),
12521        );
12522        let prefilter = Arc::new(NoFilter);
12523        let metrics = Arc::new(NoOpMetricsCollector);
12524
12525        let (row_ids, _scores) = index
12526            .bm25_search(tokens, params, Operator::And, prefilter, metrics, None)
12527            .await
12528            .unwrap();
12529
12530        assert_eq!(row_ids, vec![100]);
12531    }
12532
12533    /// Build a multi-partition inverted index in `store` with `num_partitions`
12534    /// partitions, each carrying a handful of tokens/docs.
12535    async fn build_multi_partition_index(
12536        store: &Arc<LanceIndexStore>,
12537        num_partitions: u64,
12538    ) -> (Arc<InvertedIndex>, Arc<LanceCache>) {
12539        for id in 0..num_partitions {
12540            let mut builder = InnerBuilder::new_with_format_version(
12541                id,
12542                false,
12543                TokenSetFormat::default(),
12544                InvertedListFormatVersion::V1,
12545            );
12546            // A few distinct tokens per partition so each posting file has real
12547            // content to read and materialize during prewarm.
12548            for t in 0..4u32 {
12549                builder.tokens.add(format!("tok_{id}_{t}"));
12550                let mut posting = PostingListBuilder::new_with_posting_tail_codec(
12551                    false,
12552                    PostingTailCodec::Fixed32,
12553                );
12554                let base = id * 1000 + t as u64 * 10;
12555                for d in 0..5u32 {
12556                    posting.add(d, PositionRecorder::Count(1));
12557                    builder.docs.append(base + d as u64, 4);
12558                }
12559                builder.posting_lists.push(posting);
12560            }
12561            builder.write(store.as_ref()).await.unwrap();
12562        }
12563
12564        let partition_ids: Vec<u64> = (0..num_partitions).collect();
12565        let metadata = std::collections::HashMap::from_iter(vec![
12566            (
12567                "partitions".to_owned(),
12568                serde_json::to_string(&partition_ids).unwrap(),
12569            ),
12570            (
12571                "params".to_owned(),
12572                serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
12573            ),
12574            (
12575                TOKEN_SET_FORMAT_KEY.to_owned(),
12576                TokenSetFormat::default().to_string(),
12577            ),
12578        ]);
12579        let mut writer = store
12580            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
12581            .await
12582            .unwrap();
12583        writer.finish_with_metadata(metadata).await.unwrap();
12584
12585        // Keep the cache alive and return it: the partition readers hold only a
12586        // WeakLanceCache, so the prewarmed entries vanish if this Arc is dropped.
12587        let cache = Arc::new(LanceCache::with_capacity(1 << 20));
12588        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
12589            .await
12590            .unwrap();
12591        (index, cache)
12592    }
12593
12594    /// The prewarm cost estimate must come from cheap object metadata (the
12595    /// posting file length) without reading the posting data, and must be
12596    /// monotonic in the partition's content.
12597    #[tokio::test]
12598    async fn test_posting_data_size_bytes_uses_file_length() {
12599        let tmpdir = TempObjDir::default();
12600        let store = Arc::new(LanceIndexStore::new(
12601            ObjectStore::local().into(),
12602            tmpdir.clone(),
12603            Arc::new(LanceCache::no_cache()),
12604        ));
12605        let (index, _cache) = build_multi_partition_index(&store, 3).await;
12606        for part in &index.partitions {
12607            // File length is reported by object metadata at open time; it must be
12608            // non-trivial for a partition that actually holds postings.
12609            let est = part.inverted_list.posting_data_size_bytes();
12610            assert!(
12611                est > 0,
12612                "expected a non-zero posting-data size estimate, got {est}"
12613            );
12614        }
12615    }
12616
12617    /// Each partition must read through the shared scheduler at a distinct base
12618    /// priority. Tied priorities (every partition at 0) break the scheduler's
12619    /// backpressure deadlock-break — which admits the lowest-priority in-flight
12620    /// request — because there is no unique lowest request to advance, so a
12621    /// concurrent multi-partition read (e.g. prewarm) can wedge. Distinct
12622    /// per-partition priorities keep the in-flight set totally ordered.
12623    #[tokio::test]
12624    async fn test_partitions_load_with_distinct_priorities() {
12625        let tmpdir = TempObjDir::default();
12626        let store = Arc::new(LanceIndexStore::new(
12627            ObjectStore::local().into(),
12628            tmpdir.clone(),
12629            Arc::new(LanceCache::no_cache()),
12630        ));
12631        let (index, _cache) = build_multi_partition_index(&store, 5).await;
12632
12633        let mut priorities: Vec<u64> = index
12634            .partitions
12635            .iter()
12636            .map(|part| {
12637                part.store
12638                    .as_any()
12639                    .downcast_ref::<LanceIndexStore>()
12640                    .expect("partition store should be a LanceIndexStore")
12641                    .io_priority()
12642            })
12643            .collect();
12644
12645        // Distinct and dense (0..N): every partition reads at its own priority,
12646        // so the shared scheduler sees a total order across all partitions. The
12647        // partitions may finish loading in any order, so sort before comparing —
12648        // what matters is that the priorities form a contiguous, collision-free
12649        // set, not which partition ended up at which slot.
12650        priorities.sort_unstable();
12651        assert_eq!(
12652            priorities,
12653            (0..index.partitions.len() as u64).collect::<Vec<_>>()
12654        );
12655    }
12656
12657    #[tokio::test]
12658    async fn test_update_preserves_v2_format_version() -> Result<()> {
12659        let src_dir = TempObjDir::default();
12660        let dest_dir = TempObjDir::default();
12661        let src_store = Arc::new(LanceIndexStore::new(
12662            ObjectStore::local().into(),
12663            src_dir.clone(),
12664            Arc::new(LanceCache::no_cache()),
12665        ));
12666        let dest_store = Arc::new(LanceIndexStore::new(
12667            ObjectStore::local().into(),
12668            dest_dir.clone(),
12669            Arc::new(LanceCache::no_cache()),
12670        ));
12671
12672        let format_version = InvertedListFormatVersion::V2;
12673        let posting_tail_codec = format_version.posting_tail_codec();
12674        let mut partition = InnerBuilder::new_with_format_version(
12675            0,
12676            false,
12677            TokenSetFormat::default(),
12678            format_version,
12679        );
12680        partition.tokens.add("hello".to_owned());
12681        let mut posting_list =
12682            PostingListBuilder::new_with_posting_tail_codec(false, posting_tail_codec);
12683        posting_list.add(0, PositionRecorder::Count(1));
12684        partition.posting_lists.push(posting_list);
12685        partition.docs.append(100, 1);
12686        partition.write(src_store.as_ref()).await?;
12687
12688        let metadata = HashMap::from([
12689            (
12690                "partitions".to_owned(),
12691                serde_json::to_string(&vec![0_u64]).unwrap(),
12692            ),
12693            (
12694                "params".to_owned(),
12695                serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
12696            ),
12697            (
12698                TOKEN_SET_FORMAT_KEY.to_owned(),
12699                TokenSetFormat::default().to_string(),
12700            ),
12701            (
12702                POSTING_TAIL_CODEC_KEY.to_owned(),
12703                posting_tail_codec.as_str().to_owned(),
12704            ),
12705        ]);
12706        let mut writer = src_store
12707            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
12708            .await
12709            .unwrap();
12710        writer.finish_with_metadata(metadata).await.unwrap();
12711
12712        let index = InvertedIndex::load(src_store, None, &LanceCache::no_cache()).await?;
12713        assert_eq!(index.format_version(), format_version);
12714        assert_eq!(index.index_version(), INVERTED_INDEX_VERSION_V2);
12715
12716        let schema = Arc::new(Schema::new(vec![
12717            Field::new("doc", DataType::Utf8, true),
12718            Field::new(ROW_ID, DataType::UInt64, false),
12719        ]));
12720        let docs = Arc::new(StringArray::from(vec![Some("hello again")]));
12721        let row_ids = Arc::new(UInt64Array::from(vec![101u64]));
12722        let batch = RecordBatch::try_new(schema.clone(), vec![docs, row_ids])?;
12723        let stream = RecordBatchStreamAdapter::new(schema, stream::iter(vec![Ok(batch)]));
12724        let created = index
12725            .update(Box::pin(stream), dest_store.as_ref(), None)
12726            .await?;
12727
12728        assert_eq!(created.index_version, INVERTED_INDEX_VERSION_V2);
12729
12730        let updated = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?;
12731        assert_eq!(updated.format_version(), format_version);
12732        assert_eq!(updated.index_version(), INVERTED_INDEX_VERSION_V2);
12733        assert_eq!(updated.partitions.len(), 2);
12734        for partition in &updated.partitions {
12735            assert_eq!(
12736                partition.inverted_list.posting_tail_codec(),
12737                posting_tail_codec
12738            );
12739        }
12740
12741        Ok(())
12742    }
12743
12744    #[tokio::test]
12745    async fn test_block_size_256_writes_v3_metadata_and_index_version() -> Result<()> {
12746        let src_dir = TempObjDir::default();
12747        let dest_dir = TempObjDir::default();
12748        let src_store = Arc::new(LanceIndexStore::new(
12749            ObjectStore::local().into(),
12750            src_dir.clone(),
12751            Arc::new(LanceCache::no_cache()),
12752        ));
12753        let dest_store = Arc::new(LanceIndexStore::new(
12754            ObjectStore::local().into(),
12755            dest_dir.clone(),
12756            Arc::new(LanceCache::no_cache()),
12757        ));
12758
12759        let params = InvertedIndexParams::default().block_size(256)?;
12760        let format_version = params.resolved_format_version();
12761        assert_eq!(format_version, InvertedListFormatVersion::V3);
12762
12763        let mut partition = InnerBuilder::new_with_format_version_and_block_size(
12764            0,
12765            false,
12766            TokenSetFormat::default(),
12767            format_version,
12768            params.posting_block_size(),
12769        );
12770        partition.tokens.add("hello".to_owned());
12771        let mut posting_list = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
12772            false,
12773            format_version.posting_tail_codec(),
12774            params.posting_block_size(),
12775        );
12776        posting_list.add(0, PositionRecorder::Count(1));
12777        partition.posting_lists.push(posting_list);
12778        partition.docs.append(100, 1);
12779        partition.write(src_store.as_ref()).await?;
12780
12781        write_test_metadata(&src_store, vec![0], params).await;
12782
12783        let index = InvertedIndex::load(src_store, None, &LanceCache::no_cache()).await?;
12784        assert_eq!(index.format_version(), InvertedListFormatVersion::V3);
12785        assert_eq!(index.index_version(), INVERTED_INDEX_VERSION_V3);
12786
12787        let created = index
12788            .update(empty_doc_stream(), dest_store.as_ref(), None)
12789            .await?;
12790        assert_eq!(created.index_version, INVERTED_INDEX_VERSION_V3);
12791
12792        let updated = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?;
12793        assert_eq!(updated.format_version(), InvertedListFormatVersion::V3);
12794        assert_eq!(updated.index_version(), INVERTED_INDEX_VERSION_V3);
12795
12796        Ok(())
12797    }
12798
12799    #[tokio::test]
12800    async fn test_merge_segments_preserves_arrow_token_set_format() -> Result<()> {
12801        let src_dir = TempObjDir::default();
12802        let dest_dir = TempObjDir::default();
12803        let src_store = Arc::new(LanceIndexStore::new(
12804            ObjectStore::local().into(),
12805            src_dir.clone(),
12806            Arc::new(LanceCache::no_cache()),
12807        ));
12808        let dest_store = Arc::new(LanceIndexStore::new(
12809            ObjectStore::local().into(),
12810            dest_dir.clone(),
12811            Arc::new(LanceCache::no_cache()),
12812        ));
12813
12814        let index = write_single_partition_index(
12815            src_store,
12816            InvertedIndexParams::default().format_version(InvertedListFormatVersion::V2),
12817            TokenSetFormat::Arrow,
12818            "hello",
12819            100,
12820        )
12821        .await?;
12822        assert_eq!(index.index_version(), 0);
12823        let created = InvertedIndex::merge_segments(
12824            &[index],
12825            empty_doc_stream(),
12826            dest_store.as_ref(),
12827            None,
12828            crate::progress::noop_progress(),
12829        )
12830        .await?;
12831
12832        assert_eq!(created.index_version, 0);
12833        let merged = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?;
12834        assert_eq!(merged.index_version(), 0);
12835        assert_eq!(merged.token_set_format, TokenSetFormat::Arrow);
12836
12837        let tokens = Arc::new(Tokens::new(vec!["hello".to_string()], DocType::Text));
12838        let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
12839        let prefilter = Arc::new(NoFilter);
12840        let metrics = Arc::new(NoOpMetricsCollector);
12841        let (row_ids, _) = merged
12842            .bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
12843            .await?;
12844        assert_eq!(row_ids, vec![100]);
12845
12846        Ok(())
12847    }
12848
12849    #[rstest::rstest]
12850    #[case::v1(InvertedListFormatVersion::V1, LEGACY_BLOCK_SIZE)]
12851    #[case::v2(InvertedListFormatVersion::V2, LEGACY_BLOCK_SIZE)]
12852    #[case::v3_128(InvertedListFormatVersion::V3, LEGACY_BLOCK_SIZE)]
12853    #[case::v3_256(InvertedListFormatVersion::V3, 256)]
12854    #[tokio::test]
12855    async fn test_merge_segments_preserves_format_version(
12856        #[case] format_version: InvertedListFormatVersion,
12857        #[case] block_size: usize,
12858    ) -> Result<()> {
12859        let src_dir = TempObjDir::default();
12860        let dest_dir = TempObjDir::default();
12861        let src_store = Arc::new(LanceIndexStore::new(
12862            ObjectStore::local().into(),
12863            src_dir.clone(),
12864            Arc::new(LanceCache::no_cache()),
12865        ));
12866        let dest_store = Arc::new(LanceIndexStore::new(
12867            ObjectStore::local().into(),
12868            dest_dir.clone(),
12869            Arc::new(LanceCache::no_cache()),
12870        ));
12871        let params = InvertedIndexParams::default()
12872            .block_size(block_size)?
12873            .format_version(format_version);
12874
12875        let index =
12876            write_single_partition_index(src_store, params, TokenSetFormat::Fst, "hello", 100)
12877                .await?;
12878        assert_eq!(index.format_version(), format_version);
12879
12880        let created = InvertedIndex::merge_segments(
12881            &[index],
12882            empty_doc_stream(),
12883            dest_store.as_ref(),
12884            None,
12885            crate::progress::noop_progress(),
12886        )
12887        .await?;
12888        assert_eq!(created.index_version, format_version.index_version());
12889
12890        let merged = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?;
12891        assert_eq!(merged.format_version(), format_version);
12892        assert_eq!(merged.index_version(), format_version.index_version());
12893
12894        Ok(())
12895    }
12896
12897    #[tokio::test]
12898    async fn test_merge_segments_uses_memory_limit_for_old_partitions() -> Result<()> {
12899        let src_dir_1 = TempObjDir::default();
12900        let src_dir_2 = TempObjDir::default();
12901        let dest_dir = TempObjDir::default();
12902        let src_store_1 = Arc::new(LanceIndexStore::new(
12903            ObjectStore::local().into(),
12904            src_dir_1.clone(),
12905            Arc::new(LanceCache::no_cache()),
12906        ));
12907        let src_store_2 = Arc::new(LanceIndexStore::new(
12908            ObjectStore::local().into(),
12909            src_dir_2.clone(),
12910            Arc::new(LanceCache::no_cache()),
12911        ));
12912        let dest_store = Arc::new(LanceIndexStore::new(
12913            ObjectStore::local().into(),
12914            dest_dir.clone(),
12915            Arc::new(LanceCache::no_cache()),
12916        ));
12917
12918        let params = InvertedIndexParams::default().memory_limit_mb(0);
12919        let first = write_single_partition_index(
12920            src_store_1,
12921            params.clone(),
12922            TokenSetFormat::default(),
12923            "alpha",
12924            100,
12925        )
12926        .await?;
12927        let second = write_single_partition_index(
12928            src_store_2,
12929            params,
12930            TokenSetFormat::default(),
12931            "beta",
12932            200,
12933        )
12934        .await?;
12935
12936        let mut builder =
12937            InvertedIndexBuilder::new(InvertedIndexParams::default().memory_limit_mb(0))
12938                .with_token_set_format(TokenSetFormat::default());
12939        builder
12940            .update_from_segments(
12941                empty_doc_stream(),
12942                dest_store.as_ref(),
12943                &[first, second],
12944                None,
12945            )
12946            .await?;
12947
12948        let merged = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?;
12949        assert_eq!(merged.partitions.len(), 2);
12950        let mut partition_ids = merged
12951            .partitions
12952            .iter()
12953            .map(|partition| partition.id())
12954            .collect::<Vec<_>>();
12955        partition_ids.sort_unstable();
12956        assert_eq!(partition_ids, vec![0, 1]);
12957
12958        Ok(())
12959    }
12960
12961    #[tokio::test]
12962    async fn test_modern_index_without_deleted_col_has_empty_bitmap() {
12963        // An index created before the deleted_fragments feature was added
12964        // will have a metadata file with num_rows=0 (no record batch data).
12965        // The load path should gracefully handle this with an empty bitmap.
12966        let tmpdir = TempObjDir::default();
12967        let store = Arc::new(LanceIndexStore::new(
12968            ObjectStore::local().into(),
12969            tmpdir.clone(),
12970            Arc::new(LanceCache::no_cache()),
12971        ));
12972
12973        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12974        builder.tokens.add("test".to_owned());
12975        builder.posting_lists.push(PostingListBuilder::new(false));
12976        builder.posting_lists[0].add(0, PositionRecorder::Count(1));
12977        builder.docs.append(100, 1);
12978        builder.write(store.as_ref()).await.unwrap();
12979
12980        // Write a metadata file WITHOUT the deleted_fragments column
12981        // (simulates an older index version)
12982        let metadata = std::collections::HashMap::from_iter(vec![
12983            (
12984                "partitions".to_owned(),
12985                serde_json::to_string(&vec![0u64]).unwrap(),
12986            ),
12987            (
12988                "params".to_owned(),
12989                serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
12990            ),
12991            (
12992                TOKEN_SET_FORMAT_KEY.to_owned(),
12993                TokenSetFormat::default().to_string(),
12994            ),
12995        ]);
12996        let mut writer = store
12997            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
12998            .await
12999            .unwrap();
13000        writer.finish_with_metadata(metadata).await.unwrap();
13001
13002        let index = InvertedIndex::load(store, None, &LanceCache::no_cache())
13003            .await
13004            .unwrap();
13005        assert!(
13006            index.deleted_fragments().is_empty(),
13007            "index without deleted_fragments column should have empty bitmap"
13008        );
13009    }
13010
13011    #[tokio::test]
13012    async fn flat_bm25_search_stream_with_metrics_records_elapsed_compute() {
13013        use crate::scalar::inverted::tokenizer::document_tokenizer::TextTokenizer;
13014        use arrow_array::{StringArray, UInt64Array};
13015        use lance_tokenizer::{SimpleTokenizer, TextAnalyzer};
13016
13017        // Tiny stream of one batch containing the query term in two rows.
13018        let schema = Arc::new(Schema::new(vec![
13019            ROW_ID_FIELD.clone(),
13020            Field::new("text", DataType::Utf8, false),
13021        ]));
13022        let batch = RecordBatch::try_new(
13023            schema.clone(),
13024            vec![
13025                Arc::new(UInt64Array::from(vec![0u64, 1, 2, 3])),
13026                Arc::new(StringArray::from(vec![
13027                    "the quick brown fox",
13028                    "lazy dog sleeps",
13029                    "the brown fox jumps over",
13030                    "completely unrelated text",
13031                ])),
13032            ],
13033        )
13034        .unwrap();
13035
13036        let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
13037            schema.clone(),
13038            stream::iter(vec![Ok(batch)]),
13039        ));
13040
13041        let tokenizer: Box<dyn LanceTokenizer> = Box::new(TextTokenizer::new(
13042            TextAnalyzer::builder(SimpleTokenizer::default()).build(),
13043        ));
13044
13045        let elapsed_compute = Time::default();
13046        let result_stream = flat_bm25_search_stream_with_metrics(
13047            input,
13048            "text".to_string(),
13049            "fox".to_string(),
13050            tokenizer,
13051            None,
13052            100,
13053            Some(elapsed_compute.clone()),
13054        )
13055        .await
13056        .unwrap();
13057
13058        let batches: Vec<_> = result_stream.try_collect().await.unwrap();
13059        assert!(!batches.is_empty(), "expected at least one scored batch");
13060
13061        // Both phase 1 (tokenize_and_count's spawn_cpu) and phase 2 (sync
13062        // scoring) call `add_duration` on the metric; verify the handle
13063        // was actually populated.
13064        assert!(
13065            elapsed_compute.value() > 0,
13066            "elapsed_compute should have been populated; got 0"
13067        );
13068    }
13069
13070    #[tokio::test]
13071    async fn flat_bm25_skips_zero_token_documents_from_corpus_stats() {
13072        let schema = Arc::new(Schema::new(vec![
13073            ROW_ID_FIELD.clone(),
13074            Field::new("text", DataType::Utf8, true),
13075        ]));
13076        let batch = RecordBatch::try_new(
13077            schema,
13078            vec![
13079                Arc::new(UInt64Array::from(vec![0_u64, 1, 2, 3, 4, 5])) as ArrayRef,
13080                Arc::new(StringArray::from(vec![
13081                    Some(""),
13082                    Some("   "),
13083                    Some("the"),
13084                    Some("overlength"),
13085                    None,
13086                    Some("hello"),
13087                ])) as ArrayRef,
13088            ],
13089        )
13090        .unwrap();
13091        let params = InvertedIndexParams::new("whitespace".to_string(), Language::English)
13092            .remove_stop_words(true)
13093            .stem(false)
13094            .max_token_length(Some(6));
13095        let query_tokens = Arc::new(Tokens::new(vec!["hello".to_string()], DocType::Text));
13096
13097        let counted_input = tokenize_and_count(
13098            stream::iter(vec![Ok(batch)]),
13099            params.build().unwrap(),
13100            query_tokens.clone(),
13101            1,
13102            None,
13103        )
13104        .await
13105        .unwrap();
13106
13107        assert_eq!(counted_input.num_rows(), 1);
13108        assert_eq!(
13109            counted_input[ROW_ID].as_primitive::<UInt64Type>().values(),
13110            &[5]
13111        );
13112        let scorer = initialize_scorer(None, query_tokens.as_ref(), &counted_input);
13113        let expected_scorer = MemBM25Scorer::new(1, 1, HashMap::from([("hello".to_string(), 1)]));
13114        assert_eq!(scorer.total_tokens, 1);
13115        assert_eq!(scorer.num_docs(), 1);
13116        assert_eq!(scorer.num_docs_containing_token("hello"), 1);
13117        assert_eq!(scorer.avg_doc_length(), expected_scorer.avg_doc_length());
13118        assert_eq!(
13119            scorer.query_weight("hello"),
13120            expected_scorer.query_weight("hello")
13121        );
13122    }
13123
13124    #[tokio::test]
13125    async fn flat_bm25_search_uses_full_document_length_for_normalization() {
13126        let schema = Arc::new(Schema::new(vec![
13127            ROW_ID_FIELD.clone(),
13128            Field::new("text", DataType::Utf8, false),
13129        ]));
13130        let batch = RecordBatch::try_new(
13131            schema.clone(),
13132            vec![
13133                Arc::new(UInt64Array::from(vec![0u64, 1])),
13134                Arc::new(StringArray::from(vec![
13135                    "alpha",
13136                    "alpha filler filler filler filler filler filler filler filler filler",
13137                ])),
13138            ],
13139        )
13140        .unwrap();
13141
13142        let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
13143            schema.clone(),
13144            stream::iter(vec![Ok(batch)]),
13145        ));
13146        let tokenizer: Box<dyn LanceTokenizer> = Box::new(TextTokenizer::new(
13147            TextAnalyzer::builder(SimpleTokenizer::default()).build(),
13148        ));
13149
13150        let result_stream = flat_bm25_search_stream_with_metrics(
13151            input,
13152            "text".to_string(),
13153            "alpha".to_string(),
13154            tokenizer,
13155            None,
13156            100,
13157            None,
13158        )
13159        .await
13160        .unwrap();
13161        let batches: Vec<_> = result_stream.try_collect().await.unwrap();
13162        let scored = arrow::compute::concat_batches(&FTS_SCHEMA, &batches).unwrap();
13163        let row_ids = scored[ROW_ID].as_primitive::<UInt64Type>();
13164        let scores = scored[SCORE_COL].as_primitive::<Float32Type>();
13165
13166        assert_eq!(row_ids.values(), &[0, 1]);
13167        assert!(
13168            scores.value(0) > scores.value(1),
13169            "same term frequency should score shorter document higher; short={}, long={}",
13170            scores.value(0),
13171            scores.value(1)
13172        );
13173    }
13174
13175    #[tokio::test]
13176    async fn flat_bm25_search_treats_string_lists_as_row_documents() {
13177        let mut docs_builder =
13178            GenericListBuilder::<i32, _>::new(GenericStringBuilder::<i32>::new());
13179        docs_builder.values().append_value("alpha");
13180        docs_builder.values().append_value("alpha beta");
13181        docs_builder.append(true);
13182        docs_builder.values().append_value("beta");
13183        docs_builder.append(true);
13184        docs_builder.append(true);
13185        docs_builder.values().append_null();
13186        docs_builder.append(true);
13187        docs_builder.append(false);
13188
13189        let docs = Arc::new(docs_builder.finish()) as ArrayRef;
13190        let schema = Arc::new(Schema::new(vec![
13191            ROW_ID_FIELD.clone(),
13192            Field::new("text", docs.data_type().clone(), true),
13193        ]));
13194        let batch = RecordBatch::try_new(
13195            schema.clone(),
13196            vec![
13197                Arc::new(UInt64Array::from(vec![0u64, 1, 2, 3, 4])) as ArrayRef,
13198                docs,
13199            ],
13200        )
13201        .unwrap();
13202
13203        let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
13204            schema.clone(),
13205            stream::iter(vec![Ok(batch)]),
13206        ));
13207        let tokenizer: Box<dyn LanceTokenizer> = Box::new(TextTokenizer::new(
13208            TextAnalyzer::builder(SimpleTokenizer::default()).build(),
13209        ));
13210
13211        let result_stream = flat_bm25_search_stream_with_metrics(
13212            input,
13213            "text".to_string(),
13214            "alpha".to_string(),
13215            tokenizer,
13216            None,
13217            100,
13218            None,
13219        )
13220        .await
13221        .unwrap();
13222        let batches: Vec<_> = result_stream.try_collect().await.unwrap();
13223        let scored = arrow::compute::concat_batches(&FTS_SCHEMA, &batches).unwrap();
13224        let row_ids = scored[ROW_ID].as_primitive::<UInt64Type>();
13225
13226        assert_eq!(row_ids.values(), &[0]);
13227    }
13228
13229    #[tokio::test]
13230    async fn flat_bm25_search_code_and_uses_position_groups() {
13231        let schema = Arc::new(Schema::new(vec![
13232            ROW_ID_FIELD.clone(),
13233            Field::new("code", DataType::Utf8, false),
13234        ]));
13235        let batch = RecordBatch::try_new(
13236            schema.clone(),
13237            vec![
13238                Arc::new(UInt64Array::from(vec![0u64, 1, 2, 3])),
13239                Arc::new(StringArray::from(vec![
13240                    "get user name",
13241                    "getUserName",
13242                    "get user",
13243                    "username",
13244                ])),
13245            ],
13246        )
13247        .unwrap();
13248
13249        let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
13250            schema.clone(),
13251            stream::iter(vec![Ok(batch)]),
13252        ));
13253        let tokenizer = InvertedIndexParams::code()
13254            .split_identifiers(true)
13255            .build()
13256            .unwrap();
13257
13258        let result_stream = flat_bm25_search_stream_with_metrics_and_operator(
13259            input,
13260            "code".to_string(),
13261            "getUserName".to_string(),
13262            tokenizer,
13263            None,
13264            100,
13265            Operator::And,
13266            None,
13267        )
13268        .await
13269        .unwrap();
13270
13271        let batches: Vec<_> = result_stream.try_collect().await.unwrap();
13272        let scored = arrow::compute::concat_batches(&FTS_SCHEMA, &batches).unwrap();
13273        let mut row_ids = scored[ROW_ID]
13274            .as_primitive::<UInt64Type>()
13275            .values()
13276            .to_vec();
13277        row_ids.sort_unstable();
13278
13279        assert_eq!(row_ids, vec![0, 1]);
13280    }
13281
13282    #[tokio::test]
13283    async fn flat_bm25_search_code_and_counts_repeated_subwords() {
13284        let schema = Arc::new(Schema::new(vec![
13285            ROW_ID_FIELD.clone(),
13286            Field::new("code", DataType::Utf8, false),
13287        ]));
13288        let batch = RecordBatch::try_new(
13289            schema.clone(),
13290            vec![
13291                Arc::new(UInt64Array::from(vec![0u64, 1])),
13292                Arc::new(StringArray::from(vec![
13293                    "pub fn edge_flat_generic_return<T>() -> Result<T, EdgeFlatError> where T: TryFrom<String> { todo!() }",
13294                    "pub fn edge_flat_generic_return<T>() -> Result<T> { todo!() }",
13295                ])),
13296            ],
13297        )
13298        .unwrap();
13299
13300        let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
13301            schema.clone(),
13302            stream::iter(vec![Ok(batch)]),
13303        ));
13304        let tokenizer = InvertedIndexParams::code().build().unwrap();
13305
13306        let result_stream = flat_bm25_search_stream_with_metrics_and_operator(
13307            input,
13308            "code".to_string(),
13309            "edge_flat_generic_return TryFrom EdgeFlatError Result".to_string(),
13310            tokenizer,
13311            None,
13312            100,
13313            Operator::And,
13314            None,
13315        )
13316        .await
13317        .unwrap();
13318
13319        let batches: Vec<_> = result_stream.try_collect().await.unwrap();
13320        let scored = arrow::compute::concat_batches(&FTS_SCHEMA, &batches).unwrap();
13321        let row_ids = scored[ROW_ID].as_primitive::<UInt64Type>().values();
13322
13323        assert_eq!(row_ids, &[0]);
13324    }
13325
13326    fn posting_entries(posting: &PostingList) -> Vec<(u64, u32)> {
13327        posting.iter().map(|(doc, freq, _)| (doc, freq)).collect()
13328    }
13329
13330    #[tokio::test]
13331    async fn test_modern_posting_validation_is_cached_per_token() {
13332        let tmpdir = TempObjDir::default();
13333        let store = Arc::new(LanceIndexStore::new(
13334            ObjectStore::local().into(),
13335            tmpdir.clone(),
13336            Arc::new(LanceCache::no_cache()),
13337        ));
13338
13339        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
13340        builder.tokens.add("term".to_owned());
13341        let mut valid_builder = PostingListBuilder::new(false);
13342        valid_builder.add(0, PositionRecorder::Count(1));
13343        builder.posting_lists.push(valid_builder);
13344        builder.docs.append(1000, 1);
13345        builder.write(store.as_ref()).await.unwrap();
13346
13347        let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
13348        let mut posting_reader = PostingListReader::try_new(reader, &LanceCache::no_cache())
13349            .await
13350            .unwrap();
13351        posting_reader.modern_num_docs = Some(1);
13352        let validation = &posting_reader
13353            .modern_doc_id_validations
13354            .as_ref()
13355            .expect("modern readers have per-token validation state")[0];
13356        assert!(validation.get().is_none());
13357        assert!(!posting_reader.modern_posting_is_validated(0).unwrap());
13358
13359        let mut corrupt_builder = PostingListBuilder::new(false);
13360        corrupt_builder.add(1, PositionRecorder::Count(1));
13361        let corrupt_batch = corrupt_builder.to_batch(vec![1.0]).unwrap();
13362        let corrupt_posting = PostingList::from_batch(&corrupt_batch, Some(1.0), Some(1)).unwrap();
13363        let error = posting_reader
13364            .ensure_modern_posting_validated(0, &corrupt_posting)
13365            .await
13366            .unwrap_err();
13367        assert!(matches!(error, Error::Index { .. }));
13368        assert!(error.to_string().contains("DocId 1"));
13369        assert!(error.to_string().contains("[0, 1)"));
13370        assert!(validation.get().is_none());
13371        assert!(!posting_reader.modern_posting_is_validated(0).unwrap());
13372
13373        let first = posting_reader
13374            .posting_list(0, false, &NoOpMetricsCollector)
13375            .await
13376            .unwrap();
13377        assert_eq!(posting_entries(&first), vec![(0, 1)]);
13378        assert!(validation.get().is_some());
13379        assert!(posting_reader.modern_posting_is_validated(0).unwrap());
13380
13381        let second = posting_reader
13382            .posting_list(0, false, &NoOpMetricsCollector)
13383            .await
13384            .unwrap();
13385        assert_eq!(posting_entries(&second), vec![(0, 1)]);
13386        assert!(validation.get().is_some());
13387    }
13388
13389    /// Runtime synthetic grouping must return correct posting lists for every
13390    /// token, including across synthetic group boundaries.
13391    #[tokio::test]
13392    async fn test_posting_list_synthetic_grouping_reads_group_boundaries() {
13393        let tmpdir = TempObjDir::default();
13394        let store = Arc::new(LanceIndexStore::new(
13395            ObjectStore::local().into(),
13396            tmpdir.clone(),
13397            Arc::new(LanceCache::no_cache()),
13398        ));
13399
13400        let num_tokens = runtime_posting_group_tokens() as u32 + 4;
13401        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
13402        for t in 0..num_tokens {
13403            builder.tokens.add(format!("t{t}"));
13404            let mut pl = PostingListBuilder::new(false);
13405            pl.add(t, PositionRecorder::Count(1));
13406            builder.posting_lists.push(pl);
13407            builder.docs.append(1000 + t as u64, 1);
13408        }
13409        builder.write(store.as_ref()).await.unwrap();
13410
13411        let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
13412        let cache = LanceCache::no_cache();
13413        let mut posting_reader = PostingListReader::try_new(reader, &cache).await.unwrap();
13414        posting_reader.modern_num_docs = Some(num_tokens as usize);
13415        assert!(
13416            matches!(
13417                &posting_reader.grouping,
13418                PostingGrouping::SyntheticFixed { .. }
13419            ),
13420            "v2 reader must synthesize runtime posting groups",
13421        );
13422
13423        let metrics = NoOpMetricsCollector;
13424        for token in 0..num_tokens {
13425            let posting = posting_reader
13426                .posting_list(token, false, &metrics)
13427                .await
13428                .unwrap();
13429            assert_eq!(
13430                posting_entries(&posting),
13431                vec![(token as u64, 1)],
13432                "synthetic grouping mismatch for token {token}",
13433            );
13434            assert_eq!(posting.len(), 1, "length mismatch for token {token}");
13435        }
13436    }
13437
13438    /// Prewarm must populate exactly the `PostingListGroupKey`s the read path
13439    /// looks up — in particular the final group, whose `end` both paths derive
13440    /// from `self.len()`. If those derivations drifted (e.g. one used
13441    /// `num_rows()` and the other the loaded posting count), the last group's
13442    /// warm entry would be missing and prewarm silently wasted (issue #7040).
13443    #[tokio::test]
13444    async fn test_prewarm_group_keys_match_read_path() {
13445        let tmpdir = TempObjDir::default();
13446        let store = Arc::new(LanceIndexStore::new(
13447            ObjectStore::local().into(),
13448            tmpdir.clone(),
13449            Arc::new(LanceCache::no_cache()),
13450        ));
13451
13452        let num_tokens = runtime_posting_group_tokens() as u32 + 4;
13453        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
13454        for t in 0..num_tokens {
13455            builder.tokens.add(format!("t{t}"));
13456            let mut pl = PostingListBuilder::new(false);
13457            pl.add(t, PositionRecorder::Count(1));
13458            builder.posting_lists.push(pl);
13459            builder.docs.append(1000 + t as u64, 1);
13460        }
13461        builder.write(store.as_ref()).await.unwrap();
13462
13463        let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
13464        // A real (strong) cache must outlive the reader's weak handle so the
13465        // prewarmed entries are still resolvable below.
13466        let cache = LanceCache::with_capacity(1 << 20);
13467        let mut posting_reader = PostingListReader::try_new(reader, &cache).await.unwrap();
13468        posting_reader.modern_num_docs = Some(num_tokens as usize);
13469        assert!(
13470            matches!(
13471                &posting_reader.grouping,
13472                PostingGrouping::SyntheticFixed { .. }
13473            ),
13474            "v2 reader should use runtime synthetic groups",
13475        );
13476
13477        posting_reader
13478            .prewarm_posting_lists(false, 2)
13479            .await
13480            .unwrap();
13481        assert!(posting_reader.modern_posting_validation_ready());
13482        assert!(
13483            posting_reader
13484                .modern_postings_validated
13485                .load(Ordering::Acquire)
13486        );
13487
13488        for token in 0..num_tokens {
13489            let (start, end) = posting_reader.group_range_for_token(token).unwrap();
13490            assert!(
13491                posting_reader
13492                    .index_cache
13493                    .get_with_key(&posting_list_group_cache_key(
13494                        start,
13495                        end,
13496                        posting_reader.has_impacts,
13497                    ))
13498                    .await
13499                    .is_some(),
13500                "prewarm did not populate group [{start}, {end}) that the read \
13501                 path requests for token {token}",
13502            );
13503        }
13504
13505        let (_, last_end) = posting_reader
13506            .group_range_for_token(num_tokens - 1)
13507            .unwrap();
13508        assert_eq!(
13509            last_end, num_tokens,
13510            "the last group must end at the posting count ({num_tokens})",
13511        );
13512    }
13513
13514    /// An empty partition has no synthetic groups because there are no token
13515    /// rows to cache.
13516    #[tokio::test]
13517    async fn test_empty_partition_has_no_synthetic_groups() {
13518        let tmpdir = TempObjDir::default();
13519        let store = Arc::new(LanceIndexStore::new(
13520            ObjectStore::local().into(),
13521            tmpdir.clone(),
13522            Arc::new(LanceCache::no_cache()),
13523        ));
13524
13525        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
13526        builder.write(store.as_ref()).await.unwrap();
13527
13528        let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
13529        let posting_reader = PostingListReader::try_new(reader, &LanceCache::no_cache())
13530            .await
13531            .unwrap();
13532        assert!(
13533            matches!(&posting_reader.grouping, PostingGrouping::None),
13534            "reader for an empty partition must not create cache groups",
13535        );
13536        assert!(posting_reader.is_empty());
13537    }
13538
13539    /// A large posting list can share a runtime synthetic group with neighbors;
13540    /// grouping is token-count based and should still read every member intact.
13541    #[tokio::test]
13542    async fn test_large_posting_reads_inside_synthetic_group() {
13543        let tmpdir = TempObjDir::default();
13544        let store = Arc::new(LanceIndexStore::new(
13545            ObjectStore::local().into(),
13546            tmpdir.clone(),
13547            Arc::new(LanceCache::no_cache()),
13548        ));
13549
13550        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
13551        let big_docs = (BLOCK_SIZE * 3 + 5) as u32;
13552        builder.tokens.add("big".to_owned());
13553        let mut big = PostingListBuilder::new(false);
13554        for d in 0..big_docs {
13555            big.add(d, PositionRecorder::Count(1));
13556        }
13557        builder.posting_lists.push(big);
13558        for t in 1..5u32 {
13559            builder.tokens.add(format!("t{t}"));
13560            let mut pl = PostingListBuilder::new(false);
13561            pl.add(0, PositionRecorder::Count(1));
13562            builder.posting_lists.push(pl);
13563        }
13564        for d in 0..big_docs as u64 {
13565            builder.docs.append(1000 + d, 1);
13566        }
13567        builder.write(store.as_ref()).await.unwrap();
13568
13569        let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
13570        let posting_reader = PostingListReader::try_new(reader, &LanceCache::no_cache())
13571            .await
13572            .unwrap();
13573        let expected_end = runtime_posting_group_tokens().min(5) as u32;
13574
13575        assert_eq!(
13576            posting_reader.group_range_for_token(0),
13577            Some((0, expected_end)),
13578            "runtime synthetic grouping should group by token count, not posting bytes",
13579        );
13580        let big = posting_reader
13581            .posting_list(0, false, &NoOpMetricsCollector)
13582            .await
13583            .unwrap();
13584        assert_eq!(big.len(), big_docs as usize);
13585        // A trailing tiny term (in the next, multi-token group) still reads back.
13586        let tiny = posting_reader
13587            .posting_list(2, false, &NoOpMetricsCollector)
13588            .await
13589            .unwrap();
13590        assert_eq!(tiny.len(), 1);
13591    }
13592
13593    /// Non-empty v2 indexes should prewarm synthetic `PostingListGroupKey`
13594    /// entries, matching what the read path then looks up without persisted
13595    /// grouping metadata.
13596    #[tokio::test]
13597    async fn test_prewarm_synthetic_grouping_populates_group_entries() {
13598        let tmpdir = TempObjDir::default();
13599        let store = Arc::new(LanceIndexStore::new(
13600            ObjectStore::local().into(),
13601            tmpdir.clone(),
13602            Arc::new(LanceCache::no_cache()),
13603        ));
13604
13605        let num_tokens = 3u32;
13606        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
13607        for t in 0..num_tokens {
13608            builder.tokens.add(format!("t{t}"));
13609            let mut pl = PostingListBuilder::new(false);
13610            pl.add(t, PositionRecorder::Count(1));
13611            builder.posting_lists.push(pl);
13612            builder.docs.append(1000 + t as u64, 1);
13613        }
13614        builder.write(store.as_ref()).await.unwrap();
13615
13616        let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
13617        let cache = LanceCache::with_capacity(1 << 20);
13618        let posting_reader = PostingListReader::try_new(reader, &cache).await.unwrap();
13619        assert!(matches!(
13620            &posting_reader.grouping,
13621            PostingGrouping::SyntheticFixed { .. }
13622        ));
13623
13624        posting_reader
13625            .prewarm_posting_lists(false, 2)
13626            .await
13627            .unwrap();
13628
13629        for token_id in 0..num_tokens {
13630            let (start, end) = posting_reader.group_range_for_token(token_id).unwrap();
13631            let group = posting_reader
13632                .index_cache
13633                .get_with_key(&posting_list_group_cache_key(
13634                    start,
13635                    end,
13636                    posting_reader.has_impacts,
13637                ))
13638                .await
13639                .unwrap_or_else(|| {
13640                    panic!(
13641                        "synthetic prewarm should populate group [{start}, {end}) for token {token_id}"
13642                    )
13643                });
13644            assert!(
13645                group.is_packed(),
13646                "no-position synthetic prewarm should insert a packed group"
13647            );
13648            assert!(
13649                posting_reader
13650                    .index_cache
13651                    .get_with_key(&posting_list_cache_key(
13652                        token_id,
13653                        posting_reader.has_impacts,
13654                    ))
13655                    .await
13656                    .is_none(),
13657                "synthetic prewarm should not populate per-token entry {token_id}",
13658            );
13659        }
13660    }
13661
13662    /// End-to-end BM25 search over a grouped multi-group index must return the
13663    /// correct documents, and a warm-cache query must match the cold-cache
13664    /// result exactly (issue #7040).
13665    #[tokio::test]
13666    async fn test_grouped_bm25_search_correct_and_cache_stable() {
13667        let tmpdir = TempObjDir::default();
13668        let store = Arc::new(LanceIndexStore::new(
13669            ObjectStore::local().into(),
13670            tmpdir.clone(),
13671            Arc::new(LanceCache::no_cache()),
13672        ));
13673
13674        // Rare tokens (one doc each) plus one common token in every doc. The
13675        // token count exceeds the runtime group size so scoring must index
13676        // into the right synthetic group slot.
13677        let num_rare = runtime_posting_group_tokens() as u32 + 2;
13678        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
13679        for t in 0..num_rare {
13680            builder.tokens.add(format!("t{t}"));
13681            builder.posting_lists.push(PostingListBuilder::new(false));
13682        }
13683        let common_id = builder.tokens.add("common".to_owned());
13684        builder.posting_lists.push(PostingListBuilder::new(false));
13685        for d in 0..num_rare {
13686            builder.posting_lists[d as usize].add(d, PositionRecorder::Count(1));
13687            builder.posting_lists[common_id as usize].add(d, PositionRecorder::Count(1));
13688            builder.docs.append(1000 + d as u64, 2);
13689        }
13690        builder.write(store.as_ref()).await.unwrap();
13691
13692        let metadata = HashMap::from([
13693            (
13694                "partitions".to_owned(),
13695                serde_json::to_string(&vec![0u64]).unwrap(),
13696            ),
13697            (
13698                "params".to_owned(),
13699                serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
13700            ),
13701            (
13702                TOKEN_SET_FORMAT_KEY.to_owned(),
13703                TokenSetFormat::default().to_string(),
13704            ),
13705        ]);
13706        let mut writer = store
13707            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
13708            .await
13709            .unwrap();
13710        writer.finish_with_metadata(metadata).await.unwrap();
13711
13712        let cache = Arc::new(LanceCache::with_capacity(1 << 20));
13713        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
13714            .await
13715            .unwrap();
13716
13717        // A rare token in the middle of a group must resolve to its one doc.
13718        let query = |term: &str| {
13719            let index = index.clone();
13720            let term = term.to_string();
13721            async move {
13722                index
13723                    .bm25_search(
13724                        Arc::new(Tokens::new(vec![term], DocType::Text)),
13725                        Arc::new(FtsSearchParams::new().with_limit(Some(num_rare as usize))),
13726                        Operator::Or,
13727                        Arc::new(NoFilter),
13728                        Arc::new(NoOpMetricsCollector),
13729                        None,
13730                    )
13731                    .await
13732                    .unwrap()
13733            }
13734        };
13735
13736        let rare_query_id = num_rare / 2;
13737        let (rare_rows, _) = query(&format!("t{rare_query_id}")).await;
13738        assert_eq!(
13739            rare_rows,
13740            vec![1000 + rare_query_id as u64],
13741            "rare token must map to its single doc",
13742        );
13743
13744        // Cold vs warm cache must agree for the common (large) token.
13745        let (cold_rows, cold_scores) = query("common").await;
13746        let (warm_rows, warm_scores) = query("common").await;
13747        assert_eq!(cold_rows.len(), num_rare as usize);
13748        assert_eq!(cold_rows, warm_rows, "warm-cache rows must match cold");
13749        assert_eq!(
13750            cold_scores, warm_scores,
13751            "warm-cache scores must match cold"
13752        );
13753    }
13754
13755    #[tokio::test]
13756    async fn flat_bm25_search_stop_word_query_over_unindexed_rows_returns_empty() {
13757        let schema = Arc::new(Schema::new(vec![
13758            ROW_ID_FIELD.clone(),
13759            Field::new("text", DataType::Utf8, false),
13760        ]));
13761        let batch = RecordBatch::try_new(
13762            schema.clone(),
13763            vec![
13764                Arc::new(UInt64Array::from(vec![0u64, 1, 2])),
13765                Arc::new(StringArray::from(vec![
13766                    "the quick brown fox",
13767                    "a lazy dog",
13768                    "for the win",
13769                ])),
13770            ],
13771        )
13772        .unwrap();
13773
13774        let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
13775            schema.clone(),
13776            stream::iter(vec![Ok(batch)]),
13777        ));
13778
13779        // Analyzer with an English stop-word filter, so the query "the"
13780        // tokenizes to zero terms -- exactly the production trigger.
13781        let tokenizer: Box<dyn LanceTokenizer> = Box::new(TextTokenizer::new(
13782            TextAnalyzer::builder(SimpleTokenizer::default())
13783                .filter(StopWordFilter::new(Language::English).unwrap())
13784                .build(),
13785        ));
13786
13787        let result_stream = flat_bm25_search_stream_with_metrics(
13788            input,
13789            "text".to_string(),
13790            "the".to_string(),
13791            tokenizer,
13792            None,
13793            100,
13794            None,
13795        )
13796        .await
13797        .unwrap();
13798
13799        let batches: Vec<_> = result_stream.try_collect().await.unwrap();
13800        let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
13801        assert_eq!(
13802            total_rows, 0,
13803            "a stop-word-only query has no searchable terms and must match nothing"
13804        );
13805    }
13806}