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 arrow::array::{FixedSizeListBuilder, Float32Builder, Int32Builder};
22use arrow::datatypes::{self, Float32Type, Int32Type, UInt64Type};
23use arrow::{
24    array::{
25        AsArray, LargeBinaryBuilder, ListBuilder, StringBuilder, UInt32Builder, UInt64Builder,
26    },
27    buffer::{Buffer, OffsetBuffer},
28};
29use arrow::{buffer::ScalarBuffer, datatypes::UInt32Type};
30use arrow_array::{
31    Array, ArrayRef, Float32Array, LargeBinaryArray, ListArray, OffsetSizeTrait, RecordBatch,
32    UInt32Array, UInt64Array,
33};
34use arrow_schema::{DataType, Field, Schema, SchemaRef};
35use async_trait::async_trait;
36use datafusion::execution::SendableRecordBatchStream;
37use datafusion::physical_plan::metrics::Time;
38use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
39use fst::{Automaton, IntoStreamer, Streamer};
40use futures::{FutureExt, Stream, StreamExt, TryStreamExt, stream};
41use itertools::{Either, Itertools};
42use lance_arrow::{RecordBatchExt, iter_str_array};
43use lance_core::cache::{CacheCodec, CacheKey, LanceCache, WeakLanceCache};
44use lance_core::deepsize::DeepSizeOf;
45use lance_core::error::{DataFusionResult, LanceOptionExt};
46use lance_core::utils::address::RowAddress;
47use lance_core::utils::tokio::{get_num_compute_intensive_cpus, spawn_cpu};
48use lance_core::utils::tracing::{IO_TYPE_LOAD_SCALAR_PART, TRACE_IO_EVENTS};
49use lance_core::{Error, ROW_ID, ROW_ID_FIELD, Result};
50use lance_select::{RowAddrMask, RowAddrTreeMap};
51use roaring::RoaringBitmap;
52use std::sync::LazyLock;
53use tokio::{sync::OnceCell, task::spawn_blocking};
54use tracing::{info, instrument, warn};
55
56use super::encoding::{MAX_POSTING_BLOCK_SIZE, PositionBlockBuilder};
57use super::impact::{IMPACT_LEVEL1_BLOCKS, ImpactSkipData, ImpactSkipDataBuilder};
58use super::iter::PostingListIterator;
59use super::lazy_docset::LazyDocSet;
60use super::tokenizer::{LEGACY_BLOCK_SIZE, validate_block_size};
61use super::{InvertedIndexBuilder, InvertedIndexParams, wand::*};
62use super::{
63    builder::{
64        BLOCK_SIZE, ScoredDoc, doc_file_path,
65        inverted_list_schema_for_version_with_block_size_and_impacts, posting_file_path,
66        token_file_path,
67    },
68    iter::PlainPostingListIterator,
69    query::*,
70    scorer::{B, IndexBM25Scorer, K1, Scorer, idf},
71};
72use super::{
73    builder::{InnerBuilder, PositionRecorder},
74    iter::CompressedPostingListIterator,
75};
76use crate::pbold;
77use crate::progress::IndexBuildProgress;
78use crate::scalar::inverted::scorer::MemBM25Scorer;
79use crate::scalar::inverted::tokenizer::document_tokenizer::LanceTokenizer;
80use crate::scalar::{
81    AnyQuery, BuiltinIndexType, CreatedIndex, IndexReader, IndexStore, MetricsCollector,
82    OldIndexDataFilter, RowIdRemapper, ScalarIndex, ScalarIndexParams, SearchResult, TokenQuery,
83    UpdateCriteria,
84};
85use crate::{FtsPrewarmOptions, Index};
86use crate::{prefilter::PreFilter, scalar::inverted::iter::take_fst_keys};
87use std::str::FromStr;
88
89// Version 0: Arrow TokenSetFormat (legacy)
90// Version 1: Fst TokenSetFormat with per-doc compressed positions
91// Version 2: Fst TokenSetFormat with shared posting-list position streams.
92// Version 3: Version 2 layout with configurable posting blocks and analyzer metadata.
93pub const INVERTED_INDEX_VERSION_V1: u32 = 1;
94pub const INVERTED_INDEX_VERSION_V2: u32 = 2;
95pub const INVERTED_INDEX_VERSION_V3: u32 = 3;
96pub const TOKENS_FILE: &str = "tokens.lance";
97pub const INVERT_LIST_FILE: &str = "invert.lance";
98pub const DOCS_FILE: &str = "docs.lance";
99pub const METADATA_FILE: &str = "metadata.lance";
100
101pub const TOKEN_COL: &str = "_token";
102pub const TOKEN_ID_COL: &str = "_token_id";
103pub const TOKEN_FST_BYTES_COL: &str = "_token_fst_bytes";
104pub const TOKEN_NEXT_ID_COL: &str = "_token_next_id";
105pub const TOKEN_TOTAL_LENGTH_COL: &str = "_token_total_length";
106pub const FREQUENCY_COL: &str = "_frequency";
107pub const POSITION_COL: &str = "_position";
108pub const COMPRESSED_POSITION_COL: &str = "_compressed_position";
109pub const POSITION_BLOCK_OFFSET_COL: &str = "_position_block_offset";
110pub const POSTING_COL: &str = "_posting";
111pub const IMPACT_COL: &str = "_impacts";
112pub const MAX_SCORE_COL: &str = "_max_score";
113pub const LENGTH_COL: &str = "_length";
114pub const BLOCK_MAX_SCORE_COL: &str = "_block_max_score";
115pub const NUM_TOKEN_COL: &str = "_num_tokens";
116pub const SCORE_COL: &str = "_score";
117pub const TOKEN_SET_FORMAT_KEY: &str = "token_set_format";
118pub const POSTING_TAIL_CODEC_KEY: &str = "posting_tail_codec";
119pub const FTS_FORMAT_VERSION_KEY: &str = "format_version";
120pub const POSITIONS_LAYOUT_KEY: &str = "positions_layout";
121pub const POSITIONS_CODEC_KEY: &str = "positions_codec";
122pub const POSTING_BLOCK_SIZE_KEY: &str = "posting_block_size";
123pub const POSTING_TAIL_CODEC_FIXED32_V1: &str = "fixed32_v1";
124pub const POSTING_TAIL_CODEC_VARINT_DELTA_V1: &str = "varint_delta_v1";
125pub const POSITIONS_LAYOUT_SHARED_STREAM_V2: &str = "shared_stream_v2";
126pub const POSITIONS_CODEC_VARINT_DOC_DELTA_V2: &str = "varint_doc_delta_v2";
127pub const POSITIONS_CODEC_PACKED_DELTA_V1: &str = "packed_delta_v1";
128pub const DELETED_FRAGMENTS_COL: &str = "deleted_fragments";
129
130// Just a heuristic when we need to pre-allocate memory for tokens
131pub const ESTIMATED_MAX_TOKENS_PER_ROW: usize = 4 * 1024;
132
133pub static SCORE_FIELD: LazyLock<Field> =
134    LazyLock::new(|| Field::new(SCORE_COL, DataType::Float32, true));
135pub static FTS_SCHEMA: LazyLock<SchemaRef> =
136    LazyLock::new(|| Arc::new(Schema::new(vec![ROW_ID_FIELD.clone(), SCORE_FIELD.clone()])));
137static ROW_ID_SCHEMA: LazyLock<SchemaRef> =
138    LazyLock::new(|| Arc::new(Schema::new(vec![ROW_ID_FIELD.clone()])));
139
140pub fn resolve_fts_format_version(
141    value: Option<&str>,
142) -> std::result::Result<InvertedListFormatVersion, Error> {
143    match value {
144        Some(value) => value.parse(),
145        None => Ok(default_fts_format_version()),
146    }
147}
148
149pub fn default_fts_format_version() -> InvertedListFormatVersion {
150    InvertedListFormatVersion::V2
151}
152
153pub fn current_fts_format_version() -> InvertedListFormatVersion {
154    default_fts_format_version()
155}
156
157pub fn max_supported_fts_format_version() -> InvertedListFormatVersion {
158    InvertedListFormatVersion::V3
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
162pub enum InvertedListFormatVersion {
163    V1,
164    #[default]
165    V2,
166    V3,
167}
168
169impl InvertedListFormatVersion {
170    pub fn from_posting_tail_codec(codec: PostingTailCodec) -> Self {
171        match codec {
172            PostingTailCodec::Fixed32 => Self::V1,
173            PostingTailCodec::VarintDelta => Self::V2,
174        }
175    }
176
177    pub fn from_posting_tail_codec_and_block_size(
178        codec: PostingTailCodec,
179        block_size: usize,
180    ) -> Result<Self> {
181        validate_block_size(block_size)?;
182        let format_version = match (codec, block_size) {
183            (PostingTailCodec::Fixed32, LEGACY_BLOCK_SIZE) => Self::V1,
184            (PostingTailCodec::VarintDelta, LEGACY_BLOCK_SIZE) => Self::V2,
185            (PostingTailCodec::VarintDelta, 256) => Self::V3,
186            (PostingTailCodec::Fixed32, 256) => {
187                return Err(Error::invalid_input(
188                    "FTS format_version=3 requires the varint-delta posting tail codec".to_string(),
189                ));
190            }
191            _ => unreachable!("validate_block_size limits supported block sizes"),
192        };
193        validate_format_version_block_size(format_version, block_size)?;
194        Ok(format_version)
195    }
196
197    pub fn index_version(self) -> u32 {
198        match self {
199            Self::V1 => INVERTED_INDEX_VERSION_V1,
200            Self::V2 => INVERTED_INDEX_VERSION_V2,
201            Self::V3 => INVERTED_INDEX_VERSION_V3,
202        }
203    }
204
205    pub fn posting_tail_codec(self) -> PostingTailCodec {
206        match self {
207            Self::V1 => PostingTailCodec::Fixed32,
208            Self::V2 | Self::V3 => PostingTailCodec::VarintDelta,
209        }
210    }
211
212    pub fn position_codec(self) -> Option<PositionStreamCodec> {
213        match self {
214            Self::V1 => None,
215            Self::V2 | Self::V3 => Some(PositionStreamCodec::PackedDelta),
216        }
217    }
218
219    pub fn uses_shared_position_stream(self) -> bool {
220        matches!(self, Self::V2 | Self::V3)
221    }
222}
223
224impl FromStr for InvertedListFormatVersion {
225    type Err = Error;
226
227    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
228        match s.trim() {
229            "1" | "v1" | "V1" => Ok(Self::V1),
230            "2" | "v2" | "V2" => Ok(Self::V2),
231            "3" | "v3" | "V3" => Ok(Self::V3),
232            other => Err(Error::index(format!(
233                "unsupported FTS format version {}, expected 1, 2, or 3",
234                other
235            ))),
236        }
237    }
238}
239
240pub fn default_fts_format_version_for_block_size(
241    block_size: usize,
242) -> Result<InvertedListFormatVersion> {
243    validate_block_size(block_size)?;
244    match block_size {
245        LEGACY_BLOCK_SIZE => Ok(InvertedListFormatVersion::V2),
246        256 => Ok(InvertedListFormatVersion::V3),
247        _ => unreachable!("validate_block_size limits supported block sizes"),
248    }
249}
250
251pub fn validate_format_version_block_size(
252    format_version: InvertedListFormatVersion,
253    block_size: usize,
254) -> Result<()> {
255    validate_block_size(block_size)?;
256    match (format_version, block_size) {
257        (InvertedListFormatVersion::V1 | InvertedListFormatVersion::V2, LEGACY_BLOCK_SIZE)
258        | (InvertedListFormatVersion::V3, _) => Ok(()),
259        (InvertedListFormatVersion::V1 | InvertedListFormatVersion::V2, 256) => {
260            Err(Error::invalid_input(format!(
261                "FTS format_version={} is incompatible with block_size=256; use format_version=3",
262                format_version.index_version()
263            )))
264        }
265        _ => unreachable!("validate_block_size limits supported block sizes"),
266    }
267}
268
269#[derive(Debug)]
270struct PartitionCandidates {
271    tokens_by_position: Vec<String>,
272    grouped_expansions: Vec<GroupedExpansionTerms>,
273    candidates: Vec<DocCandidate>,
274}
275
276impl PartitionCandidates {
277    fn empty() -> Self {
278        Self {
279            tokens_by_position: Vec::new(),
280            grouped_expansions: Vec::new(),
281            candidates: Vec::new(),
282        }
283    }
284}
285
286#[derive(Debug)]
287struct LoadedPostings {
288    postings: Vec<PostingIterator>,
289    grouped_expansions: Vec<GroupedExpansionTerms>,
290    impact_safe: bool,
291    exact_scoring_required: bool,
292}
293
294impl LoadedPostings {
295    fn empty() -> Self {
296        Self {
297            postings: Vec::new(),
298            grouped_expansions: Vec::new(),
299            impact_safe: false,
300            exact_scoring_required: false,
301        }
302    }
303}
304
305#[derive(Debug)]
306struct GroupedExpansionTerms {
307    position: u32,
308    terms: Arc<[GroupedTermScorer]>,
309}
310
311#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Default)]
312pub enum TokenSetFormat {
313    Arrow,
314    #[default]
315    Fst,
316}
317
318impl Display for TokenSetFormat {
319    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
320        match self {
321            Self::Arrow => f.write_str("arrow"),
322            Self::Fst => f.write_str("fst"),
323        }
324    }
325}
326
327impl FromStr for TokenSetFormat {
328    type Err = Error;
329
330    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
331        match s.trim() {
332            "" => Ok(Self::Arrow),
333            "arrow" => Ok(Self::Arrow),
334            "fst" => Ok(Self::Fst),
335            other => Err(Error::index(format!(
336                "unsupported token set format {}",
337                other
338            ))),
339        }
340    }
341}
342
343impl DeepSizeOf for TokenSetFormat {
344    fn deep_size_of_children(&self, _: &mut lance_core::deepsize::Context) -> usize {
345        0
346    }
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
350pub enum PositionStreamCodec {
351    VarintDocDelta,
352    #[default]
353    PackedDelta,
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
357pub enum PostingTailCodec {
358    Fixed32,
359    #[default]
360    VarintDelta,
361}
362
363impl PostingTailCodec {
364    pub fn as_str(self) -> &'static str {
365        match self {
366            Self::Fixed32 => POSTING_TAIL_CODEC_FIXED32_V1,
367            Self::VarintDelta => POSTING_TAIL_CODEC_VARINT_DELTA_V1,
368        }
369    }
370
371    fn from_metadata_value(value: &str) -> Result<Self> {
372        match value.trim() {
373            POSTING_TAIL_CODEC_FIXED32_V1 => Ok(Self::Fixed32),
374            POSTING_TAIL_CODEC_VARINT_DELTA_V1 => Ok(Self::VarintDelta),
375            other => Err(Error::index(format!(
376                "unsupported posting tail codec {}",
377                other
378            ))),
379        }
380    }
381}
382
383pub(super) fn parse_posting_tail_codec(
384    metadata: &HashMap<String, String>,
385) -> Result<PostingTailCodec> {
386    Ok(metadata
387        .get(POSTING_TAIL_CODEC_KEY)
388        .map(|codec| PostingTailCodec::from_metadata_value(codec))
389        .transpose()?
390        .unwrap_or(PostingTailCodec::Fixed32))
391}
392
393pub(super) fn parse_posting_block_size(metadata: &HashMap<String, String>) -> Result<usize> {
394    metadata
395        .get(POSTING_BLOCK_SIZE_KEY)
396        .map(|value| {
397            let block_size = value.parse::<usize>().map_err(|err| {
398                Error::index(format!(
399                    "invalid {POSTING_BLOCK_SIZE_KEY} metadata value {value:?}: {err}"
400                ))
401            })?;
402            validate_block_size(block_size)
403        })
404        .transpose()
405        .map(|block_size| block_size.unwrap_or(LEGACY_BLOCK_SIZE))
406}
407
408impl PositionStreamCodec {
409    pub fn as_str(self) -> &'static str {
410        match self {
411            Self::VarintDocDelta => POSITIONS_CODEC_VARINT_DOC_DELTA_V2,
412            Self::PackedDelta => POSITIONS_CODEC_PACKED_DELTA_V1,
413        }
414    }
415
416    fn from_metadata_value(value: &str) -> Result<Self> {
417        match value.trim() {
418            POSITIONS_CODEC_VARINT_DOC_DELTA_V2 => Ok(Self::VarintDocDelta),
419            POSITIONS_CODEC_PACKED_DELTA_V1 => Ok(Self::PackedDelta),
420            other => Err(Error::index(format!(
421                "unsupported positions codec {}",
422                other
423            ))),
424        }
425    }
426}
427
428fn parse_shared_position_codec(metadata: &HashMap<String, String>) -> Result<PositionStreamCodec> {
429    if let Some(codec) = metadata.get(POSITIONS_CODEC_KEY) {
430        return PositionStreamCodec::from_metadata_value(codec);
431    }
432
433    match metadata
434        .get(POSITIONS_LAYOUT_KEY)
435        .map(|layout| layout.as_str())
436    {
437        Some(POSITIONS_LAYOUT_SHARED_STREAM_V2) => Ok(PositionStreamCodec::VarintDocDelta),
438        _ => Ok(PositionStreamCodec::VarintDocDelta),
439    }
440}
441
442pub(super) fn parse_format_version_from_metadata(
443    metadata: &HashMap<String, String>,
444) -> Result<InvertedListFormatVersion> {
445    if let Some(value) = metadata.get(FTS_FORMAT_VERSION_KEY) {
446        let format_version = InvertedListFormatVersion::from_str(value)?;
447        let block_size = parse_posting_block_size(metadata)?;
448        validate_format_version_block_size(format_version, block_size)?;
449        return Ok(format_version);
450    }
451    let block_size = parse_posting_block_size(metadata)?;
452    if block_size == 256 {
453        if metadata
454            .get(POSTING_TAIL_CODEC_KEY)
455            .map(|_| parse_posting_tail_codec(metadata))
456            .transpose()?
457            .is_some_and(|posting_tail_codec| posting_tail_codec != PostingTailCodec::VarintDelta)
458        {
459            return Err(Error::index(
460                "FTS block_size=256 requires the varint-delta posting tail codec".to_string(),
461            ));
462        }
463        return Ok(InvertedListFormatVersion::V3);
464    }
465    if metadata.contains_key(POSITIONS_CODEC_KEY) || metadata.contains_key(POSITIONS_LAYOUT_KEY) {
466        return Ok(InvertedListFormatVersion::V2);
467    }
468    if parse_posting_tail_codec(metadata)? == PostingTailCodec::VarintDelta {
469        Ok(InvertedListFormatVersion::V2)
470    } else {
471        Ok(InvertedListFormatVersion::V1)
472    }
473}
474
475#[derive(Clone)]
476pub struct InvertedIndex {
477    params: InvertedIndexParams,
478    store: Arc<dyn IndexStore>,
479    tokenizer: Box<dyn LanceTokenizer>,
480    token_set_format: TokenSetFormat,
481    format_version: InvertedListFormatVersion,
482    pub(crate) partitions: Vec<Arc<InvertedPartition>>,
483    corpus_stats: Arc<OnceCell<(u64, usize)>>,
484    // Fragments which are contained in the index, but no longer in the dataset.
485    // These should be pruned at search time since we don't prune them at update time.
486    deleted_fragments: RoaringBitmap,
487}
488
489impl Debug for InvertedIndex {
490    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
491        f.debug_struct("InvertedIndex")
492            .field("params", &self.params)
493            .field("token_set_format", &self.token_set_format)
494            .field("format_version", &self.format_version)
495            .field("partitions", &self.partitions)
496            .field("deleted_fragments", &self.deleted_fragments)
497            .finish()
498    }
499}
500
501impl DeepSizeOf for InvertedIndex {
502    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
503        self.partitions.deep_size_of_children(context)
504    }
505}
506
507/// Resolve any `Pending` candidates that wand emitted via the
508/// deferred-row_id path. After this returns, every entry in
509/// `candidates` carries a real row_id.
510async fn resolve_deferred_candidates(
511    docs: &LazyDocSet,
512    candidates: &mut [DocCandidate],
513) -> Result<()> {
514    let pending: Vec<u32> = candidates
515        .iter()
516        .filter_map(|c| match c.addr {
517            CandidateAddr::Pending(d) => Some(d),
518            CandidateAddr::RowId(_) => None,
519        })
520        .collect();
521    if pending.is_empty() {
522        return Ok(());
523    }
524    let mut iter = docs.resolve_row_ids(&pending).await?.into_iter();
525    for c in candidates {
526        if matches!(c.addr, CandidateAddr::Pending(_)) {
527            let r = iter.next().ok_or_else(|| {
528                Error::internal("resolve_row_ids returned fewer items than requested")
529            })?;
530            c.addr = CandidateAddr::RowId(r);
531        }
532    }
533    Ok(())
534}
535
536impl InvertedIndex {
537    fn format_version(&self) -> InvertedListFormatVersion {
538        self.format_version
539    }
540
541    fn index_version(&self) -> u32 {
542        match (self.token_set_format, self.format_version()) {
543            (
544                TokenSetFormat::Arrow,
545                InvertedListFormatVersion::V1 | InvertedListFormatVersion::V2,
546            ) => 0,
547            (_, format_version) => format_version.index_version(),
548        }
549    }
550
551    fn posting_tail_codec(&self) -> PostingTailCodec {
552        self.partitions
553            .first()
554            .map(|partition| partition.inverted_list.posting_tail_codec())
555            .unwrap_or_default()
556    }
557
558    fn to_builder(&self) -> InvertedIndexBuilder {
559        self.to_builder_with_offset(None)
560    }
561
562    fn to_builder_with_offset(&self, fragment_mask: Option<u64>) -> InvertedIndexBuilder {
563        if self.is_legacy() {
564            // for legacy format, we re-create the index in the new format
565            InvertedIndexBuilder::from_existing_index(
566                self.params.clone(),
567                None,
568                Vec::new(),
569                self.token_set_format,
570                fragment_mask,
571                self.deleted_fragments.clone(),
572            )
573            .with_posting_tail_codec(self.posting_tail_codec())
574        } else {
575            let partitions = match fragment_mask {
576                Some(fragment_mask) => self
577                    .partitions
578                    .iter()
579                    // Filter partitions that belong to the specified fragment
580                    // The mask contains fragment_id in high 32 bits, we check if partition's
581                    // fragment_id matches by comparing the masked result with the original mask
582                    .filter(|part| part.belongs_to_fragment(fragment_mask))
583                    .map(|part| part.id())
584                    .collect(),
585                None => self.partitions.iter().map(|part| part.id()).collect(),
586            };
587
588            InvertedIndexBuilder::from_existing_index(
589                self.params.clone(),
590                Some(self.store.clone()),
591                partitions,
592                self.token_set_format,
593                fragment_mask,
594                self.deleted_fragments.clone(),
595            )
596            .with_format_version(self.format_version())
597        }
598    }
599
600    pub fn tokenizer(&self) -> Box<dyn LanceTokenizer> {
601        self.tokenizer.clone()
602    }
603
604    pub fn params(&self) -> &InvertedIndexParams {
605        &self.params
606    }
607
608    /// Returns the number of partitions in this inverted index.
609    pub fn partition_count(&self) -> usize {
610        self.partitions.len()
611    }
612    /// Returns the set of fragments which are contained in the index, but no longer in the dataset.
613    ///
614    /// Most other indices remove data from deleted fragments when the index updates (copy-on-write).
615    /// However, this would require an expensive copy of the FTS index.  Instead, we track the deleted
616    /// fragments and prune them at search time (merge-on-read).
617    pub fn deleted_fragments(&self) -> &RoaringBitmap {
618        &self.deleted_fragments
619    }
620
621    pub async fn merge_segments(
622        segments: &[Arc<Self>],
623        new_data: SendableRecordBatchStream,
624        dest_store: &dyn IndexStore,
625        old_data_filter: Option<OldIndexDataFilter>,
626        progress: Arc<dyn IndexBuildProgress>,
627    ) -> Result<CreatedIndex> {
628        let Some(first) = segments.first() else {
629            return Err(Error::invalid_input(
630                "cannot merge inverted index without at least one source segment".to_string(),
631            ));
632        };
633
634        for segment in segments.iter().skip(1) {
635            if segment.params != first.params {
636                return Err(Error::index(
637                    "cannot merge inverted index segments with different parameters".to_string(),
638                ));
639            }
640            if segment.token_set_format != first.token_set_format {
641                return Err(Error::index(
642                    "cannot merge inverted index segments with different token set formats"
643                        .to_string(),
644                ));
645            }
646            if segment.format_version() != first.format_version() {
647                return Err(Error::index(
648                    "cannot merge inverted index segments with different format versions"
649                        .to_string(),
650                ));
651            }
652            if segment.posting_tail_codec() != first.posting_tail_codec() {
653                return Err(Error::index(
654                    "cannot merge inverted index segments with different posting tail codecs"
655                        .to_string(),
656                ));
657            }
658        }
659
660        let mut builder = InvertedIndexBuilder::new(first.params.clone()).with_progress(progress);
661        builder = builder
662            .with_token_set_format(first.token_set_format)
663            .with_format_version(first.format_version());
664        let files = builder
665            .update_from_segments(new_data, dest_store, segments, old_data_filter)
666            .await?;
667
668        let details = pbold::InvertedIndexDetails::try_from(&first.params)?;
669
670        Ok(CreatedIndex {
671            index_details: prost_types::Any::from_msg(&details).unwrap(),
672            index_version: first.index_version(),
673            files,
674        })
675    }
676
677    /// Build a single-segment [`MemBM25Scorer`] whose per-term IDF table
678    /// covers every token that the per-partition scoring loop will look
679    /// up. For fuzzy queries that means the union of Levenshtein
680    /// expansions, not just the raw query tokens — otherwise
681    /// `query_weight(expanded_token)` returns 0 and the BM25 contribution
682    /// of every expanded match is discarded.
683    pub async fn bm25_base_scorer(
684        &self,
685        query_tokens: &Tokens,
686        params: &FtsSearchParams,
687    ) -> Result<MemBM25Scorer> {
688        if matches!(params.fuzziness, Some(n) if n != 0) {
689            let expanded = self.expand_fuzzy_tokens(query_tokens, params)?;
690            self.bm25_scorer_for_final_tokens(&expanded).await
691        } else {
692            self.bm25_scorer_for_final_tokens(query_tokens).await
693        }
694    }
695
696    /// Scorer for a token list that needs no further fuzzy expansion: dedup
697    /// the terms and pull their document frequencies. `bm25_search` calls
698    /// this with the tokens it already expanded, so the expansion runs once
699    /// per query rather than once for the scorer and once per partition.
700    async fn bm25_scorer_for_final_tokens(&self, tokens: &Tokens) -> Result<MemBM25Scorer> {
701        let (total_tokens, num_docs) = self.aggregate_corpus_stats().await?;
702        let mut terms: Vec<String> = Vec::new();
703        let mut seen = HashSet::new();
704        for token in tokens {
705            if seen.insert(token.to_string()) {
706                terms.push(token.to_string());
707            }
708        }
709        let mut token_docs = HashMap::with_capacity(terms.len());
710        for term in &terms {
711            let df = self.df_for_term(term).await?;
712            token_docs.insert(term.clone(), df);
713        }
714        Ok(MemBM25Scorer::new(total_tokens, num_docs, token_docs))
715    }
716
717    pub async fn bm25_stats_for_terms(&self, terms: &[String]) -> Result<(u64, usize, Vec<usize>)> {
718        let (total_tokens, num_docs) = self.aggregate_corpus_stats().await?;
719        let token_docs =
720            futures::future::try_join_all(terms.iter().map(|term| self.df_for_term(term))).await?;
721        Ok((total_tokens, num_docs, token_docs))
722    }
723
724    /// Aggregate per-partition `total_tokens` and `num_docs` across the
725    /// index. `len` is cheap (no IO); `total_tokens_num` reads only the
726    /// num_tokens column the first time per partition and caches it on
727    /// `LazyDocSet`. Avoids materializing the full DocSet just to get
728    /// these two scalars.
729    async fn aggregate_corpus_stats(&self) -> Result<(u64, usize)> {
730        self.corpus_stats
731            .get_or_try_init(|| async {
732                let io_parallelism = self.store.io_parallelism();
733                let num_docs: usize = self.partitions.iter().map(|p| p.docs.len()).sum();
734                let futures = self
735                    .partitions
736                    .iter()
737                    .map(|p| {
738                        let docs = p.docs.clone();
739                        async move { docs.total_tokens_num().await }
740                    })
741                    .collect::<Vec<_>>();
742                let totals: Vec<u64> = stream::iter(futures)
743                    .buffer_unordered(io_parallelism)
744                    .try_collect()
745                    .await?;
746                Ok((totals.into_iter().sum(), num_docs))
747            })
748            .await
749            .copied()
750    }
751
752    /// Sum the posting-list length for `term` across this index's partitions
753    /// via single-row reads, with partition lookups bounded by the store's
754    /// `io_parallelism()`.
755    async fn df_for_term(&self, term: &str) -> Result<usize> {
756        let io_parallelism = self.store.io_parallelism();
757        let futures = self
758            .partitions
759            .iter()
760            .map(|part| {
761                let part = part.clone();
762                async move {
763                    match part.tokens.get(term) {
764                        Some(token_id) => part.inverted_list.posting_len_for_token(token_id).await,
765                        None => Ok(0),
766                    }
767                }
768            })
769            .collect::<Vec<_>>();
770        let dfs: Vec<usize> = stream::iter(futures)
771            .buffer_unordered(io_parallelism)
772            .try_collect()
773            .await?;
774        Ok(dfs.into_iter().sum())
775    }
776
777    /// Expand fuzzy query tokens against all partitions in this segment.
778    ///
779    /// `params.max_expansions` caps the whole query's expansion, not any
780    /// single partition's: for each query token the per-partition candidates
781    /// (each streamed in FST key order) merge into one lexicographically
782    /// ordered set, and the remaining budget takes a prefix of it. The
783    /// selected terms are a pure function of the segment's vocabulary, so
784    /// splitting the same corpus into more partitions cannot change which
785    /// terms a fuzzy query matches.
786    pub fn expand_fuzzy_tokens(&self, tokens: &Tokens, params: &FtsSearchParams) -> Result<Tokens> {
787        let mut expanded_tokens = Vec::new();
788        let mut expanded_positions = Vec::new();
789        let mut seen = HashSet::new();
790        for token_idx in 0..tokens.len() {
791            let remaining = params.max_expansions.saturating_sub(expanded_tokens.len());
792            if remaining == 0 {
793                break;
794            }
795            let token = tokens.get_token(token_idx);
796            let position = tokens.position(token_idx);
797            // Each partition contributes at most its `remaining`
798            // lexicographically smallest candidates, so the global
799            // lex-smallest `remaining` selection below is unaffected by the
800            // per-partition truncation.
801            let mut candidates = BTreeSet::new();
802            let base_prefix_len = tokens.token_type().prefix_len(token) as u32;
803            for partition in &self.partitions {
804                partition.collect_fuzzy_candidates(
805                    token,
806                    base_prefix_len,
807                    params,
808                    remaining,
809                    &mut candidates,
810                )?;
811            }
812            for candidate in candidates {
813                if expanded_tokens.len() >= params.max_expansions {
814                    break;
815                }
816                if seen.insert((candidate.clone(), position)) {
817                    expanded_tokens.push(candidate);
818                    expanded_positions.push(position);
819                }
820            }
821        }
822        Ok(Tokens::with_positions(
823            expanded_tokens,
824            expanded_positions,
825            tokens.token_type().clone(),
826        ))
827    }
828
829    /// Search documents that match the query and return row ids sorted by BM25 score.
830    ///
831    /// When `base_scorer` is provided, search uses those corpus-level BM25 statistics
832    /// instead of deriving them from this segment alone.
833    #[instrument(level = "debug", skip_all)]
834    pub async fn bm25_search(
835        &self,
836        tokens: Arc<Tokens>,
837        params: Arc<FtsSearchParams>,
838        operator: Operator,
839        prefilter: Arc<dyn PreFilter>,
840        metrics: Arc<dyn MetricsCollector>,
841        base_scorer: Option<&MemBM25Scorer>,
842    ) -> Result<(Vec<u64>, Vec<f32>)> {
843        // Fuzzy expansion runs once here, with the global `max_expansions`
844        // budget, instead of once per partition: partitions receive the
845        // final token list, so the matched terms cannot depend on how the
846        // corpus happens to be partitioned.
847        let tokens = if matches!(params.fuzziness, Some(n) if n != 0) {
848            let expanded = Arc::new(self.expand_fuzzy_tokens(tokens.as_ref(), params.as_ref())?);
849            if operator == Operator::And || params.phrase_slop.is_some() {
850                // AND/phrase semantics require every original token position
851                // to keep at least one expansion; a position that expands to
852                // nothing anywhere in the segment can never be matched.
853                let surviving = (0..expanded.len())
854                    .map(|idx| expanded.position(idx))
855                    .collect::<HashSet<_>>();
856                if (0..tokens.len()).any(|idx| !surviving.contains(&tokens.position(idx))) {
857                    return Ok((Vec::new(), Vec::new()));
858                }
859            }
860            expanded
861        } else {
862            tokens
863        };
864
865        // The wand only consults `scorer.doc_weight`, which is metadata-free.
866        // The outer aggregation below consults `scorer.query_weight`, which
867        // hits per-token `posting_len`; building a `MemBM25Scorer` with
868        // precomputed per-term IDFs avoids the v2 bulk metadata pull.
869        let local_scorer;
870        let scorer: &MemBM25Scorer = if let Some(base_scorer) = base_scorer {
871            base_scorer
872        } else {
873            local_scorer = self.bm25_scorer_for_final_tokens(tokens.as_ref()).await?;
874            &local_scorer
875        };
876        let impact_scorer = Arc::new(scorer.clone());
877
878        let limit = params.limit.unwrap_or(usize::MAX);
879        if limit == 0 {
880            return Ok((Vec::new(), Vec::new()));
881        }
882
883        fn push_scored_candidate(
884            candidates: &mut BinaryHeap<Reverse<ScoredDoc>>,
885            limit: usize,
886            addr: CandidateAddr,
887            score: f32,
888        ) -> Result<()> {
889            // resolve_deferred_candidates ran upstream, so every candidate
890            // carries a real row_id at this point.
891            let row_id = match addr {
892                CandidateAddr::RowId(r) => r,
893                CandidateAddr::Pending(_) => {
894                    return Err(Error::internal(
895                        "bm25_search post-condition: deferred candidate left unresolved",
896                    ));
897                }
898            };
899
900            if candidates.len() < limit {
901                candidates.push(Reverse(ScoredDoc::new(row_id, score)));
902            } else if candidates.peek().unwrap().0.score.0 < score {
903                candidates.pop();
904                candidates.push(Reverse(ScoredDoc::new(row_id, score)));
905            }
906            Ok(())
907        }
908
909        let mask = prefilter.mask();
910
911        let mut candidates = BinaryHeap::new();
912        // Shared top-k floor across this query's partitions. Seeded to -inf so
913        // the first real score wins; each partition publishes its local k-th
914        // and prunes against the running global k-th (a lower bound on the true
915        // global k-th - see `Wand::shared_threshold`).
916        let impact_shared_threshold = Arc::new(AtomicU32::new(f32::NEG_INFINITY.to_bits()));
917        let legacy_shared_threshold = Arc::new(AtomicU32::new(f32::NEG_INFINITY.to_bits()));
918        let parts = self
919            .partitions
920            .iter()
921            .map(|part| {
922                let part = part.clone();
923                let tokens = tokens.clone();
924                let params = params.clone();
925                let mask = mask.clone();
926                let metrics = metrics.clone();
927                let impact_scorer = impact_scorer.clone();
928                let impact_shared_threshold = impact_shared_threshold.clone();
929                let legacy_shared_threshold = legacy_shared_threshold.clone();
930                async move {
931                    let loaded_postings = part
932                        .load_posting_lists(
933                            tokens.as_ref(),
934                            params.as_ref(),
935                            operator,
936                            impact_scorer.as_ref(),
937                            metrics.as_ref(),
938                        )
939                        .await?;
940                    let LoadedPostings {
941                        postings,
942                        grouped_expansions,
943                        impact_safe,
944                        exact_scoring_required,
945                    } = loaded_postings;
946                    if postings.is_empty() {
947                        // No hits in this partition; its DocSet stays
948                        // unloaded, so we never pay the per-doc
949                        // row_id/num_tokens download for it.
950                        return Result::Ok(PartitionCandidates::empty());
951                    }
952                    let docs_for_wand = part.docs.docs_for_wand(mask.as_ref()).await?;
953                    let max_position = postings
954                        .iter()
955                        .map(|posting| posting.term_index() as usize)
956                        .max()
957                        .unwrap_or_default();
958                    let mut tokens_by_position = vec![String::new(); max_position + 1];
959                    for posting in &postings {
960                        let idx = posting.term_index() as usize;
961                        tokens_by_position[idx] = posting.token().to_owned();
962                    }
963                    let params = params.clone();
964                    let mask = mask.clone();
965                    let metrics = metrics.clone();
966                    let part_for_wand = part.clone();
967                    let use_global_scorer = impact_safe || exact_scoring_required;
968                    let partition_threshold = if use_global_scorer {
969                        impact_shared_threshold
970                    } else {
971                        legacy_shared_threshold
972                    };
973                    let wand_scorer = use_global_scorer.then(|| impact_scorer.clone());
974                    let candidates = spawn_cpu(move || {
975                        let candidates = part_for_wand.bm25_search(
976                            docs_for_wand.as_ref(),
977                            params.as_ref(),
978                            operator,
979                            mask,
980                            postings,
981                            wand_scorer,
982                            metrics.as_ref(),
983                            partition_threshold,
984                        )?;
985                        std::result::Result::<_, Error>::Ok(candidates)
986                    })
987                    .await?;
988                    let mut partition_result = PartitionCandidates {
989                        tokens_by_position,
990                        grouped_expansions,
991                        candidates,
992                    };
993                    resolve_deferred_candidates(&part.docs, &mut partition_result.candidates)
994                        .await?;
995                    Result::Ok(partition_result)
996                }
997            })
998            .collect::<Vec<_>>();
999        let mut parts = stream::iter(parts).buffer_unordered(get_num_compute_intensive_cpus());
1000        let mut idf_cache: HashMap<String, f32> = HashMap::new();
1001        while let Some(res) = parts.try_next().await? {
1002            if res.candidates.is_empty() {
1003                continue;
1004            }
1005            let PartitionCandidates {
1006                tokens_by_position,
1007                grouped_expansions,
1008                candidates: part_candidates,
1009            } = res;
1010            let mut idf_by_position = Vec::with_capacity(tokens_by_position.len());
1011            for token in &tokens_by_position {
1012                let idf_weight = match idf_cache.get(token) {
1013                    Some(weight) => *weight,
1014                    None => {
1015                        let weight = scorer.query_weight(token);
1016                        idf_cache.insert(token.clone(), weight);
1017                        weight
1018                    }
1019                };
1020                idf_by_position.push(idf_weight);
1021            }
1022
1023            if grouped_expansions.is_empty() {
1024                for DocCandidate {
1025                    addr,
1026                    freqs,
1027                    doc_length,
1028                    ..
1029                } in part_candidates
1030                {
1031                    let mut score = 0.0;
1032                    for (term_index, freq) in freqs.into_iter() {
1033                        debug_assert!((term_index as usize) < idf_by_position.len());
1034                        score += idf_by_position[term_index as usize]
1035                            * scorer.doc_weight(freq, doc_length);
1036                    }
1037                    push_scored_candidate(&mut candidates, limit, addr, score)?;
1038                }
1039            } else {
1040                let grouped_positions = grouped_expansions
1041                    .iter()
1042                    .map(|group| group.position)
1043                    .collect::<HashSet<_>>();
1044                for DocCandidate {
1045                    addr,
1046                    posting_doc_id,
1047                    freqs,
1048                    doc_length,
1049                } in part_candidates
1050                {
1051                    let mut score = 0.0;
1052                    for (term_index, freq) in freqs.into_iter() {
1053                        if grouped_positions.contains(&term_index) {
1054                            continue;
1055                        }
1056                        debug_assert!((term_index as usize) < idf_by_position.len());
1057                        score += idf_by_position[term_index as usize]
1058                            * scorer.doc_weight(freq, doc_length);
1059                    }
1060                    for group in &grouped_expansions {
1061                        for term in group.terms.iter() {
1062                            let Some(freq) = term.frequency(posting_doc_id) else {
1063                                continue;
1064                            };
1065                            score += term.query_weight() * scorer.doc_weight(freq, doc_length);
1066                        }
1067                    }
1068                    push_scored_candidate(&mut candidates, limit, addr, score)?;
1069                }
1070            }
1071        }
1072
1073        Ok(candidates
1074            .into_sorted_vec()
1075            .into_iter()
1076            .map(|Reverse(doc)| (doc.row_id, doc.score.0))
1077            .unzip())
1078    }
1079
1080    async fn load_legacy_index(
1081        store: Arc<dyn IndexStore>,
1082        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
1083        index_cache: &LanceCache,
1084    ) -> Result<Arc<Self>> {
1085        log::warn!("loading legacy FTS index");
1086        let tokens_fut = tokio::spawn({
1087            let store = store.clone();
1088            async move {
1089                let token_reader = store.open_index_file(TOKENS_FILE).await?;
1090                let tokenizer = token_reader
1091                    .schema()
1092                    .metadata
1093                    .get("tokenizer")
1094                    .map(|s| serde_json::from_str::<InvertedIndexParams>(s))
1095                    .transpose()?
1096                    .unwrap_or_default();
1097                let tokens = TokenSet::load(token_reader, TokenSetFormat::Arrow).await?;
1098                Result::Ok((tokenizer, tokens))
1099            }
1100        });
1101        let invert_list_fut = tokio::spawn({
1102            let store = store.clone();
1103            let index_cache_clone = index_cache.clone();
1104            async move {
1105                let invert_list_reader = store.open_index_file(INVERT_LIST_FILE).await?;
1106                let invert_list =
1107                    PostingListReader::try_new(invert_list_reader, &index_cache_clone).await?;
1108                Result::Ok(Arc::new(invert_list))
1109            }
1110        });
1111        let docs_fut = tokio::spawn({
1112            let store = store.clone();
1113            async move {
1114                let docs_reader = store.open_index_file(DOCS_FILE).await?;
1115                let docs = DocSet::load(docs_reader, true, frag_reuse_index).await?;
1116                Result::Ok(docs)
1117            }
1118        });
1119
1120        let (tokenizer_config, tokens) = tokens_fut.await??;
1121        let inverted_list = invert_list_fut.await??;
1122        let docs = docs_fut.await??;
1123
1124        let tokenizer = tokenizer_config.build()?;
1125
1126        Ok(Arc::new(Self {
1127            params: tokenizer_config,
1128            store: store.clone(),
1129            tokenizer,
1130            token_set_format: TokenSetFormat::Arrow,
1131            format_version: InvertedListFormatVersion::V1,
1132            partitions: vec![Arc::new(InvertedPartition {
1133                id: 0,
1134                store,
1135                tokens,
1136                inverted_list,
1137                docs: Arc::new(LazyDocSet::from_loaded(docs)),
1138                token_set_format: TokenSetFormat::Arrow,
1139            })],
1140            corpus_stats: Arc::new(OnceCell::new()),
1141            deleted_fragments: RoaringBitmap::new(),
1142        }))
1143    }
1144
1145    pub fn is_legacy(&self) -> bool {
1146        self.partitions.len() == 1 && self.partitions[0].is_legacy()
1147    }
1148
1149    /// Read only the index's [`InvertedIndexParams`],
1150    /// Contains more complete info than manifest's lossy `InvertedIndexDetails`.
1151    pub async fn load_params(store: &dyn IndexStore) -> Result<InvertedIndexParams> {
1152        match store.open_index_file(METADATA_FILE).await {
1153            Ok(reader) => {
1154                let params = reader
1155                    .schema()
1156                    .metadata
1157                    .get("params")
1158                    .ok_or(Error::index("params not found in metadata".to_owned()))?;
1159                Ok(serde_json::from_str::<InvertedIndexParams>(params)?)
1160            }
1161            Err(metadata_error) => {
1162                // Legacy format: params live in the tokens file (see
1163                // `load_legacy_index`). Some S3 configurations return 403 for
1164                // a missing object, so the readable legacy file is the
1165                // authoritative format probe.
1166                let Ok(reader) = store.open_index_file(TOKENS_FILE).await else {
1167                    return Err(metadata_error);
1168                };
1169                Ok(reader
1170                    .schema()
1171                    .metadata
1172                    .get("tokenizer")
1173                    .map(|s| serde_json::from_str::<InvertedIndexParams>(s))
1174                    .transpose()?
1175                    .unwrap_or_default())
1176            }
1177        }
1178    }
1179
1180    pub async fn load(
1181        store: Arc<dyn IndexStore>,
1182        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
1183        index_cache: &LanceCache,
1184    ) -> Result<Arc<Self>>
1185    where
1186        Self: Sized,
1187    {
1188        // for new index format, there is a metadata file and multiple partitions,
1189        // each partition is a separate index containing tokens, inverted list and docs.
1190        // for old index format, there is no metadata file, and it's just like a single partition
1191
1192        match store.open_index_file(METADATA_FILE).await {
1193            Ok(reader) => {
1194                let params = reader
1195                    .schema()
1196                    .metadata
1197                    .get("params")
1198                    .ok_or(Error::index("params not found in metadata".to_owned()))?;
1199                let params = serde_json::from_str::<InvertedIndexParams>(params)?;
1200                let partitions = reader
1201                    .schema()
1202                    .metadata
1203                    .get("partitions")
1204                    .ok_or(Error::index("partitions not found in metadata".to_owned()))?;
1205                let partitions: Vec<u64> = serde_json::from_str(partitions)?;
1206                let token_set_format = reader
1207                    .schema()
1208                    .metadata
1209                    .get(TOKEN_SET_FORMAT_KEY)
1210                    .map(|name| TokenSetFormat::from_str(name))
1211                    .transpose()?
1212                    .unwrap_or(TokenSetFormat::Arrow);
1213                let format_version = parse_format_version_from_metadata(&reader.schema().metadata)?;
1214
1215                // Load deleted_fragments if present (optional for backward compatibility)
1216                let deleted_fragments = if reader.num_rows() > 0 {
1217                    let metadata_batch = reader.read_range(0..1, None).await?;
1218                    if let Some(col) = metadata_batch.column_by_name(DELETED_FRAGMENTS_COL) {
1219                        let arr = col.as_binary_opt::<i32>().expect_ok()?;
1220                        RoaringBitmap::deserialize_from(arr.value(0))?
1221                    } else {
1222                        RoaringBitmap::new()
1223                    }
1224                } else {
1225                    RoaringBitmap::new()
1226                };
1227
1228                let format = token_set_format;
1229                let partitions = partitions.into_iter().enumerate().map(|(priority, id)| {
1230                    let store = store.with_io_priority(priority as u64);
1231                    let frag_reuse_index_clone = frag_reuse_index.clone();
1232                    let index_cache_for_part =
1233                        index_cache.with_key_prefix(format!("part-{}", id).as_str());
1234                    let token_set_format = format;
1235                    async move {
1236                        Result::Ok(Arc::new(
1237                            InvertedPartition::load(
1238                                store,
1239                                id,
1240                                frag_reuse_index_clone,
1241                                &index_cache_for_part,
1242                                token_set_format,
1243                            )
1244                            .await?,
1245                        ))
1246                    }
1247                });
1248                let partitions = stream::iter(partitions)
1249                    .buffer_unordered(store.io_parallelism())
1250                    .try_collect::<Vec<_>>()
1251                    .await?;
1252
1253                let tokenizer = params.build()?;
1254                Ok(Arc::new(Self {
1255                    params,
1256                    store,
1257                    tokenizer,
1258                    token_set_format,
1259                    format_version,
1260                    partitions,
1261                    corpus_stats: Arc::new(OnceCell::new()),
1262                    deleted_fragments,
1263                }))
1264            }
1265            Err(_) => {
1266                // old index format
1267                Self::load_legacy_index(store, frag_reuse_index, index_cache).await
1268            }
1269        }
1270    }
1271}
1272
1273#[async_trait]
1274impl Index for InvertedIndex {
1275    fn as_any(&self) -> &dyn std::any::Any {
1276        self
1277    }
1278
1279    fn as_index(self: Arc<Self>) -> Arc<dyn Index> {
1280        self
1281    }
1282
1283    fn statistics(&self) -> Result<serde_json::Value> {
1284        let num_tokens = self
1285            .partitions
1286            .iter()
1287            .map(|part| part.tokens.len())
1288            .sum::<usize>();
1289        let num_docs = self
1290            .partitions
1291            .iter()
1292            .map(|part| part.docs.len())
1293            .sum::<usize>();
1294        Ok(serde_json::json!({
1295            "params": self.params,
1296            "num_tokens": num_tokens,
1297            "num_docs": num_docs,
1298        }))
1299    }
1300
1301    async fn prewarm(&self) -> Result<()> {
1302        self.prewarm_with_options(&FtsPrewarmOptions::default())
1303            .await
1304    }
1305
1306    fn index_type(&self) -> crate::IndexType {
1307        crate::IndexType::Inverted
1308    }
1309
1310    async fn calculate_included_frags(&self) -> Result<RoaringBitmap> {
1311        unimplemented!()
1312    }
1313}
1314
1315/// Target on-disk size of one prewarm chunk. Keep this large enough that cloud
1316/// stores do not spend prewarm time on thousands of tiny range reads, but still
1317/// bounded so one large partition is not materialized all at once.
1318const PREWARM_CHUNK_TARGET_BYTES: u64 = 128 << 20;
1319
1320/// Cap on token rows per chunk, bounding the built `Vec` when posting lists are tiny.
1321const PREWARM_MAX_CHUNK_TOKENS: usize = 256 * 1024;
1322
1323/// Floor on token rows per chunk, so a partition always makes progress.
1324const PREWARM_MIN_CHUNK_TOKENS: usize = 1;
1325
1326/// Maximum number of posting lists in a runtime synthetic cache group. This is
1327/// deliberately token-count based so grouping works for old v2 indexes without
1328/// scanning posting lengths or requiring index rebuilds.
1329static LANCE_FTS_POSTING_GROUP_MAX_TOKENS: LazyLock<usize> = LazyLock::new(|| {
1330    std::env::var("LANCE_FTS_POSTING_GROUP_MAX_TOKENS")
1331        .unwrap_or_else(|_| "128".to_string())
1332        .parse()
1333        .expect("failed to parse LANCE_FTS_POSTING_GROUP_MAX_TOKENS")
1334});
1335
1336fn runtime_posting_group_tokens() -> usize {
1337    (*LANCE_FTS_POSTING_GROUP_MAX_TOKENS).max(1)
1338}
1339
1340/// Runtime posting-list cache grouping. Non-empty v2 indexes synthesize fixed
1341/// groups at read time so prewarm and queries share group cache entries without
1342/// persisted grouping metadata or index rebuilds.
1343#[derive(Debug, Clone, DeepSizeOf)]
1344enum PostingGrouping {
1345    /// Leaves legacy or empty partitions ungrouped.
1346    None,
1347    /// Uses a fixed runtime cache group size measured in token rows, not posting bytes.
1348    SyntheticFixed { group_size: u32 },
1349}
1350
1351impl PostingGrouping {
1352    fn for_reader(is_legacy_layout: bool, token_count: usize) -> Self {
1353        if is_legacy_layout || token_count == 0 {
1354            return Self::None;
1355        }
1356
1357        let group_size = u32::try_from(runtime_posting_group_tokens())
1358            .unwrap_or(u32::MAX)
1359            .max(1);
1360        Self::SyntheticFixed { group_size }
1361    }
1362
1363    fn is_grouped(&self) -> bool {
1364        !matches!(self, Self::None)
1365    }
1366
1367    fn range_for_token(&self, token_id: u32, token_count: usize) -> Option<(u32, u32)> {
1368        match self {
1369            Self::None => None,
1370            Self::SyntheticFixed { group_size } => {
1371                let token_count = u32::try_from(token_count).unwrap_or(u32::MAX);
1372                let start = (token_id / *group_size) * *group_size;
1373                let end = start.saturating_add(*group_size).min(token_count);
1374                Some((start, end))
1375            }
1376        }
1377    }
1378
1379    fn aligned_chunk_end(&self, token_count: usize, tok_start: usize, desired_end: usize) -> usize {
1380        match self {
1381            Self::None => desired_end,
1382            Self::SyntheticFixed { group_size } => synthetic_group_aligned_chunk_end(
1383                usize::try_from(*group_size).unwrap_or(usize::MAX).max(1),
1384                token_count,
1385                tok_start,
1386                desired_end,
1387            ),
1388        }
1389    }
1390
1391    fn ranges_for_chunk(
1392        &self,
1393        tok_start: usize,
1394        tok_end: usize,
1395        token_count: usize,
1396    ) -> Vec<(u32, u32)> {
1397        match self {
1398            Self::None => Vec::new(),
1399            Self::SyntheticFixed { group_size } => synthetic_group_ranges_for_chunk(
1400                usize::try_from(*group_size).unwrap_or(usize::MAX).max(1),
1401                tok_start,
1402                tok_end,
1403                token_count,
1404            ),
1405        }
1406    }
1407}
1408
1409/// Token rows per chunk: byte target / average bytes-per-token, clamped to `[MIN, MAX]`.
1410fn prewarm_chunk_tokens(token_count: usize, file_size_bytes: u64) -> usize {
1411    if token_count == 0 {
1412        return PREWARM_MIN_CHUNK_TOKENS;
1413    }
1414    let bytes_per_token = (file_size_bytes / token_count as u64).max(1); // >= 1: no div-by-zero
1415    let by_bytes = (PREWARM_CHUNK_TARGET_BYTES / bytes_per_token) as usize;
1416    by_bytes.clamp(PREWARM_MIN_CHUNK_TOKENS, PREWARM_MAX_CHUNK_TOKENS)
1417}
1418
1419fn synthetic_group_aligned_chunk_end(
1420    group_size: usize,
1421    token_count: usize,
1422    tok_start: usize,
1423    desired_end: usize,
1424) -> usize {
1425    if desired_end >= token_count {
1426        return token_count;
1427    }
1428
1429    let boundary = desired_end - (desired_end % group_size);
1430    if boundary > tok_start {
1431        boundary
1432    } else {
1433        tok_start.saturating_add(group_size).min(token_count)
1434    }
1435}
1436
1437fn synthetic_group_ranges_for_chunk(
1438    group_size: usize,
1439    tok_start: usize,
1440    tok_end: usize,
1441    token_count: usize,
1442) -> Vec<(u32, u32)> {
1443    let mut ranges = Vec::new();
1444    let mut start = tok_start - (tok_start % group_size);
1445    if start < tok_start {
1446        start = start.saturating_add(group_size).min(token_count);
1447    }
1448    while start < tok_end {
1449        let end = start.saturating_add(group_size).min(token_count);
1450        ranges.push((
1451            u32::try_from(start).unwrap_or(u32::MAX),
1452            u32::try_from(end).unwrap_or(u32::MAX),
1453        ));
1454        start = end;
1455    }
1456    ranges
1457}
1458
1459fn prewarm_chunk_ranges(
1460    grouping: &PostingGrouping,
1461    token_count: usize,
1462    chunk_tokens: usize,
1463) -> Vec<(usize, usize)> {
1464    let mut ranges = Vec::new();
1465    let mut tok_start = 0usize;
1466    while tok_start < token_count {
1467        let mut tok_end = (tok_start + chunk_tokens).min(token_count);
1468        // `tok_start` is always a group boundary; snap `tok_end` back to one too.
1469        if grouping.is_grouped() {
1470            tok_end = grouping.aligned_chunk_end(token_count, tok_start, tok_end);
1471        }
1472        ranges.push((tok_start, tok_end));
1473        tok_start = tok_end;
1474    }
1475    ranges
1476}
1477
1478impl InvertedIndex {
1479    pub async fn prewarm_with_options(&self, options: &FtsPrewarmOptions) -> Result<()> {
1480        let with_position = options.with_position;
1481        let chunk_concurrency = self.store.io_parallelism().max(1);
1482        let prewarm_started = Instant::now();
1483        info!(
1484            partition_count = self.partitions.len(),
1485            with_position, chunk_concurrency, "fts index prewarm started"
1486        );
1487        for part in &self.partitions {
1488            let partition_started = Instant::now();
1489            info!(
1490                partition_id = part.id(),
1491                token_count = part.tokens.len(),
1492                with_position,
1493                chunk_concurrency,
1494                "fts partition prewarm started"
1495            );
1496            if let Err(err) = part
1497                .inverted_list
1498                .prewarm_posting_lists(with_position, chunk_concurrency)
1499                .await
1500            {
1501                warn!(
1502                    partition_id = part.id(),
1503                    error = %err,
1504                    elapsed_ms = partition_started.elapsed().as_millis() as u64,
1505                    "fts partition posting list prewarm failed"
1506                );
1507                return Err(err);
1508            }
1509            info!(
1510                partition_id = part.id(),
1511                elapsed_ms = partition_started.elapsed().as_millis() as u64,
1512                "fts partition posting lists prewarmed"
1513            );
1514            // Materialize the deferred DocSet too: prewarm's contract is
1515            // that subsequent queries do no IO, so the per-doc row_ids /
1516            // num_tokens must be resident, not lazily faulted in at query
1517            // time. `ensure_loaded` opens, reads, and drops the reader.
1518            let docs_started = Instant::now();
1519            if let Err(err) = part.docs.ensure_loaded().await {
1520                warn!(
1521                    partition_id = part.id(),
1522                    error = %err,
1523                    elapsed_ms = docs_started.elapsed().as_millis() as u64,
1524                    total_elapsed_ms = partition_started.elapsed().as_millis() as u64,
1525                    "fts partition docset prewarm failed"
1526                );
1527                return Err(err);
1528            }
1529            info!(
1530                partition_id = part.id(),
1531                docset_elapsed_ms = docs_started.elapsed().as_millis() as u64,
1532                elapsed_ms = partition_started.elapsed().as_millis() as u64,
1533                "fts partition prewarm finished"
1534            );
1535        }
1536        info!(
1537            partition_count = self.partitions.len(),
1538            elapsed_ms = prewarm_started.elapsed().as_millis() as u64,
1539            "fts index prewarm finished"
1540        );
1541        Ok(())
1542    }
1543    /// Search docs match the input text.
1544    async fn do_search(&self, text: &str) -> Result<RecordBatch> {
1545        let params = FtsSearchParams::new();
1546        let mut tokenizer = self.tokenizer.clone();
1547        let tokens = collect_query_tokens(text, &mut tokenizer);
1548
1549        let (doc_ids, _) = self
1550            .bm25_search(
1551                Arc::new(tokens),
1552                params.into(),
1553                Operator::And,
1554                Arc::new(NoFilter),
1555                Arc::new(NoOpMetricsCollector),
1556                None,
1557            )
1558            .boxed()
1559            .await?;
1560
1561        Ok(RecordBatch::try_new(
1562            ROW_ID_SCHEMA.clone(),
1563            vec![Arc::new(UInt64Array::from(doc_ids))],
1564        )?)
1565    }
1566}
1567
1568#[async_trait]
1569impl ScalarIndex for InvertedIndex {
1570    // return the row ids of the documents that contain the query
1571    #[instrument(level = "debug", skip_all)]
1572    async fn search(
1573        &self,
1574        query: &dyn AnyQuery,
1575        _metrics: &dyn MetricsCollector,
1576    ) -> Result<SearchResult> {
1577        let query = query.as_any().downcast_ref::<TokenQuery>().unwrap();
1578
1579        match query {
1580            TokenQuery::TokensContains(text) => {
1581                let records = self.do_search(text).await?;
1582                let row_ids = records
1583                    .column(0)
1584                    .as_any()
1585                    .downcast_ref::<UInt64Array>()
1586                    .unwrap();
1587                let row_ids = row_ids.iter().flatten().collect_vec();
1588                Ok(SearchResult::at_most(RowAddrTreeMap::from_iter(row_ids)))
1589            }
1590        }
1591    }
1592
1593    fn can_remap(&self) -> bool {
1594        true
1595    }
1596
1597    async fn remap(
1598        &self,
1599        mapping: &RowAddrRemap,
1600        dest_store: &dyn IndexStore,
1601    ) -> Result<CreatedIndex> {
1602        let files = self
1603            .to_builder()
1604            .remap(mapping, self.store.clone(), dest_store)
1605            .await?;
1606
1607        let details = pbold::InvertedIndexDetails::try_from(&self.params)?;
1608
1609        Ok(CreatedIndex {
1610            index_details: prost_types::Any::from_msg(&details).unwrap(),
1611            index_version: self.index_version(),
1612            files,
1613        })
1614    }
1615
1616    async fn update(
1617        &self,
1618        new_data: SendableRecordBatchStream,
1619        dest_store: &dyn IndexStore,
1620        old_data_filter: Option<crate::scalar::OldIndexDataFilter>,
1621    ) -> Result<CreatedIndex> {
1622        let files = self
1623            .to_builder()
1624            .update(new_data, dest_store, old_data_filter)
1625            .await?;
1626
1627        let details = pbold::InvertedIndexDetails::try_from(&self.params)?;
1628
1629        Ok(CreatedIndex {
1630            index_details: prost_types::Any::from_msg(&details).unwrap(),
1631            index_version: self.index_version(),
1632            files,
1633        })
1634    }
1635
1636    fn update_criteria(&self) -> UpdateCriteria {
1637        let criteria = TrainingCriteria::new(TrainingOrdering::None).with_row_id();
1638        if self.is_legacy() {
1639            UpdateCriteria::requires_old_data(criteria)
1640        } else {
1641            UpdateCriteria::only_new_data(criteria)
1642        }
1643    }
1644
1645    fn derive_index_params(&self) -> Result<ScalarIndexParams> {
1646        let mut params = self.params.clone();
1647        if params.base_tokenizer.is_empty() {
1648            // Empty tokenizer metadata only appears in legacy simple-tokenizer indexes.
1649            params.base_tokenizer = "simple".to_string();
1650        }
1651        params = params.format_version(self.format_version());
1652
1653        let params_json = params.to_training_json()?.to_string();
1654
1655        Ok(ScalarIndexParams {
1656            index_type: BuiltinIndexType::Inverted.as_str().to_string(),
1657            params: Some(params_json),
1658        })
1659    }
1660}
1661
1662#[derive(Debug, Clone, DeepSizeOf)]
1663pub struct InvertedPartition {
1664    // 0 for legacy format
1665    id: u64,
1666    store: Arc<dyn IndexStore>,
1667    pub(crate) tokens: TokenSet,
1668    pub(crate) inverted_list: Arc<PostingListReader>,
1669    /// Per-doc row_id + num_tokens. Wrapped in `LazyDocSet` so partitions
1670    /// that don't contribute hits to a query never pay the full-array
1671    /// download. Scoring paths call `ensure_loaded` before walking wand.
1672    pub(crate) docs: Arc<LazyDocSet>,
1673    token_set_format: TokenSetFormat,
1674}
1675
1676impl InvertedPartition {
1677    /// Check if this partition belongs to the specified fragment.
1678    ///
1679    /// This method encapsulates the bit manipulation logic for fragment filtering
1680    /// in distributed indexing scenarios.
1681    ///
1682    /// # Arguments
1683    /// * `fragment_mask` - A mask with fragment_id in high 32 bits
1684    ///
1685    /// # Returns
1686    /// * `true` if the partition belongs to the fragment, `false` otherwise
1687    pub fn belongs_to_fragment(&self, fragment_mask: u64) -> bool {
1688        (self.id() & fragment_mask) == fragment_mask
1689    }
1690
1691    pub fn id(&self) -> u64 {
1692        self.id
1693    }
1694
1695    pub fn store(&self) -> &dyn IndexStore {
1696        self.store.as_ref()
1697    }
1698
1699    pub fn is_legacy(&self) -> bool {
1700        self.inverted_list.is_legacy_layout()
1701    }
1702
1703    pub async fn load(
1704        store: Arc<dyn IndexStore>,
1705        id: u64,
1706        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
1707        index_cache: &LanceCache,
1708        token_set_format: TokenSetFormat,
1709    ) -> Result<Self> {
1710        let token_file = store.open_index_file(&token_file_path(id)).await?;
1711        let tokens = TokenSet::load(token_file, token_set_format).await?;
1712        let invert_list_file = store.open_index_file(&posting_file_path(id)).await?;
1713        let inverted_list = PostingListReader::try_new(invert_list_file, index_cache).await?;
1714        // Defer the per-doc row_id/num_tokens read. Construction reads only
1715        // the doc count (one footer read) and then drops the reader; the bulk
1716        // load happens on first scoring use, re-opening the docs file on
1717        // demand, and partitions that never score skip it entirely. Storing
1718        // the store + path instead of an open reader keeps a cached partition
1719        // from pinning a docs-file handle for its whole lifetime.
1720        let docs_path = doc_file_path(id);
1721        let num_docs = store.open_index_file(&docs_path).await?.num_rows();
1722        let docs = Arc::new(LazyDocSet::new(
1723            store.clone(),
1724            docs_path,
1725            num_docs,
1726            false,
1727            frag_reuse_index,
1728            // 256-document blocks score with quantized document lengths.
1729            inverted_list.block_size() == MAX_POSTING_BLOCK_SIZE,
1730        ));
1731
1732        Ok(Self {
1733            id,
1734            store,
1735            tokens,
1736            inverted_list: Arc::new(inverted_list),
1737            docs,
1738            token_set_format,
1739        })
1740    }
1741
1742    fn map(&self, token: &str) -> Option<u32> {
1743        self.tokens.get(token)
1744    }
1745
1746    pub fn expand_fuzzy(&self, tokens: &Tokens, params: &FtsSearchParams) -> Result<Tokens> {
1747        let mut new_tokens = Vec::with_capacity(min(tokens.len(), params.max_expansions));
1748        let mut new_positions = Vec::with_capacity(new_tokens.capacity());
1749        let mut seen = HashSet::new();
1750        for token_idx in 0..tokens.len() {
1751            let remaining = params.max_expansions.saturating_sub(new_tokens.len());
1752            if remaining == 0 {
1753                break;
1754            }
1755            let token = tokens.get_token(token_idx);
1756            let position = tokens.position(token_idx);
1757            let base_prefix_len = tokens.token_type().prefix_len(token) as u32;
1758            let mut candidates = BTreeSet::new();
1759            self.collect_fuzzy_candidates(
1760                token,
1761                base_prefix_len,
1762                params,
1763                remaining,
1764                &mut candidates,
1765            )?;
1766            for candidate in candidates {
1767                if new_tokens.len() >= params.max_expansions {
1768                    break;
1769                }
1770                if seen.insert((candidate.clone(), position)) {
1771                    new_tokens.push(candidate);
1772                    new_positions.push(position);
1773                }
1774            }
1775        }
1776        Ok(Tokens::with_positions(
1777            new_tokens,
1778            new_positions,
1779            tokens.token_type().clone(),
1780        ))
1781    }
1782
1783    /// Collect up to `limit` fuzzy candidates for one query token from this
1784    /// partition's token FST, in key (lexicographic) order. Callers merge
1785    /// candidates across partitions and apply the query-wide
1786    /// `max_expansions` budget; truncating each partition at `limit` is
1787    /// lossless for that selection because any term among the merged
1788    /// lexicographically-smallest `limit` is also among its own partition's
1789    /// smallest `limit`.
1790    fn collect_fuzzy_candidates(
1791        &self,
1792        token: &str,
1793        base_prefix_len: u32,
1794        params: &FtsSearchParams,
1795        limit: usize,
1796        candidates: &mut BTreeSet<String>,
1797    ) -> Result<()> {
1798        let fuzziness = match params.fuzziness {
1799            Some(fuzziness) => fuzziness,
1800            None => MatchQuery::auto_fuzziness(token),
1801        };
1802        let lev = fst::automaton::Levenshtein::new(token, fuzziness)
1803            .map_err(|e| Error::index(format!("failed to construct the fuzzy query: {}", e)))?;
1804
1805        if let TokenMap::Fst(ref map) = self.tokens.tokens {
1806            let mut expanded = Vec::new();
1807            match base_prefix_len + params.prefix_length {
1808                0 => take_fst_keys(map.search(lev), &mut expanded, limit),
1809                prefix_length => {
1810                    let prefix = &token[..min(prefix_length as usize, token.len())];
1811                    let prefix = fst::automaton::Str::new(prefix).starts_with();
1812                    take_fst_keys(map.search(lev.intersection(prefix)), &mut expanded, limit)
1813                }
1814            }
1815            candidates.extend(expanded);
1816            Ok(())
1817        } else {
1818            Err(Error::index(
1819                "tokens is not fst, which is not expected".to_owned(),
1820            ))
1821        }
1822    }
1823
1824    #[inline]
1825    fn grouped_score_upper_bound(
1826        query_weight: f32,
1827        union_freq: u32,
1828        doc_length: u32,
1829        scorer: &MemBM25Scorer,
1830    ) -> f32 {
1831        // BM25's document weight is monotonic in frequency and every IDF is
1832        // non-negative. Scoring the summed frequency with the summed IDF is
1833        // therefore an upper bound on the sum of the individual term scores.
1834        query_weight * scorer.doc_weight(union_freq, doc_length)
1835    }
1836
1837    fn grouped_block_max_scores(
1838        doc_ids: &[u32],
1839        frequencies: &[u32],
1840        block_size: usize,
1841        docs: &DocSet,
1842        query_weight: f32,
1843        scorer: &MemBM25Scorer,
1844    ) -> Vec<f32> {
1845        doc_ids
1846            .chunks(block_size)
1847            .zip(frequencies.chunks(block_size))
1848            .map(|(doc_ids, frequencies)| {
1849                doc_ids
1850                    .iter()
1851                    .zip(frequencies)
1852                    .map(|(doc_id, freq)| {
1853                        Self::grouped_score_upper_bound(
1854                            query_weight,
1855                            *freq,
1856                            docs.scoring_num_tokens(*doc_id),
1857                            scorer,
1858                        )
1859                    })
1860                    .fold(0.0, f32::max)
1861            })
1862            .collect()
1863    }
1864
1865    fn union_plain_posting_lists(
1866        postings: Vec<PostingList>,
1867        docs: &DocSet,
1868        query_weight: f32,
1869        scorer: &MemBM25Scorer,
1870    ) -> Result<PostingList> {
1871        let mut freqs_by_row_id = BTreeMap::new();
1872        for posting in postings {
1873            for (row_id, freq, _) in posting.iter() {
1874                let entry = freqs_by_row_id.entry(row_id).or_insert(0u32);
1875                *entry = entry.checked_add(freq).ok_or_else(|| {
1876                    Error::index(format!("posting frequency overflow for row id {}", row_id))
1877                })?;
1878            }
1879        }
1880        let mut row_ids = Vec::with_capacity(freqs_by_row_id.len());
1881        let mut frequencies = Vec::with_capacity(freqs_by_row_id.len());
1882        let mut max_score = 0.0_f32;
1883        for (row_id, freq) in freqs_by_row_id {
1884            max_score = max_score.max(Self::grouped_score_upper_bound(
1885                query_weight,
1886                freq,
1887                docs.num_tokens_by_row_id(row_id),
1888                scorer,
1889            ));
1890            row_ids.push(row_id);
1891            frequencies.push(freq as f32);
1892        }
1893        Ok(PostingList::Plain(PlainPostingList::new(
1894            ScalarBuffer::from(row_ids),
1895            ScalarBuffer::from(frequencies),
1896            Some(max_score),
1897            None,
1898        )))
1899    }
1900
1901    fn union_plain_posting_lists_with_positions(
1902        postings: Vec<PostingList>,
1903        docs: &DocSet,
1904        query_weight: f32,
1905        scorer: &MemBM25Scorer,
1906    ) -> Result<PostingList> {
1907        let mut positions_by_row_id = BTreeMap::<u64, Vec<u32>>::new();
1908        for posting in postings {
1909            for (row_id, _, positions) in posting.iter() {
1910                let positions = positions.ok_or_else(|| {
1911                    Error::index("cannot union grouped phrase terms without positions".to_string())
1912                })?;
1913                positions_by_row_id
1914                    .entry(row_id)
1915                    .or_default()
1916                    .extend(positions);
1917            }
1918        }
1919        if positions_by_row_id.is_empty() {
1920            return Ok(PostingList::Plain(PlainPostingList::new(
1921                ScalarBuffer::from(Vec::<u64>::new()),
1922                ScalarBuffer::from(Vec::<f32>::new()),
1923                None,
1924                None,
1925            )));
1926        }
1927
1928        let mut row_ids = Vec::with_capacity(positions_by_row_id.len());
1929        let mut frequencies = Vec::with_capacity(positions_by_row_id.len());
1930        let mut positions_builder = ListBuilder::new(Int32Builder::new());
1931        let mut max_score = 0.0_f32;
1932        for (row_id, mut positions) in positions_by_row_id {
1933            positions.sort_unstable();
1934            let frequency = positions.len() as u32;
1935            max_score = max_score.max(Self::grouped_score_upper_bound(
1936                query_weight,
1937                frequency,
1938                docs.num_tokens_by_row_id(row_id),
1939                scorer,
1940            ));
1941            row_ids.push(row_id);
1942            frequencies.push(frequency as f32);
1943            for position in positions {
1944                positions_builder.values().append_value(position as i32);
1945            }
1946            positions_builder.append(true);
1947        }
1948
1949        Ok(PostingList::Plain(PlainPostingList::new(
1950            ScalarBuffer::from(row_ids),
1951            ScalarBuffer::from(frequencies),
1952            Some(max_score),
1953            Some(positions_builder.finish()),
1954        )))
1955    }
1956
1957    fn union_compressed_posting_lists(
1958        postings: Vec<PostingList>,
1959        docs: &DocSet,
1960        query_weight: f32,
1961        scorer: &MemBM25Scorer,
1962    ) -> Result<PostingList> {
1963        let block_size = postings
1964            .iter()
1965            .find_map(|posting| match posting {
1966                PostingList::Compressed(posting) => Some(posting.block_size),
1967                PostingList::Plain(_) => None,
1968            })
1969            .unwrap_or(LEGACY_BLOCK_SIZE);
1970        let mut freqs_by_doc_id = BTreeMap::new();
1971        for posting in postings {
1972            for (doc_id, freq, _) in posting.iter() {
1973                let doc_id = u32::try_from(doc_id).map_err(|_| {
1974                    Error::index(format!(
1975                        "compressed posting doc id {} exceeds u32::MAX",
1976                        doc_id
1977                    ))
1978                })?;
1979                let entry = freqs_by_doc_id.entry(doc_id).or_insert(0u32);
1980                *entry = entry.checked_add(freq).ok_or_else(|| {
1981                    Error::index(format!("posting frequency overflow for doc id {}", doc_id))
1982                })?;
1983            }
1984        }
1985        if freqs_by_doc_id.is_empty() {
1986            return Ok(PostingList::Plain(PlainPostingList::new(
1987                ScalarBuffer::from(Vec::<u64>::new()),
1988                ScalarBuffer::from(Vec::<f32>::new()),
1989                None,
1990                None,
1991            )));
1992        }
1993
1994        let mut builder = PostingListBuilder::new_with_block_size(false, block_size);
1995        let mut doc_ids = Vec::with_capacity(freqs_by_doc_id.len());
1996        let mut frequencies = Vec::with_capacity(freqs_by_doc_id.len());
1997        for (doc_id, freq) in freqs_by_doc_id {
1998            builder.add(doc_id, PositionRecorder::Count(freq));
1999            doc_ids.push(doc_id);
2000            frequencies.push(freq);
2001        }
2002        let block_max_scores = Self::grouped_block_max_scores(
2003            &doc_ids,
2004            &frequencies,
2005            block_size,
2006            docs,
2007            query_weight,
2008            scorer,
2009        );
2010        let batch = builder.to_batch(block_max_scores)?;
2011        let max_score = batch[MAX_SCORE_COL].as_primitive::<Float32Type>().value(0);
2012        let length = batch[LENGTH_COL].as_primitive::<UInt32Type>().value(0);
2013        PostingList::from_batch(&batch, Some(max_score), Some(length))
2014    }
2015
2016    fn union_compressed_posting_lists_with_positions(
2017        postings: Vec<PostingList>,
2018        docs: &DocSet,
2019        query_weight: f32,
2020        scorer: &MemBM25Scorer,
2021    ) -> Result<PostingList> {
2022        let block_size = postings
2023            .iter()
2024            .find_map(|posting| match posting {
2025                PostingList::Compressed(posting) => Some(posting.block_size),
2026                PostingList::Plain(_) => None,
2027            })
2028            .unwrap_or(LEGACY_BLOCK_SIZE);
2029        let mut positions_by_doc_id = BTreeMap::<u32, Vec<u32>>::new();
2030        for posting in postings {
2031            for (doc_id, _, positions) in posting.iter() {
2032                let doc_id = u32::try_from(doc_id).map_err(|_| {
2033                    Error::index(format!(
2034                        "compressed posting doc id {} exceeds u32::MAX",
2035                        doc_id
2036                    ))
2037                })?;
2038                let positions = positions.ok_or_else(|| {
2039                    Error::index("cannot union grouped phrase terms without positions".to_string())
2040                })?;
2041                positions_by_doc_id
2042                    .entry(doc_id)
2043                    .or_default()
2044                    .extend(positions);
2045            }
2046        }
2047        if positions_by_doc_id.is_empty() {
2048            return Ok(PostingList::Plain(PlainPostingList::new(
2049                ScalarBuffer::from(Vec::<u64>::new()),
2050                ScalarBuffer::from(Vec::<f32>::new()),
2051                None,
2052                None,
2053            )));
2054        }
2055
2056        let mut builder = PostingListBuilder::new_with_block_size(true, block_size);
2057        let mut doc_ids = Vec::with_capacity(positions_by_doc_id.len());
2058        let mut frequencies = Vec::with_capacity(positions_by_doc_id.len());
2059        for (doc_id, mut positions) in positions_by_doc_id {
2060            positions.sort_unstable();
2061            let frequency = positions.len() as u32;
2062            builder.add(doc_id, PositionRecorder::Position(positions.into()));
2063            doc_ids.push(doc_id);
2064            frequencies.push(frequency);
2065        }
2066        let block_max_scores = Self::grouped_block_max_scores(
2067            &doc_ids,
2068            &frequencies,
2069            block_size,
2070            docs,
2071            query_weight,
2072            scorer,
2073        );
2074        let batch = builder.to_batch(block_max_scores)?;
2075        let max_score = batch[MAX_SCORE_COL].as_primitive::<Float32Type>().value(0);
2076        let length = batch[LENGTH_COL].as_primitive::<UInt32Type>().value(0);
2077        PostingList::from_batch(&batch, Some(max_score), Some(length))
2078    }
2079
2080    fn union_posting_lists(
2081        postings: Vec<PostingList>,
2082        docs: &DocSet,
2083        with_positions: bool,
2084        query_weight: f32,
2085        scorer: &MemBM25Scorer,
2086    ) -> Result<PostingList> {
2087        let has_plain = postings
2088            .iter()
2089            .any(|posting| matches!(posting, PostingList::Plain(_)));
2090        let has_compressed = postings
2091            .iter()
2092            .any(|posting| matches!(posting, PostingList::Compressed(_)));
2093        match (has_plain, has_compressed) {
2094            (true, true) => Err(Error::index(
2095                "cannot union mixed plain and compressed posting lists".to_owned(),
2096            )),
2097            (true, false) if with_positions => {
2098                Self::union_plain_posting_lists_with_positions(postings, docs, query_weight, scorer)
2099            }
2100            (true, false) => Self::union_plain_posting_lists(postings, docs, query_weight, scorer),
2101            (false, true) if with_positions => Self::union_compressed_posting_lists_with_positions(
2102                postings,
2103                docs,
2104                query_weight,
2105                scorer,
2106            ),
2107            (false, true) => {
2108                Self::union_compressed_posting_lists(postings, docs, query_weight, scorer)
2109            }
2110            (false, false) => Ok(PostingList::Plain(PlainPostingList::new(
2111                ScalarBuffer::from(Vec::<u64>::new()),
2112                ScalarBuffer::from(Vec::<f32>::new()),
2113                None,
2114                None,
2115            ))),
2116        }
2117    }
2118
2119    // search the documents that contain the query
2120    // return the doc info and the doc length
2121    // ref: https://en.wikipedia.org/wiki/Okapi_BM25
2122    #[instrument(level = "debug", skip_all)]
2123    async fn load_posting_lists(
2124        &self,
2125        tokens: &Tokens,
2126        params: &FtsSearchParams,
2127        operator: Operator,
2128        impact_scorer: &MemBM25Scorer,
2129        metrics: &dyn MetricsCollector,
2130    ) -> Result<LoadedPostings> {
2131        let is_phrase_query = params.phrase_slop.is_some();
2132        let is_and_query = operator == Operator::And;
2133        let required_positions = (is_and_query || is_phrase_query).then(|| {
2134            (0..tokens.len())
2135                .map(|index| tokens.position(index))
2136                .collect::<HashSet<_>>()
2137        });
2138        // Fuzzy expansion already ran once at the index level (see
2139        // `InvertedIndex::bm25_search`) under the global `max_expansions`
2140        // budget. Positions identify alternatives that must share one posting
2141        // iterator, including code identifier subwords and fuzzy expansions.
2142        let tokens = tokens.clone();
2143        let token_positions = (0..tokens.len())
2144            .map(|index| tokens.position(index))
2145            .collect::<Vec<_>>();
2146        let mut seen_positions = HashSet::with_capacity(token_positions.len());
2147        let exact_scoring_required = token_positions
2148            .iter()
2149            .any(|position| !seen_positions.insert(*position));
2150        let mut token_ids = Vec::with_capacity(tokens.len());
2151        let mut matched_positions = required_positions.as_ref().map(|_| HashSet::new());
2152        for (index, token) in tokens.into_iter().enumerate() {
2153            let token_id = self.map(&token);
2154            if let Some(token_id) = token_id {
2155                let position = token_positions[index];
2156                if let Some(matched_positions) = matched_positions.as_mut() {
2157                    matched_positions.insert(position);
2158                }
2159                token_ids.push((token_id, token, position));
2160            }
2161        }
2162        if token_ids.is_empty() {
2163            return Ok(LoadedPostings::empty());
2164        }
2165        if let Some(required_positions) = required_positions.as_ref()
2166            && let Some(matched_positions) = matched_positions.as_ref()
2167            && !required_positions.is_subset(matched_positions)
2168        {
2169            return Ok(LoadedPostings::empty());
2170        }
2171
2172        token_ids.sort_unstable_by_key(|(token_id, _, position)| (*position, *token_id));
2173        token_ids.dedup_by(|lhs, rhs| lhs.0 == rhs.0 && lhs.2 == rhs.2);
2174
2175        let num_docs = self.docs.len();
2176        let loaded_postings = stream::iter(token_ids)
2177            .map(|(token_id, token, position)| async move {
2178                let posting = self
2179                    .inverted_list
2180                    .posting_list(token_id, is_phrase_query, metrics)
2181                    .await?;
2182
2183                Result::Ok((token_id, token, position, posting))
2184            })
2185            .buffered(self.store.io_parallelism())
2186            .try_collect::<Vec<_>>()
2187            .await?;
2188
2189        let needs_union = loaded_postings
2190            .windows(2)
2191            .any(|window| window[0].2 == window[1].2);
2192        if (is_and_query || is_phrase_query)
2193            && !needs_union
2194            && loaded_postings
2195                .iter()
2196                .any(|(_, _, _, posting)| posting.is_empty())
2197        {
2198            return Ok(LoadedPostings::empty());
2199        }
2200
2201        if !needs_union {
2202            let impact_safe = loaded_postings
2203                .iter()
2204                .all(|(_, _, _, posting)| posting.has_impacts());
2205            return Ok(LoadedPostings {
2206                postings: loaded_postings
2207                    .into_iter()
2208                    .map(|(token_id, token, position, posting)| {
2209                        let needs_scorer_upper_bound =
2210                            exact_scoring_required && !posting.has_impacts();
2211                        let query_weight = if impact_safe || exact_scoring_required {
2212                            impact_scorer.query_weight(&token)
2213                        } else {
2214                            idf(posting.len(), num_docs)
2215                        };
2216                        let posting = PostingIterator::with_query_weight(
2217                            token,
2218                            token_id,
2219                            position,
2220                            query_weight,
2221                            posting,
2222                            num_docs,
2223                        );
2224                        if needs_scorer_upper_bound {
2225                            posting.with_scorer_upper_bound()
2226                        } else {
2227                            posting
2228                        }
2229                    })
2230                    .collect(),
2231                grouped_expansions: Vec::new(),
2232                impact_safe,
2233                exact_scoring_required,
2234            });
2235        }
2236
2237        let docs_for_union = if needs_union {
2238            Some(self.docs.ensure_num_tokens_loaded().await?)
2239        } else {
2240            None
2241        };
2242
2243        // WAND's AND mode treats every iterator as required, so expansions from
2244        // one original query position must be merged before scoring.
2245        let mut grouped_postings = Vec::new();
2246        let mut grouped_expansions = Vec::new();
2247        let mut iter = loaded_postings.into_iter().peekable();
2248        while let Some((token_id, token, position, posting)) = iter.next() {
2249            let mut group = vec![(token_id, token, posting)];
2250            while matches!(iter.peek(), Some((_, _, next_position, _)) if *next_position == position)
2251            {
2252                let (token_id, token, _, posting) = iter.next().expect("peeked item must exist");
2253                group.push((token_id, token, posting));
2254            }
2255
2256            let (token_id, token, posting) = if group.len() == 1 {
2257                group.pop().expect("single-item group must exist")
2258            } else {
2259                let token_id = group[0].0;
2260                let token = group[0].1.clone();
2261                let terms = group
2262                    .iter()
2263                    .map(|(_, token, posting)| {
2264                        GroupedTermScorer::new(impact_scorer.query_weight(token), posting)
2265                    })
2266                    .collect::<Vec<_>>();
2267                let terms = Arc::<[GroupedTermScorer]>::from(terms);
2268                let query_weight = terms.iter().map(GroupedTermScorer::query_weight).sum();
2269                grouped_expansions.push(GroupedExpansionTerms {
2270                    position,
2271                    terms: terms.clone(),
2272                });
2273                let postings = group
2274                    .into_iter()
2275                    .map(|(_, _, posting)| posting)
2276                    .collect::<Vec<_>>();
2277                let docs = docs_for_union.as_deref().ok_or_else(|| {
2278                    Error::index("union docs were not loaded for grouped query terms".to_string())
2279                })?;
2280                let posting = Self::union_posting_lists(
2281                    postings,
2282                    docs,
2283                    is_phrase_query,
2284                    query_weight,
2285                    impact_scorer,
2286                )?;
2287                if posting.is_empty() && (is_and_query || is_phrase_query) {
2288                    return Ok(LoadedPostings::empty());
2289                }
2290                grouped_postings.push(
2291                    PostingIterator::with_query_weight(
2292                        token,
2293                        token_id,
2294                        position,
2295                        query_weight,
2296                        posting,
2297                        num_docs,
2298                    )
2299                    .with_grouped_terms(terms),
2300                );
2301                continue;
2302            };
2303            if posting.is_empty() {
2304                if is_and_query || is_phrase_query {
2305                    return Ok(LoadedPostings::empty());
2306                }
2307                continue;
2308            }
2309
2310            let query_weight = impact_scorer.query_weight(&token);
2311            let needs_scorer_upper_bound = !posting.has_impacts();
2312            let posting = PostingIterator::with_query_weight(
2313                token,
2314                token_id,
2315                position,
2316                query_weight,
2317                posting,
2318                num_docs,
2319            );
2320            grouped_postings.push(if needs_scorer_upper_bound {
2321                posting.with_scorer_upper_bound()
2322            } else {
2323                posting
2324            });
2325        }
2326
2327        Ok(LoadedPostings {
2328            postings: grouped_postings,
2329            grouped_expansions,
2330            impact_safe: false,
2331            exact_scoring_required: true,
2332        })
2333    }
2334
2335    #[instrument(level = "debug", skip_all)]
2336    // Deferred-DocSet adds the `docs` param (caller materializes it) on top of
2337    // the cross-partition `shared_threshold`, tipping this hot-path search fn
2338    // one over the limit. Bundling args isn't worth the churn here.
2339    #[allow(clippy::too_many_arguments)]
2340    pub fn bm25_search(
2341        &self,
2342        docs: &DocSet,
2343        params: &FtsSearchParams,
2344        operator: Operator,
2345        mask: Arc<RowAddrMask>,
2346        postings: Vec<PostingIterator>,
2347        impact_scorer: Option<Arc<MemBM25Scorer>>,
2348        metrics: &dyn MetricsCollector,
2349        shared_threshold: Arc<AtomicU32>,
2350    ) -> Result<Vec<DocCandidate>> {
2351        if postings.is_empty() {
2352            return Ok(Vec::new());
2353        }
2354
2355        // Caller selects the DocSet shape via `LazyDocSet::docs_for_wand`
2356        // and passes it in here; wand uses `docs.has_row_ids()` to
2357        // handle the num_tokens-only case.
2358        let hits = if let Some(scorer) = impact_scorer {
2359            let mut wand = Wand::new(operator, postings.into_iter(), docs, scorer)
2360                .with_shared_threshold(shared_threshold);
2361            wand.search(params, mask, metrics)?
2362        } else {
2363            let scorer = IndexBM25Scorer::new(std::iter::once(self));
2364            let mut wand = Wand::new(operator, postings.into_iter(), docs, scorer)
2365                .with_shared_threshold(shared_threshold);
2366            wand.search(params, mask, metrics)?
2367        };
2368        Ok(hits)
2369    }
2370
2371    pub async fn into_builder(self) -> Result<InnerBuilder> {
2372        let mut builder = InnerBuilder::new_with_posting_tail_codec_and_block_size(
2373            self.id,
2374            self.inverted_list.has_positions(),
2375            self.token_set_format,
2376            self.inverted_list.posting_tail_codec(),
2377            self.inverted_list.block_size(),
2378        );
2379        builder.tokens = self.tokens.into_mutable();
2380        // into_builder rewrites every doc, so materialize the full
2381        // DocSet now and clone it out of the Arc.
2382        let docs_arc = self.docs.ensure_loaded().await?;
2383        builder.docs = (*docs_arc).clone();
2384
2385        builder
2386            .posting_lists
2387            .reserve_exact(self.inverted_list.len());
2388        for posting_list in self
2389            .inverted_list
2390            .read_all(self.inverted_list.has_positions())
2391            .await?
2392        {
2393            let posting_list = posting_list?;
2394            builder
2395                .posting_lists
2396                .push(posting_list.into_builder(&builder.docs));
2397        }
2398        Ok(builder)
2399    }
2400}
2401
2402// at indexing, we use HashMap because we need it to be mutable,
2403// at searching, we use fst::Map because it's more efficient
2404#[derive(Debug, Clone)]
2405pub enum TokenMap {
2406    HashMap(HashMap<String, u32>),
2407    Fst(fst::Map<Vec<u8>>),
2408}
2409
2410impl Default for TokenMap {
2411    fn default() -> Self {
2412        Self::HashMap(HashMap::new())
2413    }
2414}
2415
2416impl DeepSizeOf for TokenMap {
2417    fn deep_size_of_children(&self, ctx: &mut lance_core::deepsize::Context) -> usize {
2418        match self {
2419            Self::HashMap(map) => map.deep_size_of_children(ctx),
2420            Self::Fst(map) => map.as_fst().size(),
2421        }
2422    }
2423}
2424
2425impl TokenMap {
2426    pub fn len(&self) -> usize {
2427        match self {
2428            Self::HashMap(map) => map.len(),
2429            Self::Fst(map) => map.len(),
2430        }
2431    }
2432
2433    pub fn is_empty(&self) -> bool {
2434        self.len() == 0
2435    }
2436}
2437
2438// TokenSet is a mapping from tokens to token ids
2439#[derive(Debug, Clone, Default, DeepSizeOf)]
2440pub struct TokenSet {
2441    // token -> token_id
2442    pub(crate) tokens: TokenMap,
2443    pub(crate) next_id: u32,
2444    total_length: usize,
2445}
2446
2447impl TokenSet {
2448    pub fn into_mut(self) -> Self {
2449        let tokens = match self.tokens {
2450            TokenMap::HashMap(map) => map,
2451            TokenMap::Fst(map) => {
2452                let mut new_map = HashMap::with_capacity(map.len());
2453                let mut stream = map.into_stream();
2454                while let Some((token, token_id)) = stream.next() {
2455                    new_map.insert(String::from_utf8_lossy(token).into_owned(), token_id as u32);
2456                }
2457
2458                new_map
2459            }
2460        };
2461
2462        Self {
2463            tokens: TokenMap::HashMap(tokens),
2464            next_id: self.next_id,
2465            total_length: self.total_length,
2466        }
2467    }
2468
2469    pub fn len(&self) -> usize {
2470        self.tokens.len()
2471    }
2472
2473    pub fn is_empty(&self) -> bool {
2474        self.len() == 0
2475    }
2476
2477    pub fn to_batch(self, format: TokenSetFormat) -> Result<RecordBatch> {
2478        match format {
2479            TokenSetFormat::Arrow => self.into_arrow_batch(),
2480            TokenSetFormat::Fst => self.into_fst_batch(),
2481        }
2482    }
2483
2484    fn into_arrow_batch(self) -> Result<RecordBatch> {
2485        let mut token_builder = StringBuilder::with_capacity(self.tokens.len(), self.total_length);
2486        let mut token_id_builder = UInt32Builder::with_capacity(self.tokens.len());
2487
2488        match self.tokens {
2489            TokenMap::Fst(map) => {
2490                let mut stream = map.stream();
2491                while let Some((token, token_id)) = stream.next() {
2492                    token_builder.append_value(String::from_utf8_lossy(token));
2493                    token_id_builder.append_value(token_id as u32);
2494                }
2495            }
2496            TokenMap::HashMap(map) => {
2497                for (token, token_id) in map.into_iter().sorted_unstable() {
2498                    token_builder.append_value(token);
2499                    token_id_builder.append_value(token_id);
2500                }
2501            }
2502        }
2503
2504        let token_col = token_builder.finish();
2505        let token_id_col = token_id_builder.finish();
2506
2507        let schema = arrow_schema::Schema::new(vec![
2508            arrow_schema::Field::new(TOKEN_COL, DataType::Utf8, false),
2509            arrow_schema::Field::new(TOKEN_ID_COL, DataType::UInt32, false),
2510        ]);
2511
2512        let batch = RecordBatch::try_new(
2513            Arc::new(schema),
2514            vec![
2515                Arc::new(token_col) as ArrayRef,
2516                Arc::new(token_id_col) as ArrayRef,
2517            ],
2518        )?;
2519        Ok(batch)
2520    }
2521
2522    fn into_fst_batch(mut self) -> Result<RecordBatch> {
2523        let fst_map = match std::mem::take(&mut self.tokens) {
2524            TokenMap::Fst(map) => map,
2525            TokenMap::HashMap(map) => Self::build_fst_from_map(map)?,
2526        };
2527        let bytes = fst_map.into_fst().into_inner();
2528
2529        let mut fst_builder = LargeBinaryBuilder::with_capacity(1, bytes.len());
2530        fst_builder.append_value(bytes);
2531        let fst_col = fst_builder.finish();
2532
2533        let mut next_id_builder = UInt32Builder::with_capacity(1);
2534        next_id_builder.append_value(self.next_id);
2535        let next_id_col = next_id_builder.finish();
2536
2537        let mut total_length_builder = UInt64Builder::with_capacity(1);
2538        total_length_builder.append_value(self.total_length as u64);
2539        let total_length_col = total_length_builder.finish();
2540
2541        let schema = arrow_schema::Schema::new(vec![
2542            arrow_schema::Field::new(TOKEN_FST_BYTES_COL, DataType::LargeBinary, false),
2543            arrow_schema::Field::new(TOKEN_NEXT_ID_COL, DataType::UInt32, false),
2544            arrow_schema::Field::new(TOKEN_TOTAL_LENGTH_COL, DataType::UInt64, false),
2545        ]);
2546
2547        let batch = RecordBatch::try_new(
2548            Arc::new(schema),
2549            vec![
2550                Arc::new(fst_col) as ArrayRef,
2551                Arc::new(next_id_col) as ArrayRef,
2552                Arc::new(total_length_col) as ArrayRef,
2553            ],
2554        )?;
2555        Ok(batch)
2556    }
2557
2558    fn build_fst_from_map(map: HashMap<String, u32>) -> Result<fst::Map<Vec<u8>>> {
2559        let mut entries: Vec<_> = map.into_iter().collect();
2560        entries.sort_unstable_by(|(lhs, _), (rhs, _)| lhs.cmp(rhs));
2561        let mut builder = fst::MapBuilder::memory();
2562        for (token, token_id) in entries {
2563            builder
2564                .insert(&token, token_id as u64)
2565                .map_err(|e| Error::index(format!("failed to insert token {}: {}", token, e)))?;
2566        }
2567        Ok(builder.into_map())
2568    }
2569
2570    pub async fn load(reader: Arc<dyn IndexReader>, format: TokenSetFormat) -> Result<Self> {
2571        match format {
2572            TokenSetFormat::Arrow => Self::load_arrow(reader).await,
2573            TokenSetFormat::Fst => Self::load_fst(reader).await,
2574        }
2575    }
2576
2577    async fn load_arrow(reader: Arc<dyn IndexReader>) -> Result<Self> {
2578        let batch = reader.read_range(0..reader.num_rows(), None).await?;
2579
2580        let (tokens, next_id, total_length) = spawn_blocking(move || {
2581            let mut next_id = 0;
2582            let mut total_length = 0;
2583            let mut tokens = fst::MapBuilder::memory();
2584
2585            let token_col = batch[TOKEN_COL].as_string::<i32>();
2586            let token_id_col = batch[TOKEN_ID_COL].as_primitive::<datatypes::UInt32Type>();
2587
2588            for (token, &token_id) in token_col.iter().zip(token_id_col.values().iter()) {
2589                let token =
2590                    token.ok_or(Error::index("found null token in token set".to_owned()))?;
2591                next_id = next_id.max(token_id + 1);
2592                total_length += token.len();
2593                tokens.insert(token, token_id as u64).map_err(|e| {
2594                    Error::index(format!("failed to insert token {}: {}", token, e))
2595                })?;
2596            }
2597
2598            Ok::<_, Error>((tokens.into_map(), next_id, total_length))
2599        })
2600        .await
2601        .map_err(|err| Error::execution(format!("failed to spawn blocking task: {}", err)))??;
2602
2603        Ok(Self {
2604            tokens: TokenMap::Fst(tokens),
2605            next_id,
2606            total_length,
2607        })
2608    }
2609
2610    async fn load_fst(reader: Arc<dyn IndexReader>) -> Result<Self> {
2611        let batch = reader.read_range(0..reader.num_rows(), None).await?;
2612        if batch.num_rows() == 0 {
2613            return Err(Error::index("token set batch is empty".to_owned()));
2614        }
2615
2616        let fst_col = batch[TOKEN_FST_BYTES_COL].as_binary::<i64>();
2617        let bytes = fst_col.value(0);
2618        let map = fst::Map::new(bytes.to_vec())
2619            .map_err(|e| Error::index(format!("failed to load fst tokens: {}", e)))?;
2620
2621        let total_length_col =
2622            batch[TOKEN_TOTAL_LENGTH_COL].as_primitive::<datatypes::UInt64Type>();
2623
2624        // Token ids are dense `[0, len)`, so `next_id` must equal the token count. Recompute
2625        // it instead of trusting the persisted value, which writers before #7115 could leave
2626        // stale. Mirrors `load_arrow`.
2627        let next_id = map.len() as u32;
2628
2629        let total_length = total_length_col
2630            .values()
2631            .first()
2632            .copied()
2633            .ok_or(Error::index(
2634                "token total length column is empty".to_owned(),
2635            ))?;
2636
2637        Ok(Self {
2638            tokens: TokenMap::Fst(map),
2639            next_id,
2640            total_length: usize::try_from(total_length).map_err(|_| {
2641                Error::index(format!(
2642                    "token total length {} overflows usize",
2643                    total_length
2644                ))
2645            })?,
2646        })
2647    }
2648
2649    pub fn add(&mut self, token: String) -> u32 {
2650        let next_id = self.next_id();
2651        let len = token.len();
2652        let token_id = match self.tokens {
2653            TokenMap::HashMap(ref mut map) => *map.entry(token).or_insert(next_id),
2654            _ => unreachable!("tokens must be HashMap while indexing"),
2655        };
2656
2657        // add token if it doesn't exist
2658        if token_id == next_id {
2659            self.next_id += 1;
2660            self.total_length += len;
2661        }
2662
2663        token_id
2664    }
2665
2666    pub(crate) fn get_or_add(&mut self, token: &str) -> u32 {
2667        let next_id = self.next_id;
2668        match self.tokens {
2669            TokenMap::HashMap(ref mut map) => {
2670                if let Some(&token_id) = map.get(token) {
2671                    return token_id;
2672                }
2673
2674                map.insert(token.to_owned(), next_id);
2675            }
2676            _ => unreachable!("tokens must be HashMap while indexing"),
2677        }
2678
2679        self.next_id += 1;
2680        self.total_length += token.len();
2681        next_id
2682    }
2683
2684    pub(crate) fn into_mutable(self) -> Self {
2685        let Self {
2686            tokens,
2687            next_id,
2688            total_length,
2689        } = self;
2690        match tokens {
2691            TokenMap::HashMap(_) => Self {
2692                tokens,
2693                next_id,
2694                total_length,
2695            },
2696            TokenMap::Fst(map) => {
2697                let mut mutable = HashMap::new();
2698                let mut stream = map.stream();
2699                while let Some((token, token_id)) = stream.next() {
2700                    mutable.insert(String::from_utf8_lossy(token).into_owned(), token_id as u32);
2701                }
2702                Self {
2703                    tokens: TokenMap::HashMap(mutable),
2704                    next_id,
2705                    total_length,
2706                }
2707            }
2708        }
2709    }
2710
2711    pub fn get(&self, token: &str) -> Option<u32> {
2712        match self.tokens {
2713            TokenMap::HashMap(ref map) => map.get(token).copied(),
2714            TokenMap::Fst(ref map) => map.get(token).map(|id| id as u32),
2715        }
2716    }
2717
2718    // the `removed_token_ids` must be sorted
2719    pub fn remap(&mut self, removed_token_ids: &[u32]) {
2720        if removed_token_ids.is_empty() {
2721            return;
2722        }
2723
2724        let mut map = match std::mem::take(&mut self.tokens) {
2725            TokenMap::HashMap(map) => map,
2726            TokenMap::Fst(map) => {
2727                let mut new_map = HashMap::with_capacity(map.len());
2728                let mut stream = map.into_stream();
2729                while let Some((token, token_id)) = stream.next() {
2730                    new_map.insert(String::from_utf8_lossy(token).into_owned(), token_id as u32);
2731                }
2732
2733                new_map
2734            }
2735        };
2736
2737        let mut retained_length = 0;
2738        map.retain(
2739            |token, token_id| match removed_token_ids.binary_search(token_id) {
2740                Ok(_) => false,
2741                Err(index) => {
2742                    *token_id -= index as u32;
2743                    retained_length += token.len();
2744                    true
2745                }
2746            },
2747        );
2748
2749        self.tokens = TokenMap::HashMap(map);
2750
2751        // The retain above compacts the surviving token ids into a dense `[0, len)`
2752        // range, so `next_id` (handed to the next new token) must follow them down.
2753        // `total_length` likewise must drop the removed tokens' bytes; it is persisted
2754        // and feeds memory accounting, so a stale value drifts across remap/merge cycles.
2755        self.next_id = self.tokens.len() as u32;
2756        self.total_length = retained_length;
2757    }
2758
2759    pub fn next_id(&self) -> u32 {
2760        self.next_id
2761    }
2762
2763    pub(crate) fn memory_size(&self) -> usize {
2764        match &self.tokens {
2765            TokenMap::HashMap(map) => {
2766                self.total_length
2767                    + map.capacity()
2768                        * (std::mem::size_of::<String>()
2769                            + std::mem::size_of::<u32>()
2770                            + std::mem::size_of::<usize>())
2771            }
2772            TokenMap::Fst(map) => map.as_fst().size(),
2773        }
2774    }
2775}
2776
2777pub struct PostingListReader {
2778    reader: Arc<dyn IndexReader>,
2779
2780    /// Layout-specific metadata. V2 keeps its per-token max-score and
2781    /// length columns lazy so opening a partition doesn't drag O(num_tokens)
2782    /// bytes off cold storage when the caller only needs `df` for a few terms.
2783    metadata: PostingMetadata,
2784
2785    has_position: bool,
2786    has_impacts: bool,
2787    posting_tail_codec: PostingTailCodec,
2788    block_size: usize,
2789    positions_layout: PositionsLayout,
2790
2791    /// Runtime posting-list cache grouping. Non-empty v2 indexes use synthetic
2792    /// fixed groups so prewarm can improve cache density without rebuilding the
2793    /// index or relying on persisted grouping metadata.
2794    grouping: PostingGrouping,
2795
2796    index_cache: WeakLanceCache,
2797}
2798
2799/// Per-token metadata (max_score, length) needed by the BM25 query and stats
2800/// paths. The legacy and v2 formats store this metadata in different
2801/// places, with very different cost profiles for cold-load: the variants
2802/// surface that asymmetry so callers can choose a per-token or bulk access
2803/// pattern.
2804enum PostingMetadata {
2805    /// Legacy v1: offsets and max_scores are encoded in the file's schema
2806    /// metadata, so they are already in memory by the time `try_new` returns.
2807    LegacyV1 {
2808        offsets: Vec<usize>,
2809        max_scores: Option<Vec<f32>>,
2810    },
2811    /// V2: per-token `max_score` and `length` live as columns in the
2812    /// posting file. The bulk vectors are filled lazily by
2813    /// `ensure_metadata_loaded`, and the stats path can also fetch a single
2814    /// token via `posting_len_for_token` without forcing the bulk load.
2815    V2 {
2816        metadata: OnceCell<LoadedPostingMetadata>,
2817    },
2818}
2819
2820#[derive(Debug, Clone)]
2821struct LoadedPostingMetadata {
2822    max_scores: Vec<f32>,
2823    lengths: Vec<u32>,
2824}
2825
2826#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2827enum PositionsLayout {
2828    None,
2829    LegacyPerDoc,
2830    SharedStream(PositionStreamCodec),
2831}
2832
2833impl std::fmt::Debug for PostingListReader {
2834    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2835        let mut s = f.debug_struct("InvertedListReader");
2836        match &self.metadata {
2837            PostingMetadata::LegacyV1 {
2838                offsets,
2839                max_scores,
2840            } => {
2841                s.field("layout", &"legacy_v1")
2842                    .field("offsets", offsets)
2843                    .field("max_scores", max_scores);
2844            }
2845            PostingMetadata::V2 { metadata } => {
2846                s.field("layout", &"v2")
2847                    .field("metadata_loaded", &metadata.initialized());
2848            }
2849        }
2850        s.finish()
2851    }
2852}
2853
2854impl DeepSizeOf for PostingListReader {
2855    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
2856        let metadata_size = match &self.metadata {
2857            PostingMetadata::LegacyV1 {
2858                offsets,
2859                max_scores,
2860            } => offsets.deep_size_of_children(context) + max_scores.deep_size_of_children(context),
2861            PostingMetadata::V2 { metadata } => metadata
2862                .get()
2863                .map(|loaded| {
2864                    loaded.max_scores.deep_size_of_children(context)
2865                        + loaded.lengths.deep_size_of_children(context)
2866                })
2867                .unwrap_or(0),
2868        };
2869        metadata_size + self.grouping.deep_size_of_children(context)
2870    }
2871}
2872
2873impl PostingListReader {
2874    pub(crate) async fn try_new(
2875        reader: Arc<dyn IndexReader>,
2876        index_cache: &LanceCache,
2877    ) -> Result<Self> {
2878        let positions_layout = if reader.schema().field(COMPRESSED_POSITION_COL).is_some() {
2879            PositionsLayout::SharedStream(parse_shared_position_codec(&reader.schema().metadata)?)
2880        } else if reader.schema().field(POSITION_COL).is_some() {
2881            PositionsLayout::LegacyPerDoc
2882        } else {
2883            PositionsLayout::None
2884        };
2885        let posting_tail_codec = parse_posting_tail_codec(&reader.schema().metadata)?;
2886        let block_size = parse_posting_block_size(&reader.schema().metadata)?;
2887        let has_position = positions_layout != PositionsLayout::None;
2888        let has_impacts = reader.schema().field(IMPACT_COL).is_some();
2889        let metadata = if reader.schema().field(POSTING_COL).is_none() {
2890            let (offsets, max_scores) = Self::load_metadata(reader.schema())?;
2891            PostingMetadata::LegacyV1 {
2892                offsets,
2893                max_scores,
2894            }
2895        } else {
2896            PostingMetadata::V2 {
2897                metadata: OnceCell::new(),
2898            }
2899        };
2900
2901        let is_legacy_layout = matches!(&metadata, PostingMetadata::LegacyV1 { .. });
2902        let grouping = PostingGrouping::for_reader(is_legacy_layout, reader.num_rows());
2903
2904        Ok(Self {
2905            reader,
2906            metadata,
2907            has_position,
2908            has_impacts,
2909            posting_tail_codec,
2910            block_size,
2911            positions_layout,
2912            grouping,
2913            index_cache: WeakLanceCache::from(index_cache),
2914        })
2915    }
2916
2917    // for legacy format
2918    // returns the offsets and max scores
2919    fn load_metadata(
2920        schema: &lance_core::datatypes::Schema,
2921    ) -> Result<(Vec<usize>, Option<Vec<f32>>)> {
2922        let offsets = schema
2923            .metadata
2924            .get("offsets")
2925            .ok_or(Error::index("offsets not found in metadata".to_owned()))?;
2926        let offsets = serde_json::from_str(offsets)?;
2927
2928        let max_scores = schema
2929            .metadata
2930            .get("max_scores")
2931            .map(|max_scores| serde_json::from_str(max_scores))
2932            .transpose()?;
2933        Ok((offsets, max_scores))
2934    }
2935
2936    // the number of posting lists
2937    pub fn len(&self) -> usize {
2938        match &self.metadata {
2939            PostingMetadata::LegacyV1 { offsets, .. } => offsets.len(),
2940            PostingMetadata::V2 { .. } => self.reader.num_rows(),
2941        }
2942    }
2943
2944    pub fn is_empty(&self) -> bool {
2945        self.len() == 0
2946    }
2947
2948    pub(crate) fn has_positions(&self) -> bool {
2949        self.has_position
2950    }
2951
2952    pub(crate) fn posting_tail_codec(&self) -> PostingTailCodec {
2953        self.posting_tail_codec
2954    }
2955
2956    pub(crate) fn block_size(&self) -> usize {
2957        self.block_size
2958    }
2959
2960    fn is_legacy_layout(&self) -> bool {
2961        matches!(self.metadata, PostingMetadata::LegacyV1 { .. })
2962    }
2963
2964    /// Sync access to `posting_len`. Requires v2 metadata to already be
2965    /// loaded via [`ensure_metadata_loaded`]; the bm25 scoring path enforces
2966    /// that contract before kicking off wand. The stats path uses
2967    /// [`Self::posting_len_for_token`] instead, which avoids the bulk load.
2968    pub(crate) fn posting_len(&self, token_id: u32) -> usize {
2969        let token_id = token_id as usize;
2970        match &self.metadata {
2971            PostingMetadata::LegacyV1 { offsets, .. } => {
2972                let next_offset = offsets
2973                    .get(token_id + 1)
2974                    .copied()
2975                    .unwrap_or(self.reader.num_rows());
2976                next_offset - offsets[token_id]
2977            }
2978            PostingMetadata::V2 { metadata } => {
2979                let metadata = metadata
2980                    .get()
2981                    .expect("v2 posting metadata must be bulk-loaded before sync posting_len; call ensure_metadata_loaded first");
2982                metadata.lengths[token_id] as usize
2983            }
2984        }
2985    }
2986
2987    /// Async access to a single token's posting list length. For v2
2988    /// indexes this reads one row of posting metadata if the bulk metadata has
2989    /// not been loaded yet, and never triggers the bulk load itself. The stats
2990    /// path uses this so a single-term `df` lookup costs O(1) bytes rather
2991    /// than O(num_unique_tokens).
2992    pub(crate) async fn posting_len_for_token(&self, token_id: u32) -> Result<usize> {
2993        match &self.metadata {
2994            PostingMetadata::LegacyV1 { .. } => Ok(self.posting_len(token_id)),
2995            PostingMetadata::V2 { metadata } => {
2996                if let Some(metadata) = metadata.get() {
2997                    return Ok(metadata.lengths[token_id as usize] as usize);
2998                }
2999                let (_, length) = self.posting_metadata_for_token(token_id).await?;
3000                length
3001                    .map(|len| len as usize)
3002                    .ok_or_else(|| Error::index("posting length metadata missing".to_string()))
3003            }
3004        }
3005    }
3006
3007    /// Async access to a single token's `(max_score, length)` pair. Mirrors
3008    /// [`Self::posting_len_for_token`] but covers both columns the scoring
3009    /// path needs, in one read. For v2 indexes that have not been
3010    /// bulk-loaded this issues one `read_range(token..token+1, [MAX_SCORE,
3011    /// LENGTH])`; for legacy v1 the values come from in-memory schema
3012    /// metadata.
3013    pub(crate) async fn posting_metadata_for_token(
3014        &self,
3015        token_id: u32,
3016    ) -> Result<(Option<f32>, Option<u32>)> {
3017        match &self.metadata {
3018            PostingMetadata::LegacyV1 { max_scores, .. } => {
3019                Ok((max_scores.as_ref().map(|m| m[token_id as usize]), None))
3020            }
3021            PostingMetadata::V2 { metadata } => {
3022                if let Some(loaded) = metadata.get() {
3023                    return Ok((
3024                        Some(loaded.max_scores[token_id as usize]),
3025                        Some(loaded.lengths[token_id as usize]),
3026                    ));
3027                }
3028                let metadata = self
3029                    .index_cache
3030                    .get_or_insert_with_key(PostingMetadataKey { token_id }, || async move {
3031                        let token_id = token_id as usize;
3032                        let batch = self
3033                            .reader
3034                            .read_range(token_id..token_id + 1, Some(&[MAX_SCORE_COL, LENGTH_COL]))
3035                            .await?;
3036                        let max_score = batch[MAX_SCORE_COL].as_primitive::<Float32Type>().value(0);
3037                        let length = batch[LENGTH_COL].as_primitive::<UInt32Type>().value(0);
3038                        Ok(PostingMetadataValue { max_score, length })
3039                    })
3040                    .await?;
3041                Ok((Some(metadata.max_score), Some(metadata.length)))
3042            }
3043        }
3044    }
3045
3046    /// Force the v2 bulk metadata (`max_scores`, `lengths`) into
3047    /// memory. Cheap to call repeatedly; no-op for legacy v1 indexes whose
3048    /// metadata is already populated from schema metadata at `try_new` time.
3049    pub(crate) async fn ensure_metadata_loaded(&self) -> Result<()> {
3050        let PostingMetadata::V2 { metadata } = &self.metadata else {
3051            return Ok(());
3052        };
3053        metadata
3054            .get_or_try_init(|| async {
3055                let batch = self
3056                    .reader
3057                    .read_range(
3058                        0..self.reader.num_rows(),
3059                        Some(&[MAX_SCORE_COL, LENGTH_COL]),
3060                    )
3061                    .await?;
3062                let max_scores = batch[MAX_SCORE_COL]
3063                    .as_primitive::<Float32Type>()
3064                    .values()
3065                    .to_vec();
3066                let lengths = batch[LENGTH_COL]
3067                    .as_primitive::<UInt32Type>()
3068                    .values()
3069                    .to_vec();
3070                Ok::<LoadedPostingMetadata, Error>(LoadedPostingMetadata {
3071                    max_scores,
3072                    lengths,
3073                })
3074            })
3075            .await?;
3076        Ok(())
3077    }
3078
3079    pub(crate) async fn posting_batch(
3080        &self,
3081        token_id: u32,
3082        with_position: bool,
3083    ) -> Result<RecordBatch> {
3084        if self.is_legacy_layout() {
3085            self.posting_batch_legacy(token_id, with_position).await
3086        } else {
3087            let token_id = token_id as usize;
3088            let mut columns = if with_position {
3089                match self.positions_layout {
3090                    PositionsLayout::SharedStream(_) => {
3091                        vec![
3092                            POSTING_COL,
3093                            COMPRESSED_POSITION_COL,
3094                            POSITION_BLOCK_OFFSET_COL,
3095                        ]
3096                    }
3097                    PositionsLayout::LegacyPerDoc => vec![POSTING_COL, POSITION_COL],
3098                    PositionsLayout::None => vec![POSTING_COL],
3099                }
3100            } else {
3101                vec![POSTING_COL]
3102            };
3103            if self.has_impacts {
3104                columns.push(IMPACT_COL);
3105            }
3106            let batch = self
3107                .reader
3108                .read_range(token_id..token_id + 1, Some(&columns))
3109                .await?;
3110            Ok(batch)
3111        }
3112    }
3113
3114    async fn posting_batch_legacy(
3115        &self,
3116        token_id: u32,
3117        with_position: bool,
3118    ) -> Result<RecordBatch> {
3119        let mut columns = vec![ROW_ID, FREQUENCY_COL];
3120        if with_position {
3121            columns.push(POSITION_COL);
3122        }
3123
3124        let length = self.posting_len(token_id);
3125        let PostingMetadata::LegacyV1 { offsets, .. } = &self.metadata else {
3126            unreachable!("posting_batch_legacy is only reachable on legacy v1 layout");
3127        };
3128        let token_id = token_id as usize;
3129        let offset = offsets[token_id];
3130        let batch = self
3131            .reader
3132            .read_range(offset..offset + length, Some(&columns))
3133            .await?;
3134        Ok(batch)
3135    }
3136
3137    #[instrument(level = "debug", skip(self, metrics))]
3138    pub(crate) async fn posting_list(
3139        &self,
3140        token_id: u32,
3141        is_phrase_query: bool,
3142        metrics: &dyn MetricsCollector,
3143    ) -> Result<PostingList> {
3144        let mut posting = match self.group_range_for_token(token_id) {
3145            // Grouped path (issue #7040): one cache entry covers rows
3146            // [start, end), so neighbouring rare terms share a single read.
3147            Some((start, end)) => {
3148                let group = self
3149                    .index_cache
3150                    .get_or_insert_with_key(
3151                        posting_list_group_cache_key(start, end, self.has_impacts),
3152                        || async move {
3153                            metrics.record_part_load();
3154                            info!(target: TRACE_IO_EVENTS, r#type=IO_TYPE_LOAD_SCALAR_PART, index_type="inverted", part_id=start);
3155                            self.load_posting_list_group(start, end).await
3156                        },
3157                    )
3158                    .await?;
3159                let (max_score, length) = if group.needs_external_metadata() {
3160                    self.posting_metadata_for_token(token_id).await?
3161                } else {
3162                    (None, None)
3163                };
3164                let slot = (token_id - start) as usize;
3165                group
3166                    .posting_list(slot, max_score, length)?
3167                    .ok_or_else(|| {
3168                        Error::index(format!(
3169                            "token {token_id} maps to slot {slot} outside posting group [{start}, {end})"
3170                        ))
3171                    })?
3172            }
3173            // Fallback for layouts that cannot use row-based groups: one cache
3174            // entry per token.
3175            None => self
3176                .index_cache
3177                .get_or_insert_with_key(
3178                    posting_list_cache_key(token_id, self.has_impacts),
3179                    || async move {
3180                        metrics.record_part_load();
3181                        info!(target: TRACE_IO_EVENTS, r#type=IO_TYPE_LOAD_SCALAR_PART, index_type="inverted", part_id=token_id);
3182                        // Fetch the posting batch and this token's (max_score,
3183                        // length) in parallel; for cold v2 partitions this is one
3184                        // single-row metadata read plus one posting-row read,
3185                        // instead of pulling the full per-token metadata table.
3186                        let (batch, (max_score, length)) = futures::try_join!(
3187                            self.posting_batch(token_id, false),
3188                            self.posting_metadata_for_token(token_id),
3189                        )?;
3190                        self.posting_list_from_batch(&batch, max_score, length)
3191                    },
3192                )
3193                .await?
3194                .as_ref()
3195                .clone(),
3196        };
3197
3198        if is_phrase_query && !posting.has_position() {
3199            // hit the cache and when the cache was populated, the positions column was not loaded
3200            let positions = self.read_positions(token_id).await?;
3201            posting.set_positions(positions);
3202        }
3203
3204        Ok(posting)
3205    }
3206
3207    /// Map a token id to its cache group's row range `[start, end)`, or `None`
3208    /// when grouping is not available so the caller falls back to the per-token
3209    /// path. In v2 the token id is the row offset, so the group range is also
3210    /// the physical row range.
3211    fn group_range_for_token(&self, token_id: u32) -> Option<(u32, u32)> {
3212        self.grouping.range_for_token(token_id, self.len())
3213    }
3214
3215    /// Read rows `[start, end)` into one compact Arrow-backed cache value.
3216    /// Positions are excluded; phrase queries load them on demand via
3217    /// [`Self::read_positions`].
3218    async fn load_posting_list_group(&self, start: u32, end: u32) -> Result<PostingListGroup> {
3219        let mut columns = vec![POSTING_COL, MAX_SCORE_COL, LENGTH_COL];
3220        if self.has_impacts {
3221            columns.push(IMPACT_COL);
3222        }
3223        let batch = self
3224            .reader
3225            .read_range(start as usize..end as usize, Some(&columns))
3226            .await?;
3227        PostingListGroup::new_packed_with_block_size(
3228            batch.shrink_to_fit()?,
3229            self.posting_tail_codec,
3230            self.block_size,
3231        )
3232    }
3233
3234    fn posting_list_from_batch_parts(
3235        batch: &RecordBatch,
3236        max_score: Option<f32>,
3237        length: Option<u32>,
3238        posting_tail_codec: PostingTailCodec,
3239        block_size: usize,
3240        positions_layout: PositionsLayout,
3241    ) -> Result<PostingList> {
3242        let posting_list = PostingList::from_batch_with_tail_codec_and_positions_layout(
3243            batch,
3244            max_score,
3245            length,
3246            posting_tail_codec,
3247            block_size,
3248            positions_layout,
3249        )?;
3250        Ok(posting_list)
3251    }
3252
3253    pub(crate) fn posting_list_from_batch(
3254        &self,
3255        batch: &RecordBatch,
3256        max_score: Option<f32>,
3257        length: Option<u32>,
3258    ) -> Result<PostingList> {
3259        Self::posting_list_from_batch_parts(
3260            batch,
3261            max_score,
3262            length,
3263            self.posting_tail_codec,
3264            self.block_size,
3265            self.positions_layout,
3266        )
3267    }
3268
3269    /// Build posting lists for one chunk's token range from `chunk_batch`, rebasing
3270    /// global offsets to chunk-local rows. Returns `(global token_id, PostingList)`
3271    /// pairs identical to the whole-file path, only bounded to one chunk.
3272    fn build_prewarm_posting_lists_chunk(
3273        chunk_batch: RecordBatch,
3274        chunk: PrewarmChunk<'_>,
3275        ctx: &PrewarmBuildCtx<'_>,
3276    ) -> Result<Vec<(u32, PostingList)>> {
3277        let mut posting_lists = Vec::with_capacity(chunk.token_count);
3278        for local in 0..chunk.token_count {
3279            let global = chunk.tok_start + local;
3280            let row_batch = if let Some(chunk_offsets) = chunk.offsets {
3281                // Legacy v1: rebase global offsets to chunk row 0; the last token
3282                // ends at `chunk.end_row` (no trailing sentinel in chunk_offsets).
3283                let base = chunk_offsets[0];
3284                let start = chunk_offsets[local] - base;
3285                let end = if local + 1 < chunk_offsets.len() {
3286                    chunk_offsets[local + 1] - base
3287                } else {
3288                    chunk.end_row - base
3289                };
3290                chunk_batch.slice(start, end - start)
3291            } else {
3292                // V2: one posting row per token; row `local` within the chunk.
3293                chunk_batch.slice(local, 1)
3294            };
3295            let row_batch = row_batch.shrink_to_fit()?;
3296            let posting_list = Self::posting_list_from_batch_parts(
3297                &row_batch,
3298                ctx.max_scores.map(|scores| scores[global]),
3299                ctx.lengths.map(|lengths| lengths[global]),
3300                ctx.posting_tail_codec,
3301                ctx.block_size,
3302                ctx.positions_layout,
3303            )?;
3304            posting_lists.push((global as u32, posting_list));
3305        }
3306
3307        Ok(posting_lists)
3308    }
3309
3310    /// Read the posting rows for token ids `[tok_start, tok_end)` into one RecordBatch.
3311    /// For v2 the token range is the row range; for v1 it's derived from the offsets.
3312    async fn read_chunk_batch(
3313        &self,
3314        tok_start: usize,
3315        tok_end: usize,
3316        with_position: bool,
3317    ) -> Result<RecordBatch> {
3318        let columns = self.posting_columns(with_position);
3319        let row_range = match &self.metadata {
3320            PostingMetadata::LegacyV1 { offsets, .. } => {
3321                let start = offsets[tok_start];
3322                let end = offsets
3323                    .get(tok_end)
3324                    .copied()
3325                    .unwrap_or_else(|| self.reader.num_rows());
3326                start..end
3327            }
3328            PostingMetadata::V2 { .. } => tok_start..tok_end,
3329        };
3330        let batch = self.reader.read_range(row_range, Some(&columns)).await?;
3331        Ok(batch)
3332    }
3333
3334    async fn prewarm_posting_lists(
3335        &self,
3336        with_position: bool,
3337        chunk_concurrency: usize,
3338    ) -> Result<()> {
3339        self.prewarm_posting_lists_chunked(with_position, None, chunk_concurrency)
3340            .await?;
3341        Ok(())
3342    }
3343
3344    /// Stream the partition's posting lists into the cache in bounded token-row chunks
3345    /// (read -> build -> insert -> drop), so peak resident set is ~one chunk. Returns
3346    /// the chunk count (tests assert it split). `chunk_tokens_override` is test-only.
3347    async fn prewarm_posting_lists_chunked(
3348        &self,
3349        with_position: bool,
3350        chunk_tokens_override: Option<usize>,
3351        chunk_concurrency: usize,
3352    ) -> Result<usize> {
3353        if with_position && !self.has_positions() {
3354            return Err(Error::invalid_input(
3355                "cannot prewarm positions for an inverted index that was built without positions; recreate the index with with_position=true".to_owned(),
3356            ));
3357        }
3358
3359        // Make max_scores/lengths available for query-local packed views. The
3360        // materialized fallback also clones them into its blocking build task.
3361        self.ensure_metadata_loaded().await?;
3362
3363        // With grouping the cache stores one entry per group, so a group's
3364        // posting lists must all be resident at once: align chunk boundaries to
3365        // whole groups. Without grouping, chunks are plain token ranges.
3366        let grouping = self.grouping.clone();
3367        let use_packed_groups = grouping.is_grouped() && !with_position;
3368        // Packed groups reuse the reader's bulk metadata at query time, so they
3369        // do not need the temporary full-partition metadata clones used by the
3370        // materialized fallback.
3371        let state = (!use_packed_groups).then(|| self.chunk_build_state());
3372        let token_count = self.len();
3373        let posting_data_size_bytes = self.posting_data_size_bytes();
3374        let chunk_tokens = chunk_tokens_override
3375            .unwrap_or_else(|| prewarm_chunk_tokens(token_count, posting_data_size_bytes))
3376            .max(1);
3377        let chunk_ranges = prewarm_chunk_ranges(&grouping, token_count, chunk_tokens);
3378        let chunk_count = chunk_ranges.len();
3379        let chunk_concurrency = chunk_concurrency.max(1);
3380
3381        let read_build_start = Instant::now();
3382        stream::iter(chunk_ranges)
3383            .map(|(tok_start, tok_end)| {
3384                let state = state.as_ref();
3385                let grouping = &grouping;
3386                async move {
3387                    if use_packed_groups {
3388                        let groups = self
3389                            .build_packed_chunk_groups(tok_start, tok_end, token_count, grouping)
3390                            .await?;
3391                        for (start, end, group) in groups {
3392                            self.index_cache
3393                                .insert_with_key(
3394                                    &posting_list_group_cache_key(start, end, self.has_impacts),
3395                                    Arc::new(group),
3396                                )
3397                                .await;
3398                        }
3399                    } else {
3400                        let state = state.expect(
3401                            "materialized prewarm must initialize posting-list build state",
3402                        );
3403                        let posting_lists = self
3404                            .build_chunk_postings(tok_start, tok_end, with_position, state)
3405                            .await?;
3406                        self.publish_chunk_postings(
3407                            posting_lists,
3408                            grouping,
3409                            tok_start,
3410                            tok_end,
3411                            token_count,
3412                            with_position,
3413                        )
3414                        .await;
3415                    }
3416                    Result::Ok(())
3417                }
3418            })
3419            .buffer_unordered(chunk_concurrency)
3420            .try_collect::<()>()
3421            .await?;
3422        let read_build_elapsed = read_build_start.elapsed();
3423
3424        info!(
3425            legacy_layout = self.is_legacy_layout(),
3426            with_position,
3427            token_count,
3428            chunk_count,
3429            chunk_tokens,
3430            chunk_concurrency,
3431            posting_data_size_bytes,
3432            read_build_ms = read_build_elapsed.as_secs_f64() * 1000.0,
3433            "posting list prewarm timing"
3434        );
3435
3436        Ok(chunk_count)
3437    }
3438
3439    /// Loop-invariant inputs shared by every chunk build: the metadata vecs
3440    /// (`Arc`d so chunks share them without re-cloning) plus codec/layout.
3441    fn chunk_build_state(&self) -> ChunkBuildState {
3442        let (offsets, max_scores, lengths) = match &self.metadata {
3443            PostingMetadata::LegacyV1 {
3444                offsets,
3445                max_scores,
3446            } => (Some(offsets.clone()), max_scores.clone(), None),
3447            PostingMetadata::V2 { metadata } => (
3448                None,
3449                metadata.get().map(|loaded| loaded.max_scores.clone()),
3450                metadata.get().map(|loaded| loaded.lengths.clone()),
3451            ),
3452        };
3453        ChunkBuildState {
3454            offsets: offsets.map(Arc::new),
3455            max_scores: max_scores.map(Arc::new),
3456            lengths: lengths.map(Arc::new),
3457            posting_tail_codec: self.posting_tail_codec,
3458            block_size: self.block_size,
3459            positions_layout: self.positions_layout,
3460        }
3461    }
3462
3463    /// Read one token-row chunk and build its posting lists off the runtime thread.
3464    /// The large batch is dropped inside the blocking task once built, bounding
3465    /// resident memory to one chunk.
3466    async fn build_chunk_postings(
3467        &self,
3468        tok_start: usize,
3469        tok_end: usize,
3470        with_position: bool,
3471        state: &ChunkBuildState,
3472    ) -> Result<Vec<(u32, PostingList)>> {
3473        let chunk_token_count = tok_end - tok_start;
3474        let chunk_batch = self
3475            .read_chunk_batch(tok_start, tok_end, with_position)
3476            .await?;
3477
3478        let (chunk_offsets, chunk_end_row) = match state.offsets.as_ref() {
3479            Some(offsets) => {
3480                let end_row = offsets
3481                    .get(tok_end)
3482                    .copied()
3483                    .unwrap_or_else(|| self.reader.num_rows());
3484                (Some(offsets[tok_start..tok_end].to_vec()), end_row)
3485            }
3486            // V2 doesn't use chunk_end_row (one row per token); pass tok_end.
3487            None => (None, tok_end),
3488        };
3489        let max_scores = state.max_scores.clone();
3490        let lengths = state.lengths.clone();
3491        let posting_tail_codec = state.posting_tail_codec;
3492        let block_size = state.block_size;
3493        let positions_layout = state.positions_layout;
3494        let posting_lists = spawn_blocking(move || {
3495            let ctx = PrewarmBuildCtx {
3496                max_scores: max_scores.as_deref().map(|v| v.as_slice()),
3497                lengths: lengths.as_deref().map(|v| v.as_slice()),
3498                posting_tail_codec,
3499                block_size,
3500                positions_layout,
3501            };
3502            let chunk = PrewarmChunk {
3503                tok_start,
3504                token_count: chunk_token_count,
3505                offsets: chunk_offsets.as_deref(),
3506                end_row: chunk_end_row,
3507            };
3508            Self::build_prewarm_posting_lists_chunk(chunk_batch, chunk, &ctx)
3509        })
3510        .await
3511        .map_err(|err| {
3512            Error::internal(format!(
3513                "Failed to build prewarm posting lists in blocking task: {err}"
3514            ))
3515        })??;
3516        // The chunk yields its token range as contiguous ascending ids from
3517        // `tok_start`; the group publish path relies on this to index the lists.
3518        debug_assert_eq!(posting_lists.len(), chunk_token_count);
3519        debug_assert!(
3520            posting_lists
3521                .iter()
3522                .enumerate()
3523                .all(|(i, (token_id, _))| *token_id as usize == tok_start + i)
3524        );
3525        Ok(posting_lists)
3526    }
3527
3528    /// Build compact v2 groups directly from one posting-row chunk. Each group
3529    /// slice is deep-copied once, so it owns only its Arrow buffers without
3530    /// materializing a `Vec<PostingList>` or retaining the full chunk.
3531    async fn build_packed_chunk_groups(
3532        &self,
3533        tok_start: usize,
3534        tok_end: usize,
3535        token_count: usize,
3536        grouping: &PostingGrouping,
3537    ) -> Result<Vec<(u32, u32, PostingListGroup)>> {
3538        debug_assert!(grouping.is_grouped());
3539        debug_assert!(!self.is_legacy_layout());
3540
3541        let chunk_batch = self.read_chunk_batch(tok_start, tok_end, false).await?;
3542        let ranges = grouping.ranges_for_chunk(tok_start, tok_end, token_count);
3543        let posting_tail_codec = self.posting_tail_codec;
3544        let block_size = self.block_size;
3545
3546        spawn_blocking(move || {
3547            let mut groups = Vec::with_capacity(ranges.len());
3548            for (start, end) in ranges {
3549                let start_usize = start as usize;
3550                let end_usize = end as usize;
3551                let local_start = start_usize - tok_start;
3552                let group_len = end_usize - start_usize;
3553                let group_batch = chunk_batch.slice(local_start, group_len).shrink_to_fit()?;
3554                groups.push((
3555                    start,
3556                    end,
3557                    PostingListGroup::new_packed_with_block_size(
3558                        group_batch,
3559                        posting_tail_codec,
3560                        block_size,
3561                    )?,
3562                ));
3563            }
3564            Result::Ok(groups)
3565        })
3566        .await
3567        .map_err(|err| {
3568            Error::internal(format!(
3569                "Failed to build packed prewarm posting groups in blocking task: {err}"
3570            ))
3571        })?
3572    }
3573
3574    /// Strip positions into their own per-token cache entries (the posting cache
3575    /// holds positions-free lists), then populate the same cache keys the read
3576    /// path uses: grouped entries when grouping is active, per-token entries
3577    /// otherwise. Called once per chunk; the chunk's lists drop on return.
3578    async fn publish_chunk_postings(
3579        &self,
3580        posting_lists: Vec<(u32, PostingList)>,
3581        grouping: &PostingGrouping,
3582        tok_start: usize,
3583        tok_end: usize,
3584        token_count: usize,
3585        with_position: bool,
3586    ) {
3587        match grouping {
3588            PostingGrouping::None => {
3589                for (token_id, mut posting_list) in posting_lists {
3590                    self.cache_positions(&mut posting_list, token_id, with_position)
3591                        .await;
3592                    self.index_cache
3593                        .insert_with_key(
3594                            &posting_list_cache_key(token_id, self.has_impacts),
3595                            Arc::new(posting_list),
3596                        )
3597                        .await;
3598                }
3599            }
3600            PostingGrouping::SyntheticFixed { .. } => {
3601                let mut chunk_postings = Vec::with_capacity(posting_lists.len());
3602                for (token_id, mut posting_list) in posting_lists {
3603                    self.cache_positions(&mut posting_list, token_id, with_position)
3604                        .await;
3605                    chunk_postings.push(posting_list);
3606                }
3607                // Chunk is group-aligned, so every group starting in it also ends
3608                // in it; `chunk_postings[i]` is token `tok_start + i`. The last
3609                // group's `end` derives from `token_count`, matching the read path
3610                // so both produce identical `PostingListGroupKey`s.
3611                for (start, end) in grouping.ranges_for_chunk(tok_start, tok_end, token_count) {
3612                    let start_usize = start as usize;
3613                    let lo = start_usize - tok_start;
3614                    let hi = end as usize - tok_start;
3615                    let group = PostingListGroup::new(chunk_postings[lo..hi].to_vec());
3616                    self.index_cache
3617                        .insert_with_key(
3618                            &posting_list_group_cache_key(start, end, self.has_impacts),
3619                            Arc::new(group),
3620                        )
3621                        .await;
3622                }
3623            }
3624        }
3625    }
3626
3627    /// Move a posting list's positions (when present and requested) into the
3628    /// dedicated per-token position cache, leaving the posting list positions-free.
3629    async fn cache_positions(
3630        &self,
3631        posting_list: &mut PostingList,
3632        token_id: u32,
3633        with_position: bool,
3634    ) {
3635        if with_position && let Some(positions) = posting_list.take_positions() {
3636            self.index_cache
3637                .insert_with_key(&PositionKey { token_id }, Arc::new(Positions(positions)))
3638                .await;
3639        }
3640    }
3641
3642    /// Cheap `invert.lance` size estimate (file length from object metadata, no
3643    /// data read), used only to size prewarm chunks. Falls back to a row-count
3644    /// proxy when the reader can't surface the length (legacy v1).
3645    pub(crate) fn posting_data_size_bytes(&self) -> u64 {
3646        if let Some(size) = self.reader.file_size_bytes() {
3647            return size;
3648        }
3649        // Fallback proxy for readers that don't cache their file length: just needs
3650        // to be monotonic in partition size.
3651        const ESTIMATED_BYTES_PER_ROW: u64 = 16;
3652        (self.reader.num_rows() as u64).saturating_mul(ESTIMATED_BYTES_PER_ROW)
3653    }
3654
3655    pub(crate) async fn read_batch(&self, with_position: bool) -> Result<RecordBatch> {
3656        let columns = self.posting_columns(with_position);
3657        let batch = self
3658            .reader
3659            .read_range(0..self.reader.num_rows(), Some(&columns))
3660            .await?;
3661        Ok(batch)
3662    }
3663
3664    pub(crate) async fn read_all(
3665        &self,
3666        with_position: bool,
3667    ) -> Result<impl Iterator<Item = Result<PostingList>> + '_> {
3668        // read_all walks every posting list; the bulk metadata is paid for
3669        // unconditionally, so just load it once up front and index into it
3670        // synchronously below.
3671        self.ensure_metadata_loaded().await?;
3672        let batch = self.read_batch(with_position).await?;
3673        Ok((0..self.len()).map(move |i| {
3674            let token_id = i as u32;
3675            let range = self.posting_list_range(token_id);
3676            let batch = batch.slice(i, range.end - range.start);
3677            let (max_score, length) = self.bulk_metadata_for_token(token_id);
3678            self.posting_list_from_batch(&batch, max_score, length)
3679        }))
3680    }
3681
3682    /// Sync lookup of `(max_score, length)` from the bulk-loaded metadata.
3683    /// Only safe after [`Self::ensure_metadata_loaded`]; callers that hold
3684    /// the OnceCell-loaded reference (e.g. read_all, prewarm) use this to
3685    /// avoid the per-token IO path.
3686    fn bulk_metadata_for_token(&self, token_id: u32) -> (Option<f32>, Option<u32>) {
3687        match &self.metadata {
3688            PostingMetadata::LegacyV1 { max_scores, .. } => {
3689                (max_scores.as_ref().map(|m| m[token_id as usize]), None)
3690            }
3691            PostingMetadata::V2 { metadata } => {
3692                let loaded = metadata.get().expect(
3693                    "v2 metadata must be bulk-loaded before bulk_metadata_for_token; call ensure_metadata_loaded first",
3694                );
3695                (
3696                    Some(loaded.max_scores[token_id as usize]),
3697                    Some(loaded.lengths[token_id as usize]),
3698                )
3699            }
3700        }
3701    }
3702
3703    async fn read_positions(&self, token_id: u32) -> Result<CompressedPositionStorage> {
3704        let positions = self.index_cache.get_or_insert_with_key(PositionKey { token_id }, || async move {
3705            let positions = match self.positions_layout {
3706                PositionsLayout::None => {
3707                    return Err(Error::invalid_input(
3708                        "position is not found but required for phrase queries, try recreating the index with position".to_owned(),
3709                    ));
3710                }
3711                PositionsLayout::LegacyPerDoc => {
3712                    let batch = self
3713                        .reader
3714                        .read_range(self.posting_list_range(token_id), Some(&[POSITION_COL]))
3715                        .await
3716                        .map_err(|e| match e {
3717                            Error::Schema { .. } => Error::invalid_input("position is not found but required for phrase queries, try recreating the index with position".to_owned()),
3718                            e => e,
3719                        })?;
3720                    CompressedPositionStorage::LegacyPerDoc(
3721                        batch[POSITION_COL].as_list::<i32>().value(0).as_list::<i32>().clone(),
3722                    )
3723                }
3724                PositionsLayout::SharedStream(codec) => {
3725                    let batch = self
3726                        .reader
3727                        .read_range(
3728                            self.posting_list_range(token_id),
3729                            Some(&[COMPRESSED_POSITION_COL, POSITION_BLOCK_OFFSET_COL]),
3730                        )
3731                        .await
3732                        .map_err(|e| match e {
3733                            Error::Schema { .. } => Error::invalid_input("position is not found but required for phrase queries, try recreating the index with position".to_owned()),
3734                            e => e,
3735                        })?;
3736                    let bytes = bytes::Bytes::from(
3737                        batch[COMPRESSED_POSITION_COL]
3738                            .as_binary::<i64>()
3739                            .value(0)
3740                            .to_vec(),
3741                    );
3742                    let block_offsets = batch[POSITION_BLOCK_OFFSET_COL]
3743                        .as_list::<i32>()
3744                        .value(0)
3745                        .as_primitive::<UInt32Type>()
3746                        .values()
3747                        .to_vec();
3748                    CompressedPositionStorage::SharedStream(SharedPositionStream::new(
3749                        codec,
3750                        block_offsets,
3751                        bytes,
3752                    ))
3753                }
3754            };
3755            Result::Ok(Positions(positions))
3756        }).await?;
3757        Ok(positions.0.clone())
3758    }
3759
3760    fn posting_list_range(&self, token_id: u32) -> Range<usize> {
3761        match &self.metadata {
3762            PostingMetadata::LegacyV1 { offsets, .. } => {
3763                let offset = offsets[token_id as usize];
3764                let posting_len = self.posting_len(token_id);
3765                offset..offset + posting_len
3766            }
3767            PostingMetadata::V2 { .. } => {
3768                let token_id = token_id as usize;
3769                token_id..token_id + 1
3770            }
3771        }
3772    }
3773
3774    fn posting_columns(&self, with_position: bool) -> Vec<&'static str> {
3775        let mut base_columns = if self.is_legacy_layout() {
3776            vec![ROW_ID, FREQUENCY_COL]
3777        } else {
3778            vec![POSTING_COL]
3779        };
3780        if with_position {
3781            match self.positions_layout {
3782                PositionsLayout::None => {}
3783                PositionsLayout::LegacyPerDoc => base_columns.push(POSITION_COL),
3784                PositionsLayout::SharedStream(_) => {
3785                    base_columns.push(COMPRESSED_POSITION_COL);
3786                    base_columns.push(POSITION_BLOCK_OFFSET_COL);
3787                }
3788            }
3789        }
3790        if self.has_impacts {
3791            base_columns.push(IMPACT_COL);
3792        }
3793        base_columns
3794    }
3795}
3796
3797/// Loop-invariant state for [`InvertedPartition::build_chunk_postings`]. The
3798/// metadata vecs are `Arc`d so each chunk's blocking build shares them cheaply.
3799struct ChunkBuildState {
3800    offsets: Option<Arc<Vec<usize>>>,
3801    max_scores: Option<Arc<Vec<f32>>>,
3802    lengths: Option<Arc<Vec<u32>>>,
3803    posting_tail_codec: PostingTailCodec,
3804    block_size: usize,
3805    positions_layout: PositionsLayout,
3806}
3807
3808/// Chunk-invariant inputs to [`InvertedPartition::build_prewarm_posting_lists_chunk`]:
3809/// the per-partition codec/layout and the (shared, whole-partition) metadata
3810/// slices indexed by global token id. These don't change across chunks.
3811struct PrewarmBuildCtx<'a> {
3812    max_scores: Option<&'a [f32]>,
3813    lengths: Option<&'a [u32]>,
3814    posting_tail_codec: PostingTailCodec,
3815    block_size: usize,
3816    positions_layout: PositionsLayout,
3817}
3818
3819/// Per-chunk inputs to [`InvertedPartition::build_prewarm_posting_lists_chunk`]:
3820/// the token sub-range `[tok_start, tok_start + token_count)` and, for legacy
3821/// v1, the rebased offset slice plus the chunk's end row.
3822struct PrewarmChunk<'a> {
3823    tok_start: usize,
3824    token_count: usize,
3825    /// Legacy v1 only: `offsets[tok_start..tok_start+token_count]` (no sentinel).
3826    offsets: Option<&'a [usize]>,
3827    /// Legacy v1 only: global row at which this chunk's posting rows end.
3828    end_row: usize,
3829}
3830
3831/// New type just to allow Positions implement DeepSizeOf so it can be put
3832/// in the cache.
3833#[derive(Clone)]
3834pub struct Positions(pub(super) CompressedPositionStorage);
3835
3836/// Slice-aware cache-size charge for the Arrow array shapes stored in posting
3837/// caches. [`Array::get_buffer_memory_size`] reports the full capacity of shared
3838/// backing buffers; cached posting lists often reference only a small slice of a
3839/// group read. Count the referenced span for the known posting-list types and
3840/// fall back to Arrow's full-buffer size for anything else.
3841fn sliced_cache_bytes(array: &dyn Array) -> usize {
3842    let validity_bytes = array
3843        .nulls()
3844        .map(|nulls| nulls.len().div_ceil(8))
3845        .unwrap_or(0);
3846    match array.data_type() {
3847        DataType::LargeBinary => {
3848            let array = array.as_binary::<i64>();
3849            let data_bytes = if array.is_empty() {
3850                0
3851            } else {
3852                let offsets = array.value_offsets();
3853                (offsets[array.len()] - offsets[0]) as usize
3854            };
3855            data_bytes + (array.len() + 1) * std::mem::size_of::<i64>() + validity_bytes
3856        }
3857        DataType::List(_) => {
3858            let array = array.as_list::<i32>();
3859            let (child_start, child_end) = if array.is_empty() {
3860                (0, 0)
3861            } else {
3862                let offsets = array.value_offsets();
3863                (offsets[0] as usize, offsets[array.len()] as usize)
3864            };
3865            let offset_bytes = (array.len() + 1) * std::mem::size_of::<i32>();
3866            let child = array.values().slice(child_start, child_end - child_start);
3867            offset_bytes + validity_bytes + sliced_cache_bytes(child.as_ref())
3868        }
3869        // Fixed-width primitives hold exactly `len * width` bytes regardless of
3870        // buffer capacity, so this is already slice-aware. Any other type falls
3871        // back to the full-buffer size.
3872        other => match other.primitive_width() {
3873            Some(width) => array.len() * width + validity_bytes,
3874            None => array.get_buffer_memory_size(),
3875        },
3876    }
3877}
3878
3879impl DeepSizeOf for Positions {
3880    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
3881        self.0.deep_size_of_children(context)
3882    }
3883}
3884
3885// Cache key implementations for type-safe cache access
3886#[derive(Debug, Clone)]
3887pub struct PostingListKey {
3888    pub token_id: u32,
3889}
3890
3891impl CacheKey for PostingListKey {
3892    type ValueType = PostingList;
3893
3894    fn key(&self) -> std::borrow::Cow<'_, str> {
3895        format!("postings-{}", self.token_id).into()
3896    }
3897
3898    fn type_name() -> &'static str {
3899        "PostingList"
3900    }
3901
3902    fn codec() -> Option<CacheCodec> {
3903        Some(CacheCodec::from_impl::<PostingList>())
3904    }
3905}
3906
3907/// Cache key for a group of consecutive posting lists stored as a single
3908/// entry, covering rows `[start, end)` (issue #7040). The range, not a token
3909/// id, is the key so a runtime group-size change simply misses old entries
3910/// instead of serving a differently-shaped group.
3911#[derive(Debug, Clone)]
3912pub struct PostingListGroupKey {
3913    pub start: u32,
3914    pub end: u32,
3915}
3916
3917impl CacheKey for PostingListGroupKey {
3918    type ValueType = PostingListGroup;
3919
3920    fn key(&self) -> std::borrow::Cow<'_, str> {
3921        format!("postings-{}-{}", self.start, self.end).into()
3922    }
3923
3924    fn type_name() -> &'static str {
3925        "PostingListGroup"
3926    }
3927
3928    fn codec() -> Option<CacheCodec> {
3929        Some(CacheCodec::from_impl::<PostingListGroup>())
3930    }
3931}
3932
3933/// Internal cache-key decorator that isolates impact-bearing posting values
3934/// without changing the source-compatible public posting key structs.
3935#[derive(Debug, Clone)]
3936struct ImpactAwareCacheKey<K> {
3937    inner: K,
3938    has_impacts: bool,
3939}
3940
3941impl<K: CacheKey> CacheKey for ImpactAwareCacheKey<K> {
3942    type ValueType = K::ValueType;
3943
3944    fn key(&self) -> std::borrow::Cow<'_, str> {
3945        if self.has_impacts {
3946            format!("{}-impacts", self.inner.key()).into()
3947        } else {
3948            self.inner.key()
3949        }
3950    }
3951
3952    fn type_name() -> &'static str {
3953        K::type_name()
3954    }
3955
3956    fn codec() -> Option<CacheCodec> {
3957        K::codec()
3958    }
3959}
3960
3961fn posting_list_cache_key(token_id: u32, has_impacts: bool) -> ImpactAwareCacheKey<PostingListKey> {
3962    ImpactAwareCacheKey {
3963        inner: PostingListKey { token_id },
3964        has_impacts,
3965    }
3966}
3967
3968fn posting_list_group_cache_key(
3969    start: u32,
3970    end: u32,
3971    has_impacts: bool,
3972) -> ImpactAwareCacheKey<PostingListGroupKey> {
3973    ImpactAwareCacheKey {
3974        inner: PostingListGroupKey { start, end },
3975        has_impacts,
3976    }
3977}
3978
3979#[derive(Debug, Clone, DeepSizeOf)]
3980struct PostingMetadataValue {
3981    max_score: f32,
3982    length: u32,
3983}
3984
3985#[derive(Debug, Clone)]
3986struct PostingMetadataKey {
3987    token_id: u32,
3988}
3989
3990impl CacheKey for PostingMetadataKey {
3991    type ValueType = PostingMetadataValue;
3992
3993    fn key(&self) -> std::borrow::Cow<'_, str> {
3994        format!("posting-metadata-{}", self.token_id).into()
3995    }
3996
3997    fn type_name() -> &'static str {
3998        "PostingMetadata"
3999    }
4000}
4001
4002#[derive(Debug, Clone)]
4003pub struct PositionKey {
4004    pub token_id: u32,
4005}
4006
4007impl CacheKey for PositionKey {
4008    type ValueType = Positions;
4009
4010    fn key(&self) -> std::borrow::Cow<'_, str> {
4011        format!("positions-{}", self.token_id).into()
4012    }
4013
4014    fn type_name() -> &'static str {
4015        "Position"
4016    }
4017
4018    fn codec() -> Option<CacheCodec> {
4019        Some(CacheCodec::from_impl::<Positions>())
4020    }
4021}
4022
4023#[derive(Debug, Clone, PartialEq)]
4024pub enum CompressedPositionStorage {
4025    LegacyPerDoc(ListArray),
4026    SharedStream(SharedPositionStream),
4027}
4028
4029impl DeepSizeOf for CompressedPositionStorage {
4030    fn deep_size_of_children(&self, _context: &mut lance_core::deepsize::Context) -> usize {
4031        match self {
4032            Self::LegacyPerDoc(positions) => sliced_cache_bytes(positions),
4033            Self::SharedStream(stream) => stream.size(),
4034        }
4035    }
4036}
4037
4038#[derive(Debug, Clone, PartialEq, Eq, Default)]
4039pub struct SharedPositionStream {
4040    codec: PositionStreamCodec,
4041    block_offsets: Arc<[u32]>,
4042    // Stored with shared ownership so cache hits can clone position streams
4043    // without copying either offsets or bytes.
4044    bytes: bytes::Bytes,
4045}
4046
4047impl SharedPositionStream {
4048    pub fn new(codec: PositionStreamCodec, block_offsets: Vec<u32>, bytes: bytes::Bytes) -> Self {
4049        Self {
4050            codec,
4051            block_offsets: Arc::from(block_offsets.into_boxed_slice()),
4052            bytes,
4053        }
4054    }
4055
4056    pub fn codec(&self) -> PositionStreamCodec {
4057        self.codec
4058    }
4059
4060    pub fn block_count(&self) -> usize {
4061        self.block_offsets.len()
4062    }
4063
4064    pub fn block_range(&self, index: usize) -> Range<usize> {
4065        let start = self.block_offsets[index] as usize;
4066        let end = self
4067            .block_offsets
4068            .get(index + 1)
4069            .map(|offset| *offset as usize)
4070            .unwrap_or(self.bytes.len());
4071        start..end
4072    }
4073
4074    pub fn block(&self, index: usize) -> &[u8] {
4075        let range = self.block_range(index);
4076        &self.bytes[range]
4077    }
4078
4079    pub fn bytes(&self) -> &[u8] {
4080        &self.bytes
4081    }
4082
4083    pub fn block_offsets(&self) -> &[u32] {
4084        self.block_offsets.as_ref()
4085    }
4086
4087    pub fn size(&self) -> usize {
4088        self.block_offsets.len() * std::mem::size_of::<u32>() + self.bytes.len()
4089    }
4090}
4091
4092/// A group of consecutive posting lists held in a single cache entry, in row
4093/// order (issue #7040). Prewarmed modern groups without positions retain only
4094/// the compact Arrow posting rows read from `invert.lance`; max-score/length
4095/// metadata stays in the reader and is injected when a query creates a
4096/// posting-list view. Cold-loaded groups may keep inline metadata to preserve
4097/// one-read query loading. Legacy and position-bearing prewarm paths use the
4098/// materialized fallback.
4099#[derive(Debug, Clone)]
4100pub struct PostingListGroup {
4101    pub(super) storage: PostingListGroupStorage,
4102}
4103
4104#[derive(Debug, Clone)]
4105pub(super) enum PostingListGroupStorage {
4106    Packed(PackedPostingListGroup),
4107    Materialized(Vec<PostingList>),
4108}
4109
4110#[derive(Debug, Clone)]
4111pub(super) struct PackedPostingListGroup {
4112    pub(super) batch: RecordBatch,
4113    pub(super) posting_tail_codec: PostingTailCodec,
4114    pub(super) block_size: usize,
4115    first_docs_states: Arc<[OnceLock<Box<[u32]>>]>,
4116    first_docs_state_capacity_bytes: usize,
4117    impact_states: Option<Arc<[OnceLock<Box<ImpactSkipData>>]>>,
4118    impact_state_capacity_bytes: usize,
4119}
4120
4121impl DeepSizeOf for PostingListGroup {
4122    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
4123        match &self.storage {
4124            PostingListGroupStorage::Packed(group) => group
4125                .batch
4126                .columns()
4127                .iter()
4128                .map(|column| sliced_cache_bytes(column.as_ref()))
4129                .sum::<usize>()
4130                .saturating_add(group.first_docs_state_capacity_bytes)
4131                .saturating_add(group.impact_state_capacity_bytes),
4132            PostingListGroupStorage::Materialized(posting_lists) => {
4133                posting_lists.deep_size_of_children(context)
4134            }
4135        }
4136    }
4137}
4138
4139impl PostingListGroup {
4140    pub(super) fn new(posting_lists: Vec<PostingList>) -> Self {
4141        Self {
4142            storage: PostingListGroupStorage::Materialized(posting_lists),
4143        }
4144    }
4145
4146    pub(super) fn new_packed(
4147        batch: RecordBatch,
4148        posting_tail_codec: PostingTailCodec,
4149    ) -> Result<Self> {
4150        let block_size = parse_posting_block_size(batch.schema_ref().metadata())?;
4151        Self::new_packed_with_block_size(batch, posting_tail_codec, block_size)
4152    }
4153
4154    fn new_packed_with_block_size(
4155        batch: RecordBatch,
4156        posting_tail_codec: PostingTailCodec,
4157        block_size: usize,
4158    ) -> Result<Self> {
4159        validate_block_size(block_size)?;
4160        if let Some(encoded_block_size) = batch.schema_ref().metadata().get(POSTING_BLOCK_SIZE_KEY)
4161        {
4162            let encoded_block_size = encoded_block_size.parse::<usize>().map_err(|err| {
4163                Error::index(format!(
4164                    "invalid {POSTING_BLOCK_SIZE_KEY} metadata value {encoded_block_size:?}: {err}"
4165                ))
4166            })?;
4167            if encoded_block_size != block_size {
4168                return Err(Error::index(format!(
4169                    "packed posting group {POSTING_BLOCK_SIZE_KEY}={encoded_block_size} does not match block_size={block_size}"
4170                )));
4171            }
4172        }
4173
4174        // Projected reads may drop schema metadata. Restore the reader's
4175        // validated block size before the batch enters the packed cache so IPC
4176        // roundtrips remain self-describing. Older packed cache entries omit
4177        // the key and enter through new_packed with the legacy 128-doc default.
4178        let mut schema = batch.schema().as_ref().clone();
4179        schema
4180            .metadata
4181            .insert(POSTING_BLOCK_SIZE_KEY.to_owned(), block_size.to_string());
4182        let batch = batch.with_schema(Arc::new(schema))?;
4183        let postings = batch
4184            .column_by_name(POSTING_COL)
4185            .and_then(|column| column.as_list_opt::<i32>())
4186            .ok_or_else(|| {
4187                Error::index(format!(
4188                    "packed posting group column {POSTING_COL} must be List<LargeBinary>"
4189                ))
4190            })?;
4191        if postings.values().data_type() != &DataType::LargeBinary {
4192            return Err(Error::index(format!(
4193                "packed posting group column {POSTING_COL} must contain LargeBinary values, got {}",
4194                postings.values().data_type()
4195            )));
4196        }
4197        if postings.null_count() != 0 {
4198            return Err(Error::index(
4199                "packed posting group column must not contain nulls".to_string(),
4200            ));
4201        }
4202        let total_posting_blocks = (0..batch.num_rows())
4203            .map(|slot| postings.value_length(slot) as usize)
4204            .sum::<usize>();
4205        let first_docs_states: Arc<[OnceLock<Box<[u32]>>]> = (0..batch.num_rows())
4206            .map(|_| OnceLock::new())
4207            .collect::<Vec<_>>()
4208            .into();
4209        // Reserve the compact per-slot state slab and the block-head arrays it
4210        // can lazily retain, so warming these derived values cannot grow the
4211        // cache beyond its admission charge.
4212        let first_docs_state_capacity_bytes = first_docs_states
4213            .len()
4214            .saturating_mul(std::mem::size_of::<OnceLock<Box<[u32]>>>())
4215            .saturating_add(total_posting_blocks.saturating_mul(std::mem::size_of::<u32>()));
4216        let (impact_states, impact_state_capacity_bytes) = if let Some(impacts) =
4217            batch.column_by_name(IMPACT_COL)
4218        {
4219            let impacts = impacts.as_list_opt::<i32>().ok_or_else(|| {
4220                Error::index(format!(
4221                    "packed posting group column {IMPACT_COL} must be List<LargeBinary>"
4222                ))
4223            })?;
4224            if impacts.values().data_type() != &DataType::LargeBinary {
4225                return Err(Error::index(format!(
4226                    "packed posting group column {IMPACT_COL} must contain LargeBinary values, got {}",
4227                    impacts.values().data_type()
4228                )));
4229            }
4230            if impacts.null_count() != 0 {
4231                return Err(Error::index(format!(
4232                    "packed posting group column {IMPACT_COL} must not contain nulls"
4233                )));
4234            }
4235            let mut derived_cache_bytes = 0usize;
4236            for slot in 0..batch.num_rows() {
4237                let posting_blocks = postings.value_length(slot) as usize;
4238                let impact_entries = impacts.value_length(slot) as usize;
4239                let expected_impact_entries =
4240                    posting_blocks.saturating_add(posting_blocks.div_ceil(IMPACT_LEVEL1_BLOCKS));
4241                if impact_entries != expected_impact_entries {
4242                    return Err(Error::index(format!(
4243                        "packed posting group impact slot {slot} has {impact_entries} entries, expected {expected_impact_entries} for {posting_blocks} posting blocks"
4244                    )));
4245                }
4246                derived_cache_bytes = derived_cache_bytes.saturating_add(
4247                    ImpactSkipData::derived_cache_bytes_for_entries(impact_entries),
4248                );
4249            }
4250
4251            let states: Arc<[OnceLock<Box<ImpactSkipData>>]> = (0..batch.num_rows())
4252                .map(|_| OnceLock::new())
4253                .collect::<Vec<_>>()
4254                .into();
4255            // Account up front for every allocation that the lazy states can
4256            // eventually retain. The impact entry bytes themselves remain in
4257            // `batch` and are already charged exactly once above.
4258            let per_slot_bytes = std::mem::size_of::<OnceLock<Box<ImpactSkipData>>>()
4259                .saturating_add(std::mem::size_of::<ImpactSkipData>());
4260            let capacity_bytes = states
4261                .len()
4262                .saturating_mul(per_slot_bytes)
4263                .saturating_add(derived_cache_bytes);
4264            (Some(states), capacity_bytes)
4265        } else {
4266            (None, 0)
4267        };
4268        match (
4269            batch.column_by_name(MAX_SCORE_COL),
4270            batch.column_by_name(LENGTH_COL),
4271        ) {
4272            (None, None) => {}
4273            (Some(max_scores), Some(lengths)) => {
4274                let max_scores = max_scores
4275                    .as_primitive_opt::<Float32Type>()
4276                    .ok_or_else(|| {
4277                        Error::index(format!(
4278                            "packed posting group column {MAX_SCORE_COL} must be Float32"
4279                        ))
4280                    })?;
4281                let lengths = lengths.as_primitive_opt::<UInt32Type>().ok_or_else(|| {
4282                    Error::index(format!(
4283                        "packed posting group column {LENGTH_COL} must be UInt32"
4284                    ))
4285                })?;
4286                if max_scores.null_count() != 0 || lengths.null_count() != 0 {
4287                    return Err(Error::index(
4288                        "packed posting group metadata columns must not contain nulls".to_string(),
4289                    ));
4290                }
4291            }
4292            _ => {
4293                return Err(Error::index(format!(
4294                    "packed posting group must contain both {MAX_SCORE_COL} and {LENGTH_COL}, or neither"
4295                )));
4296            }
4297        }
4298
4299        Ok(Self {
4300            storage: PostingListGroupStorage::Packed(PackedPostingListGroup {
4301                batch,
4302                posting_tail_codec,
4303                block_size,
4304                first_docs_states,
4305                first_docs_state_capacity_bytes,
4306                impact_states,
4307                impact_state_capacity_bytes,
4308            }),
4309        })
4310    }
4311
4312    pub(super) fn len(&self) -> usize {
4313        match &self.storage {
4314            PostingListGroupStorage::Packed(group) => group.batch.num_rows(),
4315            PostingListGroupStorage::Materialized(posting_lists) => posting_lists.len(),
4316        }
4317    }
4318
4319    #[cfg(test)]
4320    pub(super) fn is_packed(&self) -> bool {
4321        matches!(&self.storage, PostingListGroupStorage::Packed(_))
4322    }
4323
4324    fn needs_external_metadata(&self) -> bool {
4325        match &self.storage {
4326            PostingListGroupStorage::Packed(group) => {
4327                group.batch.column_by_name(MAX_SCORE_COL).is_none()
4328            }
4329            PostingListGroupStorage::Materialized(_) => false,
4330        }
4331    }
4332
4333    /// Build an owned posting-list view for `slot`. Packed groups clone only
4334    /// Arrow array metadata; the compressed posting bytes remain shared with
4335    /// the group's `List<LargeBinary>` child buffers.
4336    pub(super) fn posting_list(
4337        &self,
4338        slot: usize,
4339        max_score: Option<f32>,
4340        length: Option<u32>,
4341    ) -> Result<Option<PostingList>> {
4342        match &self.storage {
4343            PostingListGroupStorage::Materialized(posting_lists) => {
4344                Ok(posting_lists.get(slot).cloned())
4345            }
4346            PostingListGroupStorage::Packed(group) => {
4347                if slot >= group.batch.num_rows() {
4348                    return Ok(None);
4349                }
4350                let postings = group
4351                    .batch
4352                    .column_by_name(POSTING_COL)
4353                    .and_then(|column| column.as_list_opt::<i32>())
4354                    .ok_or_else(|| {
4355                        Error::index(format!(
4356                            "packed posting group column {POSTING_COL} must be List<LargeBinary>"
4357                        ))
4358                    })?;
4359                let blocks = postings.value(slot);
4360                let blocks = blocks.as_binary_opt::<i64>().ok_or_else(|| {
4361                    Error::index(format!(
4362                        "packed posting group slot {slot} is not LargeBinary"
4363                    ))
4364                })?;
4365                let max_score = match group.batch.column_by_name(MAX_SCORE_COL) {
4366                    Some(column) => column
4367                        .as_primitive_opt::<Float32Type>()
4368                        .expect("packed group metadata was validated at construction")
4369                        .value(slot),
4370                    None => max_score.ok_or_else(|| {
4371                        Error::index("packed posting group requires max-score metadata".to_string())
4372                    })?,
4373                };
4374                let length = match group.batch.column_by_name(LENGTH_COL) {
4375                    Some(column) => column
4376                        .as_primitive_opt::<UInt32Type>()
4377                        .expect("packed group metadata was validated at construction")
4378                        .value(slot),
4379                    None => length.ok_or_else(|| {
4380                        Error::index("packed posting group requires length metadata".to_string())
4381                    })?,
4382                };
4383                let impacts = match (
4384                    group.impact_states.as_ref(),
4385                    group.batch.column_by_name(IMPACT_COL),
4386                ) {
4387                    (Some(states), Some(column)) => {
4388                        let state = states.get(slot).ok_or_else(|| {
4389                            Error::index(format!(
4390                                "packed posting group impact state missing slot {slot}"
4391                            ))
4392                        })?;
4393                        let impact_lists = column.as_list_opt::<i32>().ok_or_else(|| {
4394                            Error::index(format!(
4395                                "packed posting group column {IMPACT_COL} must be List<LargeBinary>"
4396                            ))
4397                        })?;
4398                        let entries = impact_lists.value(slot);
4399                        let entries = entries.as_binary_opt::<i64>().ok_or_else(|| {
4400                            Error::index(format!(
4401                                "packed posting group impact slot {slot} is not LargeBinary"
4402                            ))
4403                        })?;
4404                        let impacts =
4405                            state.get_or_init(|| {
4406                                Box::new(ImpactSkipData::new(entries.clone(), blocks.len()).expect(
4407                                    "packed impact entry count was validated at construction",
4408                                ))
4409                            });
4410                        Some(impacts.as_ref().clone())
4411                    }
4412                    (None, None) => None,
4413                    _ => {
4414                        return Err(Error::internal(
4415                            "packed posting group impact column/state mismatch".to_string(),
4416                        ));
4417                    }
4418                };
4419                Ok(Some(PostingList::Compressed(
4420                    CompressedPostingList::new(
4421                        blocks.clone(),
4422                        max_score,
4423                        length,
4424                        group.posting_tail_codec,
4425                        group.block_size,
4426                        None,
4427                        impacts,
4428                    )
4429                    .with_packed_first_docs(group.first_docs_states.clone(), slot),
4430                )))
4431            }
4432        }
4433    }
4434}
4435
4436#[derive(Debug, Clone, DeepSizeOf)]
4437#[allow(clippy::large_enum_variant)]
4438pub enum PostingList {
4439    Plain(PlainPostingList),
4440    Compressed(CompressedPostingList),
4441}
4442
4443impl PostingList {
4444    pub fn from_batch(
4445        batch: &RecordBatch,
4446        max_score: Option<f32>,
4447        length: Option<u32>,
4448    ) -> Result<Self> {
4449        let posting_tail_codec = parse_posting_tail_codec(batch.schema_ref().metadata())?;
4450        let block_size = parse_posting_block_size(batch.schema_ref().metadata())?;
4451        Self::from_batch_with_tail_codec(batch, max_score, length, posting_tail_codec, block_size)
4452    }
4453
4454    pub fn from_batch_with_tail_codec(
4455        batch: &RecordBatch,
4456        max_score: Option<f32>,
4457        length: Option<u32>,
4458        posting_tail_codec: PostingTailCodec,
4459        block_size: usize,
4460    ) -> Result<Self> {
4461        let positions_layout = if batch.column_by_name(COMPRESSED_POSITION_COL).is_some() {
4462            PositionsLayout::SharedStream(parse_shared_position_codec(
4463                batch.schema_ref().metadata(),
4464            )?)
4465        } else if batch.column_by_name(POSITION_COL).is_some() {
4466            PositionsLayout::LegacyPerDoc
4467        } else {
4468            PositionsLayout::None
4469        };
4470        Self::from_batch_with_tail_codec_and_positions_layout(
4471            batch,
4472            max_score,
4473            length,
4474            posting_tail_codec,
4475            block_size,
4476            positions_layout,
4477        )
4478    }
4479
4480    fn from_batch_with_tail_codec_and_positions_layout(
4481        batch: &RecordBatch,
4482        max_score: Option<f32>,
4483        length: Option<u32>,
4484        posting_tail_codec: PostingTailCodec,
4485        block_size: usize,
4486        positions_layout: PositionsLayout,
4487    ) -> Result<Self> {
4488        match batch.column_by_name(POSTING_COL) {
4489            Some(_) => {
4490                debug_assert!(max_score.is_some() && length.is_some());
4491                let shared_position_codec = match positions_layout {
4492                    PositionsLayout::SharedStream(codec) => Some(codec),
4493                    _ => None,
4494                };
4495                let posting = CompressedPostingList::from_batch(
4496                    batch,
4497                    max_score.unwrap(),
4498                    length.unwrap(),
4499                    posting_tail_codec,
4500                    block_size,
4501                    shared_position_codec,
4502                )?;
4503                Ok(Self::Compressed(posting))
4504            }
4505            None => {
4506                let posting = PlainPostingList::from_batch(batch, max_score);
4507                Ok(Self::Plain(posting))
4508            }
4509        }
4510    }
4511
4512    pub fn iter(&self) -> PostingListIterator<'_> {
4513        PostingListIterator::new(self)
4514    }
4515
4516    pub fn has_position(&self) -> bool {
4517        match self {
4518            Self::Plain(posting) => posting.positions.is_some(),
4519            Self::Compressed(posting) => posting.positions.is_some(),
4520        }
4521    }
4522
4523    pub fn has_impacts(&self) -> bool {
4524        match self {
4525            Self::Plain(_) => false,
4526            Self::Compressed(posting) => posting.impacts.is_some(),
4527        }
4528    }
4529
4530    pub fn set_positions(&mut self, positions: CompressedPositionStorage) {
4531        match self {
4532            Self::Plain(posting) => match positions {
4533                CompressedPositionStorage::LegacyPerDoc(positions) => {
4534                    posting.positions = Some(positions)
4535                }
4536                CompressedPositionStorage::SharedStream(_) => {
4537                    unreachable!("shared position stream is not supported for plain postings")
4538                }
4539            },
4540            Self::Compressed(posting) => {
4541                posting.positions = Some(positions);
4542            }
4543        }
4544    }
4545
4546    pub fn take_positions(&mut self) -> Option<CompressedPositionStorage> {
4547        match self {
4548            Self::Plain(posting) => posting
4549                .positions
4550                .take()
4551                .map(CompressedPositionStorage::LegacyPerDoc),
4552            Self::Compressed(posting) => posting.positions.take(),
4553        }
4554    }
4555
4556    pub fn max_score(&self) -> Option<f32> {
4557        match self {
4558            Self::Plain(posting) => posting.max_score,
4559            Self::Compressed(posting) => Some(posting.max_score),
4560        }
4561    }
4562
4563    pub fn len(&self) -> usize {
4564        match self {
4565            Self::Plain(posting) => posting.len(),
4566            Self::Compressed(posting) => posting.length as usize,
4567        }
4568    }
4569
4570    pub fn is_empty(&self) -> bool {
4571        self.len() == 0
4572    }
4573
4574    pub fn into_builder(self, docs: &DocSet) -> PostingListBuilder {
4575        let posting_tail_codec = match &self {
4576            Self::Plain(_) => PostingTailCodec::Fixed32,
4577            Self::Compressed(posting) => posting.posting_tail_codec,
4578        };
4579        let block_size = match &self {
4580            Self::Plain(_) => LEGACY_BLOCK_SIZE,
4581            Self::Compressed(posting) => posting.block_size,
4582        };
4583        let mut builder = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
4584            self.has_position(),
4585            posting_tail_codec,
4586            block_size,
4587        );
4588        match self {
4589            // legacy format
4590            Self::Plain(posting) => {
4591                // convert the posting list to the new format:
4592                // 1. map row ids to doc ids
4593                // 2. sort the posting list by doc ids
4594                struct Item {
4595                    doc_id: u32,
4596                    positions: PositionRecorder,
4597                }
4598                let doc_ids = docs
4599                    .row_ids
4600                    .iter()
4601                    .enumerate()
4602                    .map(|(doc_id, row_id)| (*row_id, doc_id as u32))
4603                    .collect::<HashMap<_, _>>();
4604                let mut items = Vec::with_capacity(posting.len());
4605                for (row_id, freq, positions) in posting.iter() {
4606                    let freq = freq as u32;
4607                    let positions = match positions {
4608                        Some(positions) => {
4609                            PositionRecorder::Position(positions.collect::<Vec<_>>().into())
4610                        }
4611                        None => PositionRecorder::Count(freq),
4612                    };
4613                    items.push(Item {
4614                        doc_id: doc_ids[&row_id],
4615                        positions,
4616                    });
4617                }
4618                items.sort_unstable_by_key(|item| item.doc_id);
4619                for item in items {
4620                    builder.add(item.doc_id, item.positions);
4621                }
4622            }
4623            Self::Compressed(posting) => {
4624                posting.iter().for_each(|(doc_id, freq, positions)| {
4625                    let positions = match positions {
4626                        Some(positions) => {
4627                            PositionRecorder::Position(positions.collect::<Vec<_>>().into())
4628                        }
4629                        None => PositionRecorder::Count(freq),
4630                    };
4631                    builder.add(doc_id, positions);
4632                });
4633            }
4634        }
4635        builder
4636    }
4637}
4638
4639#[derive(Debug, PartialEq, Clone)]
4640pub struct PlainPostingList {
4641    pub row_ids: ScalarBuffer<u64>,
4642    pub frequencies: ScalarBuffer<f32>,
4643    pub max_score: Option<f32>,
4644    pub positions: Option<ListArray>, // List of Int32
4645}
4646
4647impl DeepSizeOf for PlainPostingList {
4648    fn deep_size_of_children(&self, _context: &mut lance_core::deepsize::Context) -> usize {
4649        self.row_ids.len() * std::mem::size_of::<u64>()
4650            + self.frequencies.len() * std::mem::size_of::<f32>()
4651            + self
4652                .positions
4653                .as_ref()
4654                .map(|positions| sliced_cache_bytes(positions))
4655                .unwrap_or(0)
4656    }
4657}
4658
4659impl PlainPostingList {
4660    pub fn new(
4661        row_ids: ScalarBuffer<u64>,
4662        frequencies: ScalarBuffer<f32>,
4663        max_score: Option<f32>,
4664        positions: Option<ListArray>,
4665    ) -> Self {
4666        Self {
4667            row_ids,
4668            frequencies,
4669            max_score,
4670            positions,
4671        }
4672    }
4673
4674    pub fn from_batch(batch: &RecordBatch, max_score: Option<f32>) -> Self {
4675        let row_ids = batch[ROW_ID].as_primitive::<UInt64Type>().values().clone();
4676        let frequencies = batch[FREQUENCY_COL]
4677            .as_primitive::<Float32Type>()
4678            .values()
4679            .clone();
4680        let positions = batch
4681            .column_by_name(POSITION_COL)
4682            .map(|col| col.as_list::<i32>().clone());
4683
4684        Self::new(row_ids, frequencies, max_score, positions)
4685    }
4686
4687    pub fn len(&self) -> usize {
4688        self.row_ids.len()
4689    }
4690
4691    pub fn is_empty(&self) -> bool {
4692        self.len() == 0
4693    }
4694
4695    pub fn iter(&self) -> PlainPostingListIterator<'_> {
4696        Box::new(
4697            self.row_ids
4698                .iter()
4699                .zip(self.frequencies.iter())
4700                .enumerate()
4701                .map(|(idx, (doc_id, freq))| {
4702                    (
4703                        *doc_id,
4704                        *freq,
4705                        self.positions.as_ref().map(|p| {
4706                            let start = p.value_offsets()[idx] as usize;
4707                            let end = p.value_offsets()[idx + 1] as usize;
4708                            Box::new(
4709                                p.values().as_primitive::<Int32Type>().values()[start..end]
4710                                    .iter()
4711                                    .map(|pos| *pos as u32),
4712                            ) as _
4713                        }),
4714                    )
4715                }),
4716        )
4717    }
4718
4719    #[inline]
4720    pub fn doc(&self, i: usize) -> LocatedDocInfo {
4721        LocatedDocInfo::new(self.row_ids[i], self.frequencies[i])
4722    }
4723
4724    pub fn positions(&self, index: usize) -> Option<Arc<dyn Array>> {
4725        self.positions
4726            .as_ref()
4727            .map(|positions| positions.value(index))
4728    }
4729
4730    pub fn max_score(&self) -> Option<f32> {
4731        self.max_score
4732    }
4733
4734    pub fn row_id(&self, i: usize) -> u64 {
4735        self.row_ids[i]
4736    }
4737}
4738
4739#[derive(Debug, Clone)]
4740enum FirstDocsState {
4741    Standalone(Arc<OnceLock<Box<[u32]>>>),
4742    Packed {
4743        states: Arc<[OnceLock<Box<[u32]>>]>,
4744        slot: usize,
4745    },
4746}
4747
4748impl FirstDocsState {
4749    fn standalone() -> Self {
4750        Self::Standalone(Arc::new(OnceLock::new()))
4751    }
4752
4753    fn state(&self) -> &OnceLock<Box<[u32]>> {
4754        match self {
4755            Self::Standalone(state) => state,
4756            Self::Packed { states, slot } => &states[*slot],
4757        }
4758    }
4759
4760    fn get_or_init(&self, initialize: impl FnOnce() -> Box<[u32]>) -> &[u32] {
4761        self.state().get_or_init(initialize)
4762    }
4763
4764    fn capacity_bytes(
4765        &self,
4766        block_count: usize,
4767        context: &mut lance_core::deepsize::Context,
4768    ) -> usize {
4769        if context.mark_seen(self.state() as *const _ as usize) {
4770            std::mem::size_of::<OnceLock<Box<[u32]>>>()
4771                .saturating_add(block_count.saturating_mul(std::mem::size_of::<u32>()))
4772        } else {
4773            0
4774        }
4775    }
4776
4777    #[cfg(test)]
4778    fn shares_state_with(&self, other: &Self) -> bool {
4779        std::ptr::eq(self.state(), other.state())
4780    }
4781}
4782
4783#[derive(Debug, Clone)]
4784pub struct CompressedPostingList {
4785    pub max_score: f32,
4786    pub length: u32,
4787    // each binary is a block of compressed data
4788    // that contains `block_size` doc ids and then `block_size` frequencies,
4789    // packed by the physical bitpacker matching that block size.
4790    pub blocks: LargeBinaryArray,
4791    pub posting_tail_codec: PostingTailCodec,
4792    pub block_size: usize,
4793    pub positions: Option<CompressedPositionStorage>,
4794    pub(crate) impacts: Option<ImpactSkipData>,
4795    // First doc id per block, baked lazily and shared across per-query clones
4796    // of the cached list. See `block_first_docs`.
4797    first_docs: FirstDocsState,
4798}
4799
4800impl PartialEq for CompressedPostingList {
4801    fn eq(&self, other: &Self) -> bool {
4802        self.max_score == other.max_score
4803            && self.length == other.length
4804            && self.blocks == other.blocks
4805            && self.posting_tail_codec == other.posting_tail_codec
4806            && self.block_size == other.block_size
4807            && self.positions == other.positions
4808            && self.impacts == other.impacts
4809    }
4810}
4811
4812impl DeepSizeOf for CompressedPostingList {
4813    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
4814        sliced_cache_bytes(&self.blocks)
4815            + self
4816                .positions
4817                .as_ref()
4818                .map(|positions| positions.deep_size_of_children(context))
4819                .unwrap_or(0)
4820            + self
4821                .impacts
4822                .as_ref()
4823                .map(|impacts| {
4824                    sliced_cache_bytes(impacts.entries())
4825                        .saturating_add(impacts.derived_cache_bytes())
4826                })
4827                .unwrap_or(0)
4828            + self.first_docs.capacity_bytes(self.blocks.len(), context)
4829    }
4830}
4831
4832impl CompressedPostingList {
4833    pub(crate) fn new(
4834        blocks: LargeBinaryArray,
4835        max_score: f32,
4836        length: u32,
4837        posting_tail_codec: PostingTailCodec,
4838        block_size: usize,
4839        positions: Option<CompressedPositionStorage>,
4840        impacts: Option<ImpactSkipData>,
4841    ) -> Self {
4842        debug_assert!(block_size.is_power_of_two());
4843        Self {
4844            max_score,
4845            length,
4846            blocks,
4847            posting_tail_codec,
4848            block_size,
4849            positions,
4850            impacts,
4851            first_docs: FirstDocsState::standalone(),
4852        }
4853    }
4854
4855    fn with_packed_first_docs(mut self, states: Arc<[OnceLock<Box<[u32]>>]>, slot: usize) -> Self {
4856        debug_assert!(slot < states.len());
4857        self.first_docs = FirstDocsState::Packed { states, slot };
4858        self
4859    }
4860
4861    /// Block sizes are validated powers of two, so per-doc hot loops derive
4862    /// block indices with shift/mask instead of runtime division, which is
4863    /// measurably slower in the iterator advance path.
4864    #[inline]
4865    pub(crate) fn block_shift(&self) -> u32 {
4866        self.block_size.trailing_zeros()
4867    }
4868
4869    #[inline]
4870    pub(crate) fn block_mask(&self) -> usize {
4871        self.block_size - 1
4872    }
4873
4874    pub fn from_batch(
4875        batch: &RecordBatch,
4876        max_score: f32,
4877        length: u32,
4878        posting_tail_codec: PostingTailCodec,
4879        block_size: usize,
4880        shared_position_codec: Option<PositionStreamCodec>,
4881    ) -> Result<Self> {
4882        debug_assert_eq!(batch.num_rows(), 1);
4883        let blocks = batch[POSTING_COL]
4884            .as_list::<i32>()
4885            .value(0)
4886            .as_binary::<i64>()
4887            .clone();
4888        let positions = if let Some(col) = batch.column_by_name(COMPRESSED_POSITION_COL) {
4889            let bytes = bytes::Bytes::from(col.as_binary::<i64>().value(0).to_vec());
4890            let block_offsets = batch[POSITION_BLOCK_OFFSET_COL]
4891                .as_list::<i32>()
4892                .value(0)
4893                .as_primitive::<UInt32Type>()
4894                .values()
4895                .to_vec();
4896            let codec = shared_position_codec.unwrap_or_else(|| {
4897                parse_shared_position_codec(batch.schema_ref().metadata())
4898                    .expect("shared position stream codec metadata should be valid")
4899            });
4900            Some(CompressedPositionStorage::SharedStream(
4901                SharedPositionStream::new(codec, block_offsets, bytes),
4902            ))
4903        } else {
4904            batch.column_by_name(POSITION_COL).map(|col| {
4905                CompressedPositionStorage::LegacyPerDoc(
4906                    col.as_list::<i32>().value(0).as_list::<i32>().clone(),
4907                )
4908            })
4909        };
4910        let impacts = batch
4911            .column_by_name(IMPACT_COL)
4912            .map(|col| {
4913                let entries = col.as_list::<i32>().value(0).as_binary::<i64>().clone();
4914                ImpactSkipData::new(entries, blocks.len())
4915            })
4916            .transpose()?;
4917
4918        Ok(Self {
4919            max_score,
4920            length,
4921            blocks,
4922            posting_tail_codec,
4923            block_size,
4924            positions,
4925            impacts,
4926            first_docs: FirstDocsState::standalone(),
4927        })
4928    }
4929
4930    pub fn iter(&self) -> CompressedPostingListIterator {
4931        CompressedPostingListIterator::new(
4932            self.length as usize,
4933            self.blocks.clone(),
4934            self.posting_tail_codec,
4935            self.positions.clone(),
4936            self.block_size,
4937        )
4938    }
4939
4940    pub fn block_max_score(&self, block_idx: usize) -> f32 {
4941        // 256-document blocks store no per-block max score: their impact
4942        // skip data supplies the tight per-block bound, so callers on that
4943        // path never reach here. Fall back to the list-level max, which is
4944        // still a valid (looser) bound for any block.
4945        if super::encoding::posting_block_score_prefix_len(self.block_size) == 0 {
4946            return self.max_score;
4947        }
4948        let block = self.blocks.value(block_idx);
4949        block[0..4].try_into().map(f32::from_le_bytes).unwrap()
4950    }
4951
4952    #[inline]
4953    pub fn block_least_doc_id(&self, block_idx: usize) -> u32 {
4954        self.block_first_docs()[block_idx]
4955    }
4956
4957    /// First doc id of every block, decoded once per cached list and shared by
4958    /// the per-query clones. Block boundary lookups (window bounds, block
4959    /// binary searches) are hot enough that re-reading the block headers —
4960    /// and re-decoding the tail block — shows up in profiles.
4961    pub(crate) fn block_first_docs(&self) -> &[u32] {
4962        self.first_docs.get_or_init(|| {
4963            (0..self.blocks.len())
4964                .map(|block_idx| {
4965                    let block = self.blocks.value(block_idx);
4966                    let remainder = self.length as usize % self.block_size;
4967                    if block_idx + 1 == self.blocks.len() && remainder > 0 {
4968                        return super::encoding::read_posting_tail_first_doc(
4969                            block,
4970                            self.posting_tail_codec,
4971                            self.block_size,
4972                        );
4973                    }
4974                    let prefix = super::encoding::posting_block_score_prefix_len(self.block_size);
4975                    block[prefix..prefix + 4]
4976                        .try_into()
4977                        .map(u32::from_le_bytes)
4978                        .unwrap()
4979                })
4980                .collect::<Vec<_>>()
4981                .into_boxed_slice()
4982        })
4983    }
4984
4985    #[cfg(test)]
4986    fn shares_first_docs_with(&self, other: &Self) -> bool {
4987        self.first_docs.shares_state_with(&other.first_docs)
4988    }
4989}
4990
4991#[derive(Debug, Clone, PartialEq, Eq, Default)]
4992struct EncodedBlocks {
4993    offsets: Vec<u32>,
4994    bytes: Vec<u8>,
4995}
4996
4997impl EncodedBlocks {
4998    fn len(&self) -> usize {
4999        self.offsets.len()
5000    }
5001
5002    fn size(&self) -> usize {
5003        self.offsets.capacity() * std::mem::size_of::<u32>() + self.bytes.capacity()
5004    }
5005
5006    fn push_full_block(&mut self, doc_ids: &[u32], frequencies: &[u32]) -> Result<usize> {
5007        let start = self.bytes.len();
5008        self.offsets.push(start as u32);
5009        super::encoding::encode_full_posting_block_into(doc_ids, frequencies, &mut self.bytes)?;
5010        Ok(self.bytes.len() - start)
5011    }
5012
5013    fn block(&self, index: usize) -> &[u8] {
5014        let (start, end) = self.block_range(index);
5015        &self.bytes[start..end]
5016    }
5017
5018    fn block_range(&self, index: usize) -> (usize, usize) {
5019        let start = self.offsets[index] as usize;
5020        let end = self
5021            .offsets
5022            .get(index + 1)
5023            .map(|offset| *offset as usize)
5024            .unwrap_or(self.bytes.len());
5025        (start, end)
5026    }
5027
5028    fn set_block_score(&mut self, index: usize, score: f32) {
5029        let (start, _) = self.block_range(index);
5030        self.bytes[start..start + 4].copy_from_slice(&score.to_le_bytes());
5031    }
5032
5033    fn append_remainder_block_with_codec(
5034        &mut self,
5035        doc_ids: &[u32],
5036        frequencies: &[u32],
5037        codec: PostingTailCodec,
5038        block_size: usize,
5039    ) -> Result<()> {
5040        self.offsets.push(self.bytes.len() as u32);
5041        super::encoding::encode_remainder_posting_block_into(
5042            doc_ids,
5043            frequencies,
5044            codec,
5045            block_size,
5046            &mut self.bytes,
5047        )
5048    }
5049
5050    fn into_array(mut self) -> LargeBinaryArray {
5051        let mut offsets = Vec::with_capacity(self.offsets.len() + 1);
5052        offsets.extend(self.offsets.into_iter().map(i64::from));
5053        offsets.push(self.bytes.len() as i64);
5054        LargeBinaryArray::new(
5055            OffsetBuffer::new(ScalarBuffer::from(offsets)),
5056            Buffer::from_vec(std::mem::take(&mut self.bytes)),
5057            None,
5058        )
5059    }
5060
5061    fn iter(&self) -> impl Iterator<Item = &[u8]> {
5062        (0..self.len()).map(|index| self.block(index))
5063    }
5064}
5065
5066#[derive(Debug, Clone, PartialEq, Eq, Default)]
5067struct EncodedPositionBlocks {
5068    offsets: Vec<u32>,
5069    bytes: Vec<u8>,
5070}
5071
5072impl EncodedPositionBlocks {
5073    fn size(&self) -> usize {
5074        self.offsets.capacity() * std::mem::size_of::<u32>() + self.bytes.capacity()
5075    }
5076
5077    fn block(&self, index: usize) -> &[u8] {
5078        let start = self.offsets[index] as usize;
5079        let end = self
5080            .offsets
5081            .get(index + 1)
5082            .map(|offset| *offset as usize)
5083            .unwrap_or(self.bytes.len());
5084        &self.bytes[start..end]
5085    }
5086
5087    fn push_encoded_block(&mut self, block: &[u8]) -> usize {
5088        let start = self.bytes.len();
5089        self.offsets.push(start as u32);
5090        self.bytes.extend_from_slice(block);
5091        self.bytes.len() - start
5092    }
5093
5094    fn into_stream(self) -> SharedPositionStream {
5095        SharedPositionStream::new(
5096            PositionStreamCodec::PackedDelta,
5097            self.offsets,
5098            bytes::Bytes::from(self.bytes),
5099        )
5100    }
5101}
5102
5103#[derive(Debug)]
5104pub struct PostingListBuilder {
5105    with_positions: bool,
5106    posting_tail_codec: PostingTailCodec,
5107    encoded_blocks: Option<Box<EncodedBlocks>>,
5108    encoded_position_blocks: Option<Box<EncodedPositionBlocks>>,
5109    tail_entries: Vec<RawDocInfo>,
5110    tail_positions: PositionBlockBuilder,
5111    open_doc_id: Option<u32>,
5112    open_doc_frequency: u32,
5113    open_doc_last_position: Option<u32>,
5114    block_size: usize,
5115    memory_size_bytes: u32,
5116    len: u32,
5117}
5118
5119pub(super) struct PostingListBatchBuilder {
5120    schema: SchemaRef,
5121    postings: ListBuilder<LargeBinaryBuilder>,
5122    impacts: Option<ListBuilder<LargeBinaryBuilder>>,
5123    max_scores: Float32Builder,
5124    lengths: UInt32Builder,
5125    positions: BatchPositionsBuilder,
5126    len: usize,
5127}
5128
5129enum BatchPositionsBuilder {
5130    None,
5131    Legacy(ListBuilder<ListBuilder<LargeBinaryBuilder>>),
5132    Shared {
5133        bytes: LargeBinaryBuilder,
5134        block_offsets: ListBuilder<UInt32Builder>,
5135    },
5136}
5137
5138struct PostingListParts<'a> {
5139    with_positions: bool,
5140    posting_tail_codec: PostingTailCodec,
5141    block_size: usize,
5142    length: usize,
5143    encoded_blocks: EncodedBlocks,
5144    encoded_position_blocks: EncodedPositionBlocks,
5145    tail_entries: &'a [RawDocInfo],
5146    tail_position_block: Option<Vec<u8>>,
5147}
5148
5149impl PostingListBatchBuilder {
5150    pub fn new(
5151        schema: SchemaRef,
5152        with_positions: bool,
5153        format_version: InvertedListFormatVersion,
5154        capacity: usize,
5155    ) -> Self {
5156        let positions = if !with_positions {
5157            BatchPositionsBuilder::None
5158        } else if format_version.uses_shared_position_stream() {
5159            BatchPositionsBuilder::Shared {
5160                bytes: LargeBinaryBuilder::with_capacity(capacity, 0),
5161                block_offsets: ListBuilder::with_capacity(UInt32Builder::new(), capacity),
5162            }
5163        } else {
5164            BatchPositionsBuilder::Legacy(ListBuilder::with_capacity(
5165                ListBuilder::new(LargeBinaryBuilder::new()),
5166                capacity,
5167            ))
5168        };
5169        let impacts = schema
5170            .field_with_name(IMPACT_COL)
5171            .ok()
5172            .map(|_| ListBuilder::with_capacity(LargeBinaryBuilder::new(), capacity));
5173        Self {
5174            schema,
5175            postings: ListBuilder::with_capacity(LargeBinaryBuilder::new(), capacity),
5176            impacts,
5177            max_scores: Float32Builder::with_capacity(capacity),
5178            lengths: UInt32Builder::with_capacity(capacity),
5179            positions,
5180            len: 0,
5181        }
5182    }
5183
5184    pub fn len(&self) -> usize {
5185        self.len
5186    }
5187
5188    pub fn is_empty(&self) -> bool {
5189        self.len == 0
5190    }
5191
5192    fn append(
5193        &mut self,
5194        compressed: LargeBinaryArray,
5195        impacts: Option<&ImpactSkipData>,
5196        max_score: f32,
5197        length: u32,
5198        positions: Option<&CompressedPositionStorage>,
5199    ) -> Result<()> {
5200        {
5201            let values = self.postings.values();
5202            for index in 0..compressed.len() {
5203                values.append_value(compressed.value(index));
5204            }
5205        }
5206        self.postings.append(true);
5207        if let Some(impacts_builder) = &mut self.impacts {
5208            let impacts = impacts.ok_or_else(|| {
5209                Error::index(format!(
5210                    "impacts builder missing impact data for posting length {}",
5211                    length
5212                ))
5213            })?;
5214            let values = impacts_builder.values();
5215            for index in 0..impacts.entries().len() {
5216                values.append_value(impacts.entries().value(index));
5217            }
5218            impacts_builder.append(true);
5219        }
5220        self.max_scores.append_value(max_score);
5221        self.lengths.append_value(length);
5222
5223        match &mut self.positions {
5224            BatchPositionsBuilder::None => {}
5225            BatchPositionsBuilder::Shared {
5226                bytes,
5227                block_offsets,
5228            } => {
5229                let positions = positions.ok_or_else(|| {
5230                    Error::index(format!(
5231                        "positions builder missing position data for posting length {}",
5232                        length
5233                    ))
5234                })?;
5235                let CompressedPositionStorage::SharedStream(positions) = positions else {
5236                    return Err(Error::index(
5237                        "shared positions builder received legacy positions".to_owned(),
5238                    ));
5239                };
5240                bytes.append_value(positions.bytes());
5241                let offsets_builder = block_offsets.values();
5242                for &offset in positions.block_offsets() {
5243                    offsets_builder.append_value(offset);
5244                }
5245                block_offsets.append(true);
5246            }
5247            BatchPositionsBuilder::Legacy(position_lists) => {
5248                let positions = positions.ok_or_else(|| {
5249                    Error::index(format!(
5250                        "positions builder missing position data for posting length {}",
5251                        length
5252                    ))
5253                })?;
5254                let CompressedPositionStorage::LegacyPerDoc(positions) = positions else {
5255                    return Err(Error::index(
5256                        "legacy positions builder received shared position stream".to_owned(),
5257                    ));
5258                };
5259                let docs_builder = position_lists.values();
5260                for doc_idx in 0..positions.len() {
5261                    let doc_positions = positions.value(doc_idx);
5262                    let compressed_positions = doc_positions.as_binary::<i64>();
5263                    for block_idx in 0..compressed_positions.len() {
5264                        docs_builder
5265                            .values()
5266                            .append_value(compressed_positions.value(block_idx));
5267                    }
5268                    docs_builder.append(true);
5269                }
5270                position_lists.append(true);
5271            }
5272        }
5273
5274        self.len += 1;
5275        Ok(())
5276    }
5277
5278    pub fn finish(&mut self) -> Result<RecordBatch> {
5279        let mut columns = vec![
5280            Arc::new(self.postings.finish()) as ArrayRef,
5281            Arc::new(self.max_scores.finish()) as ArrayRef,
5282            Arc::new(self.lengths.finish()) as ArrayRef,
5283        ];
5284        if let Some(impacts) = &mut self.impacts {
5285            columns.push(Arc::new(impacts.finish()) as ArrayRef);
5286        }
5287        match &mut self.positions {
5288            BatchPositionsBuilder::None => {}
5289            BatchPositionsBuilder::Legacy(position_lists) => {
5290                columns.push(Arc::new(position_lists.finish()) as ArrayRef);
5291            }
5292            BatchPositionsBuilder::Shared {
5293                bytes,
5294                block_offsets,
5295            } => {
5296                columns.push(Arc::new(bytes.finish()) as ArrayRef);
5297                columns.push(Arc::new(block_offsets.finish()) as ArrayRef);
5298            }
5299        }
5300        self.len = 0;
5301        RecordBatch::try_new(self.schema.clone(), columns).map_err(Error::from)
5302    }
5303}
5304
5305impl PostingListBuilder {
5306    pub fn size(&self) -> u64 {
5307        self.memory_size_bytes as u64
5308    }
5309
5310    pub fn has_positions(&self) -> bool {
5311        self.with_positions
5312    }
5313
5314    pub fn new(with_position: bool) -> Self {
5315        Self::new_with_posting_tail_codec_and_block_size(
5316            with_position,
5317            current_fts_format_version().posting_tail_codec(),
5318            LEGACY_BLOCK_SIZE,
5319        )
5320    }
5321
5322    pub fn new_with_posting_tail_codec(
5323        with_position: bool,
5324        posting_tail_codec: PostingTailCodec,
5325    ) -> Self {
5326        Self::new_with_posting_tail_codec_and_block_size(
5327            with_position,
5328            posting_tail_codec,
5329            LEGACY_BLOCK_SIZE,
5330        )
5331    }
5332
5333    pub fn new_with_block_size(with_position: bool, block_size: usize) -> Self {
5334        Self::new_with_posting_tail_codec_and_block_size(
5335            with_position,
5336            current_fts_format_version().posting_tail_codec(),
5337            block_size,
5338        )
5339    }
5340
5341    pub fn new_with_posting_tail_codec_and_block_size(
5342        with_position: bool,
5343        posting_tail_codec: PostingTailCodec,
5344        block_size: usize,
5345    ) -> Self {
5346        validate_block_size(block_size).expect("invalid posting list block size");
5347        Self {
5348            with_positions: with_position,
5349            posting_tail_codec,
5350            encoded_blocks: None,
5351            encoded_position_blocks: None,
5352            tail_entries: Vec::new(),
5353            tail_positions: PositionBlockBuilder::default(),
5354            open_doc_id: None,
5355            open_doc_frequency: 0,
5356            open_doc_last_position: None,
5357            block_size,
5358            len: 0,
5359            memory_size_bytes: 0,
5360        }
5361    }
5362
5363    pub fn len(&self) -> usize {
5364        self.len as usize
5365    }
5366
5367    pub fn is_empty(&self) -> bool {
5368        self.len == 0
5369    }
5370
5371    pub fn iter(&self) -> std::vec::IntoIter<(u32, u32, Option<Vec<u32>>)> {
5372        self.collect_entries().into_iter()
5373    }
5374
5375    pub fn for_each_entry<E>(
5376        &self,
5377        mut visit: impl FnMut(u32, u32, Option<Vec<u32>>) -> std::result::Result<(), E>,
5378    ) -> std::result::Result<(), E> {
5379        let mut doc_ids = Vec::with_capacity(self.block_size);
5380        let mut frequencies = Vec::with_capacity(self.block_size);
5381        let mut decoded_positions = Vec::new();
5382        let mut position_block_index = 0usize;
5383
5384        if let Some(encoded_blocks) = self.encoded_blocks.as_deref() {
5385            for block in encoded_blocks.iter() {
5386                doc_ids.clear();
5387                frequencies.clear();
5388                super::encoding::decode_full_posting_block(
5389                    block,
5390                    &mut doc_ids,
5391                    &mut frequencies,
5392                    self.block_size,
5393                );
5394                decoded_positions.clear();
5395                if self.with_positions {
5396                    let position_blocks = self
5397                        .encoded_position_blocks
5398                        .as_deref()
5399                        .expect("positions must exist for posting list");
5400                    super::encoding::decode_position_stream_block(
5401                        position_blocks.block(position_block_index),
5402                        &frequencies,
5403                        PositionStreamCodec::PackedDelta,
5404                        &mut decoded_positions,
5405                    )
5406                    .expect("position stream decoding should succeed");
5407                    position_block_index += 1;
5408                }
5409                let mut offset = 0usize;
5410                for (doc_id, frequency) in doc_ids.iter().copied().zip(frequencies.iter().copied())
5411                {
5412                    let positions = self.with_positions.then(|| {
5413                        let end = offset + frequency as usize;
5414                        let doc_positions = decoded_positions[offset..end].to_vec();
5415                        offset = end;
5416                        doc_positions
5417                    });
5418                    visit(doc_id, frequency, positions)?;
5419                }
5420            }
5421        }
5422
5423        let mut decoded_tail_positions = Vec::new();
5424        if self.with_positions && !self.tail_entries.is_empty() {
5425            let tail_frequencies = self
5426                .tail_entries
5427                .iter()
5428                .map(|entry| entry.frequency)
5429                .collect::<Vec<_>>();
5430            self.tail_positions
5431                .decode_into(tail_frequencies.as_slice(), &mut decoded_tail_positions)
5432                .expect("tail position stream decoding should succeed");
5433        }
5434        let mut tail_offset = 0usize;
5435        for entry in &self.tail_entries {
5436            let positions = self.with_positions.then(|| {
5437                let end = tail_offset + entry.frequency as usize;
5438                let doc_positions = decoded_tail_positions[tail_offset..end].to_vec();
5439                tail_offset = end;
5440                doc_positions
5441            });
5442            visit(entry.doc_id, entry.frequency, positions)?;
5443        }
5444
5445        Ok(())
5446    }
5447
5448    pub fn add(&mut self, doc_id: u32, term_positions: PositionRecorder) {
5449        debug_assert!(
5450            self.open_doc_id.is_none(),
5451            "cannot add closed doc while a positions doc is still open"
5452        );
5453        let tail_entries_capacity_before = self.tail_entries.capacity();
5454        self.tail_entries
5455            .push(RawDocInfo::new(doc_id, term_positions.len()));
5456        let tail_entries_capacity_after = self.tail_entries.capacity();
5457        if tail_entries_capacity_after > tail_entries_capacity_before {
5458            self.add_memory_bytes(
5459                (tail_entries_capacity_after - tail_entries_capacity_before)
5460                    * std::mem::size_of::<RawDocInfo>(),
5461            );
5462        }
5463        if let PositionRecorder::Position(positions_in_doc) = term_positions {
5464            debug_assert!(self.with_positions);
5465            let old_size = self.tail_positions.size();
5466            self.tail_positions
5467                .append_doc_positions(positions_in_doc.as_slice())
5468                .expect("position stream encoding should succeed");
5469            self.adjust_tail_positions_size(old_size);
5470        }
5471        self.len += 1;
5472
5473        if self.tail_entries.len() == self.block_size {
5474            self.flush_tail_block()
5475                .expect("posting list block compression should succeed");
5476        }
5477    }
5478
5479    pub fn add_occurrence(&mut self, doc_id: u32, position: u32) -> Result<bool> {
5480        if !self.with_positions {
5481            return Err(Error::index(
5482                "cannot append streamed positions to a posting list without positions".to_owned(),
5483            ));
5484        }
5485
5486        match self.open_doc_id {
5487            Some(open_doc_id) if open_doc_id == doc_id => {
5488                let old_size = self.tail_positions.size();
5489                self.tail_positions
5490                    .append_position(position, self.open_doc_last_position)?;
5491                self.adjust_tail_positions_size(old_size);
5492                self.open_doc_frequency += 1;
5493                self.open_doc_last_position = Some(position);
5494                Ok(false)
5495            }
5496            Some(open_doc_id) => Err(Error::index(format!(
5497                "posting list received doc {} before finishing open doc {}",
5498                doc_id, open_doc_id
5499            ))),
5500            None => {
5501                let old_size = self.tail_positions.size();
5502                self.tail_positions.append_position(position, None)?;
5503                self.adjust_tail_positions_size(old_size);
5504                self.open_doc_id = Some(doc_id);
5505                self.open_doc_frequency = 1;
5506                self.open_doc_last_position = Some(position);
5507                self.len += 1;
5508                Ok(true)
5509            }
5510        }
5511    }
5512
5513    pub fn finish_open_doc(&mut self, doc_id: u32) -> Result<()> {
5514        if !self.with_positions {
5515            return Ok(());
5516        }
5517        match self.open_doc_id {
5518            Some(open_doc_id) if open_doc_id == doc_id => {
5519                let tail_entries_capacity_before = self.tail_entries.capacity();
5520                self.tail_entries
5521                    .push(RawDocInfo::new(doc_id, self.open_doc_frequency));
5522                let tail_entries_capacity_after = self.tail_entries.capacity();
5523                if tail_entries_capacity_after > tail_entries_capacity_before {
5524                    self.add_memory_bytes(
5525                        (tail_entries_capacity_after - tail_entries_capacity_before)
5526                            * std::mem::size_of::<RawDocInfo>(),
5527                    );
5528                }
5529                self.open_doc_id = None;
5530                self.open_doc_frequency = 0;
5531                self.open_doc_last_position = None;
5532                if self.tail_entries.len() == self.block_size {
5533                    self.flush_tail_block()?;
5534                }
5535                Ok(())
5536            }
5537            Some(open_doc_id) => Err(Error::index(format!(
5538                "attempted to finish doc {} while doc {} is still open",
5539                doc_id, open_doc_id
5540            ))),
5541            None => Ok(()),
5542        }
5543    }
5544
5545    fn collect_entries(&self) -> Vec<(u32, u32, Option<Vec<u32>>)> {
5546        let mut entries = Vec::with_capacity(self.len());
5547        self.for_each_entry(|doc_id, frequency, positions| {
5548            entries.push((doc_id, frequency, positions));
5549            Ok::<(), ()>(())
5550        })
5551        .expect("collecting posting list entries should not fail");
5552        entries
5553    }
5554
5555    fn encoded_blocks_mut(&mut self) -> &mut EncodedBlocks {
5556        if self.encoded_blocks.is_none() {
5557            self.encoded_blocks = Some(Box::default());
5558            self.add_memory_bytes(std::mem::size_of::<EncodedBlocks>());
5559        }
5560        self.encoded_blocks
5561            .as_deref_mut()
5562            .expect("encoded blocks must exist")
5563    }
5564
5565    fn encoded_position_blocks_mut(&mut self) -> &mut EncodedPositionBlocks {
5566        if self.encoded_position_blocks.is_none() {
5567            self.encoded_position_blocks = Some(Box::default());
5568            self.add_memory_bytes(std::mem::size_of::<EncodedPositionBlocks>());
5569        }
5570        self.encoded_position_blocks
5571            .as_deref_mut()
5572            .expect("encoded position blocks must exist")
5573    }
5574
5575    fn flush_tail_block(&mut self) -> Result<()> {
5576        if self.tail_entries.is_empty() {
5577            return Ok(());
5578        }
5579        debug_assert!(
5580            self.open_doc_id.is_none(),
5581            "cannot flush a posting block while a document is still open"
5582        );
5583        debug_assert_eq!(self.tail_entries.len(), self.block_size);
5584        let doc_ids = self
5585            .tail_entries
5586            .iter()
5587            .map(|entry| entry.doc_id)
5588            .collect::<Vec<_>>();
5589        let frequencies = self
5590            .tail_entries
5591            .iter()
5592            .map(|entry| entry.frequency)
5593            .collect::<Vec<_>>();
5594        let encoded_blocks_size_before = self
5595            .encoded_blocks
5596            .as_ref()
5597            .map(|encoded_blocks| encoded_blocks.size())
5598            .unwrap_or(0usize);
5599        self.encoded_blocks_mut()
5600            .push_full_block(&doc_ids, &frequencies)?;
5601        let encoded_blocks_size_after = self
5602            .encoded_blocks
5603            .as_ref()
5604            .map(|encoded_blocks| encoded_blocks.size())
5605            .unwrap_or(0usize);
5606        if encoded_blocks_size_after > encoded_blocks_size_before {
5607            self.add_memory_bytes(encoded_blocks_size_after - encoded_blocks_size_before);
5608        }
5609        if self.with_positions {
5610            let encoded_positions_size_before = self
5611                .encoded_position_blocks
5612                .as_ref()
5613                .map(|encoded| encoded.size())
5614                .unwrap_or(0usize);
5615            let released_tail_positions_bytes = self.tail_positions.size();
5616            let tail_position_block = std::mem::take(&mut self.tail_positions).finish();
5617            self.encoded_position_blocks_mut()
5618                .push_encoded_block(tail_position_block.as_slice());
5619            let encoded_positions_size_after = self
5620                .encoded_position_blocks
5621                .as_ref()
5622                .map(|encoded| encoded.size())
5623                .unwrap_or(0usize);
5624            if released_tail_positions_bytes > 0 {
5625                self.subtract_memory_bytes(released_tail_positions_bytes);
5626            }
5627            if encoded_positions_size_after > encoded_positions_size_before {
5628                self.add_memory_bytes(encoded_positions_size_after - encoded_positions_size_before);
5629            }
5630        }
5631        self.tail_entries.clear();
5632        Ok(())
5633    }
5634
5635    fn adjust_tail_positions_size(&mut self, old_size: usize) {
5636        let new_size = self.tail_positions.size();
5637        if new_size > old_size {
5638            self.add_memory_bytes(new_size - old_size);
5639        } else if old_size > new_size {
5640            self.subtract_memory_bytes(old_size - new_size);
5641        }
5642    }
5643
5644    fn add_memory_bytes(&mut self, bytes: usize) {
5645        self.memory_size_bytes = self
5646            .memory_size_bytes
5647            .checked_add(
5648                u32::try_from(bytes).expect("posting list memory size delta overflowed u32"),
5649            )
5650            .expect("posting list memory size overflowed u32");
5651    }
5652
5653    fn subtract_memory_bytes(&mut self, bytes: usize) {
5654        self.memory_size_bytes = self
5655            .memory_size_bytes
5656            .checked_sub(
5657                u32::try_from(bytes).expect("posting list memory size delta overflowed u32"),
5658            )
5659            .expect("posting list memory size underflowed u32");
5660    }
5661
5662    fn build_position_columns(
5663        positions: Option<CompressedPositionStorage>,
5664    ) -> Result<Vec<ArrayRef>> {
5665        let Some(positions) = positions else {
5666            return Ok(Vec::new());
5667        };
5668        match positions {
5669            CompressedPositionStorage::LegacyPerDoc(positions) => {
5670                Ok(vec![Arc::new(ListArray::try_new(
5671                    Arc::new(Field::new("item", positions.data_type().clone(), true)),
5672                    OffsetBuffer::new(ScalarBuffer::from(vec![0_i32, positions.len() as i32])),
5673                    Arc::new(positions) as ArrayRef,
5674                    None,
5675                )?) as ArrayRef])
5676            }
5677            CompressedPositionStorage::SharedStream(positions) => {
5678                let mut columns = Vec::with_capacity(2);
5679                columns.push(
5680                    Arc::new(LargeBinaryArray::from(vec![Some(positions.bytes())])) as ArrayRef,
5681                );
5682
5683                let mut offsets_builder = ListBuilder::new(UInt32Builder::new());
5684                for &offset in positions.block_offsets() {
5685                    offsets_builder.values().append_value(offset);
5686                }
5687                offsets_builder.append(true);
5688                columns.push(Arc::new(offsets_builder.finish()) as ArrayRef);
5689                Ok(columns)
5690            }
5691        }
5692    }
5693
5694    fn build_batch(
5695        self,
5696        compressed: LargeBinaryArray,
5697        impacts: Option<ImpactSkipData>,
5698        max_score: f32,
5699        schema: SchemaRef,
5700        positions: Option<CompressedPositionStorage>,
5701    ) -> Result<RecordBatch> {
5702        let length = self.len();
5703        let offsets = OffsetBuffer::new(ScalarBuffer::from(vec![0, compressed.len() as i32]));
5704        let mut columns = vec![
5705            Arc::new(ListArray::try_new(
5706                Arc::new(Field::new("item", datatypes::DataType::LargeBinary, true)),
5707                offsets,
5708                Arc::new(compressed),
5709                None,
5710            )?) as ArrayRef,
5711            Arc::new(Float32Array::from_iter_values(std::iter::once(max_score))) as ArrayRef,
5712            Arc::new(UInt32Array::from_iter_values(std::iter::once(
5713                length as u32,
5714            ))) as ArrayRef,
5715        ];
5716        if schema.field_with_name(IMPACT_COL).is_ok() {
5717            let impacts = impacts.ok_or_else(|| {
5718                Error::index(format!(
5719                    "impact column requested without impact data for posting length {}",
5720                    length
5721                ))
5722            })?;
5723            let impact_offsets =
5724                OffsetBuffer::new(ScalarBuffer::from(vec![0, impacts.entries().len() as i32]));
5725            columns.push(Arc::new(ListArray::try_new(
5726                Arc::new(Field::new("item", datatypes::DataType::LargeBinary, true)),
5727                impact_offsets,
5728                Arc::new(impacts.entries().clone()),
5729                None,
5730            )?) as ArrayRef);
5731        }
5732        columns.extend(Self::build_position_columns(positions)?);
5733
5734        let batch = RecordBatch::try_new(schema, columns)?;
5735        Ok(batch)
5736    }
5737
5738    fn build_legacy_positions(&self) -> Result<ListArray> {
5739        let mut positions_builder = ListBuilder::new(LargeBinaryBuilder::new());
5740        self.for_each_entry(|_doc_id, frequency, positions| {
5741            let positions = positions.ok_or_else(|| {
5742                Error::index(format!(
5743                    "legacy position writer missing positions for frequency {}",
5744                    frequency
5745                ))
5746            })?;
5747            let compressed = super::encoding::compress_positions(positions.as_slice())?;
5748            for block_idx in 0..compressed.len() {
5749                positions_builder
5750                    .values()
5751                    .append_value(compressed.value(block_idx));
5752            }
5753            positions_builder.append(true);
5754            Ok::<(), Error>(())
5755        })?;
5756        Ok(positions_builder.finish())
5757    }
5758
5759    pub(super) fn append_to_batch_with_docs(
5760        self,
5761        docs: &DocSet,
5762        batch_builder: &mut PostingListBatchBuilder,
5763        format_version: InvertedListFormatVersion,
5764    ) -> Result<()> {
5765        let legacy_positions =
5766            if self.with_positions && !format_version.uses_shared_position_stream() {
5767                Some(self.build_legacy_positions()?)
5768            } else {
5769                None
5770            };
5771        let Self {
5772            with_positions,
5773            posting_tail_codec,
5774            encoded_blocks,
5775            encoded_position_blocks,
5776            tail_entries,
5777            tail_positions,
5778            open_doc_id,
5779            open_doc_frequency,
5780            open_doc_last_position,
5781            block_size,
5782            len,
5783            ..
5784        } = self;
5785        debug_assert!(open_doc_id.is_none());
5786        debug_assert_eq!(open_doc_frequency, 0);
5787        debug_assert!(open_doc_last_position.is_none());
5788        let parts = PostingListParts {
5789            with_positions,
5790            posting_tail_codec,
5791            block_size,
5792            length: len as usize,
5793            encoded_blocks: encoded_blocks
5794                .map(|encoded_blocks| *encoded_blocks)
5795                .unwrap_or_default(),
5796            encoded_position_blocks: encoded_position_blocks
5797                .map(|encoded_positions| *encoded_positions)
5798                .unwrap_or_default(),
5799            tail_entries: tail_entries.as_slice(),
5800            tail_position_block: with_positions.then(|| tail_positions.finish()),
5801        };
5802        let (compressed, shared_positions, max_score, impacts) =
5803            Self::build_compressed_with_scores_from_parts(parts, docs)?;
5804        let positions = match legacy_positions {
5805            Some(positions) => Some(CompressedPositionStorage::LegacyPerDoc(positions)),
5806            None => shared_positions.map(CompressedPositionStorage::SharedStream),
5807        };
5808        batch_builder.append(
5809            compressed,
5810            Some(&impacts),
5811            max_score,
5812            len,
5813            positions.as_ref(),
5814        )
5815    }
5816
5817    fn extend_tail_components(
5818        tail_entries: &[RawDocInfo],
5819        doc_ids: &mut Vec<u32>,
5820        frequencies: &mut Vec<u32>,
5821    ) {
5822        doc_ids.clear();
5823        frequencies.clear();
5824        doc_ids.extend(tail_entries.iter().map(|entry| entry.doc_id));
5825        frequencies.extend(tail_entries.iter().map(|entry| entry.frequency));
5826    }
5827
5828    fn build_compressed_with_scores_from_parts(
5829        parts: PostingListParts<'_>,
5830        docs: &DocSet,
5831    ) -> Result<(
5832        LargeBinaryArray,
5833        Option<SharedPositionStream>,
5834        f32,
5835        ImpactSkipData,
5836    )> {
5837        let PostingListParts {
5838            with_positions,
5839            posting_tail_codec,
5840            length,
5841            block_size,
5842            mut encoded_blocks,
5843            mut encoded_position_blocks,
5844            tail_entries,
5845            tail_position_block,
5846        } = parts;
5847        let avgdl = docs.average_length();
5848        let idf_scale = idf(length, docs.len()) * (K1 + 1.0);
5849        let mut max_score = f32::MIN;
5850        let mut doc_ids = Vec::with_capacity(block_size);
5851        let mut frequencies = Vec::with_capacity(block_size);
5852        let mut impact_block = Vec::with_capacity(block_size);
5853        let mut impact_builder =
5854            ImpactSkipDataBuilder::with_capacity(length.div_ceil(block_size), block_size);
5855
5856        for index in 0..encoded_blocks.len() {
5857            let block = encoded_blocks.block(index);
5858            doc_ids.clear();
5859            frequencies.clear();
5860            super::encoding::decode_full_posting_block(
5861                block,
5862                &mut doc_ids,
5863                &mut frequencies,
5864                block_size,
5865            );
5866            let block_score = compute_block_score_and_impact_block(
5867                docs,
5868                avgdl,
5869                idf_scale,
5870                doc_ids.iter().copied(),
5871                frequencies.iter().copied(),
5872                &mut impact_block,
5873            );
5874            impact_builder.append_block(impact_block.as_slice())?;
5875            max_score = max_score.max(block_score);
5876            if super::encoding::posting_block_score_prefix_len(block_size) > 0 {
5877                encoded_blocks.set_block_score(index, block_score);
5878            }
5879        }
5880
5881        if !tail_entries.is_empty() {
5882            Self::extend_tail_components(tail_entries, &mut doc_ids, &mut frequencies);
5883            let block_score = compute_block_score_and_impact_block(
5884                docs,
5885                avgdl,
5886                idf_scale,
5887                doc_ids.iter().copied(),
5888                frequencies.iter().copied(),
5889                &mut impact_block,
5890            );
5891            impact_builder.append_block(impact_block.as_slice())?;
5892            max_score = max_score.max(block_score);
5893            encoded_blocks.append_remainder_block_with_codec(
5894                doc_ids.as_slice(),
5895                frequencies.as_slice(),
5896                posting_tail_codec,
5897                block_size,
5898            )?;
5899            if super::encoding::posting_block_score_prefix_len(block_size) > 0 {
5900                encoded_blocks.set_block_score(encoded_blocks.len() - 1, block_score);
5901            }
5902            if with_positions {
5903                encoded_position_blocks.push_encoded_block(
5904                    tail_position_block
5905                        .as_deref()
5906                        .expect("tail position block must exist for postings with positions"),
5907                );
5908            }
5909        }
5910
5911        let impacts = impact_builder.finish()?;
5912        Ok((
5913            encoded_blocks.into_array(),
5914            with_positions.then(|| encoded_position_blocks.into_stream()),
5915            max_score,
5916            impacts,
5917        ))
5918    }
5919
5920    #[allow(clippy::too_many_arguments)]
5921    fn build_compressed_with_block_scores_from_parts(
5922        with_positions: bool,
5923        posting_tail_codec: PostingTailCodec,
5924        block_size: usize,
5925        mut encoded_blocks: EncodedBlocks,
5926        mut encoded_position_blocks: EncodedPositionBlocks,
5927        tail_entries: &[RawDocInfo],
5928        tail_position_block: Option<Vec<u8>>,
5929        mut block_max_scores: impl Iterator<Item = f32>,
5930    ) -> Result<(LargeBinaryArray, Option<SharedPositionStream>, f32)> {
5931        let has_score_prefix = super::encoding::posting_block_score_prefix_len(block_size) > 0;
5932        let mut max_score = f32::MIN;
5933        let mut doc_ids = Vec::with_capacity(BLOCK_SIZE);
5934        let mut frequencies = Vec::with_capacity(BLOCK_SIZE);
5935
5936        for index in 0..encoded_blocks.len() {
5937            let block_score = block_max_scores
5938                .next()
5939                .ok_or_else(|| Error::index("missing block max score".to_owned()))?;
5940            max_score = max_score.max(block_score);
5941            if has_score_prefix {
5942                encoded_blocks.set_block_score(index, block_score);
5943            }
5944        }
5945
5946        if !tail_entries.is_empty() {
5947            let block_score = block_max_scores
5948                .next()
5949                .ok_or_else(|| Error::index("missing tail block max score".to_owned()))?;
5950            max_score = max_score.max(block_score);
5951            Self::extend_tail_components(tail_entries, &mut doc_ids, &mut frequencies);
5952            encoded_blocks.append_remainder_block_with_codec(
5953                doc_ids.as_slice(),
5954                frequencies.as_slice(),
5955                posting_tail_codec,
5956                block_size,
5957            )?;
5958            if has_score_prefix {
5959                encoded_blocks.set_block_score(encoded_blocks.len() - 1, block_score);
5960            }
5961            if with_positions {
5962                encoded_position_blocks.push_encoded_block(
5963                    tail_position_block
5964                        .as_deref()
5965                        .expect("tail position block must exist for postings with positions"),
5966                );
5967            }
5968        }
5969
5970        Ok((
5971            encoded_blocks.into_array(),
5972            with_positions.then(|| encoded_position_blocks.into_stream()),
5973            max_score,
5974        ))
5975    }
5976
5977    pub fn to_batch(self, block_max_scores: Vec<f32>) -> Result<RecordBatch> {
5978        let format_version = InvertedListFormatVersion::from_posting_tail_codec_and_block_size(
5979            self.posting_tail_codec,
5980            self.block_size,
5981        )?;
5982        let schema = inverted_list_schema_for_version_with_block_size_and_impacts(
5983            self.has_positions(),
5984            format_version,
5985            self.block_size,
5986            false,
5987        );
5988        let legacy_positions =
5989            if self.with_positions && !format_version.uses_shared_position_stream() {
5990                Some(self.build_legacy_positions()?)
5991            } else {
5992                None
5993            };
5994        let Self {
5995            with_positions,
5996            posting_tail_codec,
5997            encoded_blocks,
5998            encoded_position_blocks,
5999            tail_entries,
6000            tail_positions,
6001            open_doc_id,
6002            open_doc_frequency,
6003            open_doc_last_position,
6004            block_size,
6005            len,
6006            ..
6007        } = self;
6008        debug_assert!(open_doc_id.is_none());
6009        debug_assert_eq!(open_doc_frequency, 0);
6010        debug_assert!(open_doc_last_position.is_none());
6011        let (compressed, shared_positions, max_score) =
6012            Self::build_compressed_with_block_scores_from_parts(
6013                with_positions,
6014                posting_tail_codec,
6015                block_size,
6016                encoded_blocks
6017                    .map(|encoded_blocks| *encoded_blocks)
6018                    .unwrap_or_default(),
6019                encoded_position_blocks
6020                    .map(|encoded_positions| *encoded_positions)
6021                    .unwrap_or_default(),
6022                tail_entries.as_slice(),
6023                with_positions.then(|| tail_positions.finish()),
6024                block_max_scores.into_iter(),
6025            )?;
6026        let builder = Self {
6027            with_positions,
6028            posting_tail_codec,
6029            encoded_blocks: None,
6030            encoded_position_blocks: None,
6031            tail_entries: Vec::new(),
6032            tail_positions: PositionBlockBuilder::default(),
6033            open_doc_id: None,
6034            open_doc_frequency: 0,
6035            open_doc_last_position: None,
6036            block_size,
6037            memory_size_bytes: 0,
6038            len,
6039        };
6040        let positions = match legacy_positions {
6041            Some(positions) => Some(CompressedPositionStorage::LegacyPerDoc(positions)),
6042            None => shared_positions.map(CompressedPositionStorage::SharedStream),
6043        };
6044        builder.build_batch(compressed, None, max_score, schema, positions)
6045    }
6046
6047    pub fn to_batch_with_docs(self, docs: &DocSet, schema: SchemaRef) -> Result<RecordBatch> {
6048        let format_version = parse_format_version_from_metadata(schema.metadata())?;
6049        let legacy_positions =
6050            if self.with_positions && !format_version.uses_shared_position_stream() {
6051                Some(self.build_legacy_positions()?)
6052            } else {
6053                None
6054            };
6055        let Self {
6056            with_positions,
6057            posting_tail_codec,
6058            encoded_blocks,
6059            encoded_position_blocks,
6060            tail_entries,
6061            tail_positions,
6062            open_doc_id,
6063            open_doc_frequency,
6064            open_doc_last_position,
6065            block_size,
6066            len,
6067            ..
6068        } = self;
6069        debug_assert!(open_doc_id.is_none());
6070        debug_assert_eq!(open_doc_frequency, 0);
6071        debug_assert!(open_doc_last_position.is_none());
6072        let parts = PostingListParts {
6073            with_positions,
6074            posting_tail_codec,
6075            block_size,
6076            length: len as usize,
6077            encoded_blocks: encoded_blocks
6078                .map(|encoded_blocks| *encoded_blocks)
6079                .unwrap_or_default(),
6080            encoded_position_blocks: encoded_position_blocks
6081                .map(|encoded_positions| *encoded_positions)
6082                .unwrap_or_default(),
6083            tail_entries: tail_entries.as_slice(),
6084            tail_position_block: with_positions.then(|| tail_positions.finish()),
6085        };
6086        let (compressed, shared_positions, max_score, impacts) =
6087            Self::build_compressed_with_scores_from_parts(parts, docs)?;
6088        let builder = Self {
6089            with_positions,
6090            posting_tail_codec,
6091            encoded_blocks: None,
6092            encoded_position_blocks: None,
6093            tail_entries: Vec::new(),
6094            tail_positions: PositionBlockBuilder::default(),
6095            open_doc_id: None,
6096            open_doc_frequency: 0,
6097            open_doc_last_position: None,
6098            block_size,
6099            memory_size_bytes: 0,
6100            len,
6101        };
6102        let positions = match legacy_positions {
6103            Some(positions) => Some(CompressedPositionStorage::LegacyPerDoc(positions)),
6104            None => shared_positions.map(CompressedPositionStorage::SharedStream),
6105        };
6106        builder.build_batch(compressed, Some(impacts), max_score, schema, positions)
6107    }
6108
6109    pub fn remap(&mut self, removed: &[u32]) {
6110        let mut cursor = 0;
6111        let mut new_builder = Self::new_with_posting_tail_codec_and_block_size(
6112            self.has_positions(),
6113            self.posting_tail_codec,
6114            self.block_size,
6115        );
6116        for (doc_id, freq, positions) in self.iter() {
6117            while cursor < removed.len() && removed[cursor] < doc_id {
6118                cursor += 1;
6119            }
6120            if cursor < removed.len() && removed[cursor] == doc_id {
6121                continue;
6122            }
6123            let positions = match positions {
6124                Some(positions) => PositionRecorder::Position(positions.into()),
6125                None => PositionRecorder::Count(freq),
6126            };
6127            new_builder.add(doc_id - cursor as u32, positions);
6128        }
6129
6130        *self = new_builder;
6131    }
6132}
6133
6134fn compute_block_score_and_impact_block(
6135    docs: &DocSet,
6136    avgdl: f32,
6137    idf_scale: f32,
6138    doc_ids: impl Iterator<Item = u32>,
6139    frequencies: impl Iterator<Item = u32>,
6140    impact_block: &mut Vec<(u32, u32, u32)>,
6141) -> f32 {
6142    impact_block.clear();
6143    let mut block_max_score = f32::MIN;
6144    for (doc_id, freq) in doc_ids.zip(frequencies) {
6145        let doc_len = docs.num_tokens(doc_id);
6146        let doc_norm = K1 * (1.0 - B + B * doc_len as f32 / avgdl);
6147        let freq_f32 = freq as f32;
6148        let score = freq_f32 / (freq_f32 + doc_norm);
6149        block_max_score = block_max_score.max(score);
6150        impact_block.push((doc_id, freq, doc_len));
6151    }
6152    block_max_score * idf_scale
6153}
6154
6155#[derive(Debug, Clone, DeepSizeOf, Copy)]
6156pub enum DocInfo {
6157    Located(LocatedDocInfo),
6158    Raw(RawDocInfo),
6159}
6160
6161impl DocInfo {
6162    pub fn doc_id(&self) -> u64 {
6163        match self {
6164            Self::Raw(info) => info.doc_id as u64,
6165            Self::Located(info) => info.row_id,
6166        }
6167    }
6168
6169    pub fn frequency(&self) -> u32 {
6170        match self {
6171            Self::Raw(info) => info.frequency,
6172            Self::Located(info) => info.frequency as u32,
6173        }
6174    }
6175}
6176
6177impl Eq for DocInfo {}
6178
6179impl PartialEq for DocInfo {
6180    fn eq(&self, other: &Self) -> bool {
6181        self.doc_id() == other.doc_id()
6182    }
6183}
6184
6185impl PartialOrd for DocInfo {
6186    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
6187        Some(self.cmp(other))
6188    }
6189}
6190
6191impl Ord for DocInfo {
6192    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
6193        self.doc_id().cmp(&other.doc_id())
6194    }
6195}
6196
6197#[derive(Debug, Clone, Default, DeepSizeOf, Copy)]
6198pub struct LocatedDocInfo {
6199    pub row_id: u64,
6200    pub frequency: f32,
6201}
6202
6203impl LocatedDocInfo {
6204    pub fn new(row_id: u64, frequency: f32) -> Self {
6205        Self { row_id, frequency }
6206    }
6207}
6208
6209impl Eq for LocatedDocInfo {}
6210
6211impl PartialEq for LocatedDocInfo {
6212    fn eq(&self, other: &Self) -> bool {
6213        self.row_id == other.row_id
6214    }
6215}
6216
6217impl PartialOrd for LocatedDocInfo {
6218    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
6219        Some(self.cmp(other))
6220    }
6221}
6222
6223impl Ord for LocatedDocInfo {
6224    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
6225        self.row_id.cmp(&other.row_id)
6226    }
6227}
6228
6229#[derive(Debug, Clone, Default, DeepSizeOf, Copy)]
6230pub struct RawDocInfo {
6231    pub doc_id: u32,
6232    pub frequency: u32,
6233}
6234
6235impl RawDocInfo {
6236    pub fn new(doc_id: u32, frequency: u32) -> Self {
6237        Self { doc_id, frequency }
6238    }
6239}
6240
6241impl Eq for RawDocInfo {}
6242
6243impl PartialEq for RawDocInfo {
6244    fn eq(&self, other: &Self) -> bool {
6245        self.doc_id == other.doc_id
6246    }
6247}
6248
6249impl PartialOrd for RawDocInfo {
6250    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
6251        Some(self.cmp(other))
6252    }
6253}
6254
6255impl Ord for RawDocInfo {
6256    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
6257        self.doc_id.cmp(&other.doc_id)
6258    }
6259}
6260
6261/// Lucene SmallFloat-style document-length quantization for 256-document-block scoring and impact
6262/// norms: a 4-mantissa-bit float-like byte code. Values 0-7 are exact; larger
6263/// values keep their top four significand bits (relative error <= 6.25%) and
6264/// decode to their bucket floor. The floor only ever shortens a doc, so impact
6265/// bounds remain conservative for exact scoring as well as quantized scoring.
6266pub(super) fn quantize_doc_length(value: u32) -> u8 {
6267    let num_bits = 32 - value.leading_zeros();
6268    if num_bits < 4 {
6269        value as u8
6270    } else {
6271        let shift = num_bits - 4;
6272        (((value >> shift) as u8) & 0x07) | (((shift + 1) as u8) << 3)
6273    }
6274}
6275
6276#[inline]
6277pub(super) fn dequantize_doc_length(code: u8) -> u32 {
6278    DEQUANTIZED_DOC_LENGTHS[code as usize]
6279}
6280
6281pub(super) static DEQUANTIZED_DOC_LENGTHS: [u32; 256] = build_dequantized_doc_lengths();
6282
6283const fn build_dequantized_doc_lengths() -> [u32; 256] {
6284    let mut table = [0u32; 256];
6285    let mut code = 0usize;
6286    while code < 256 {
6287        let bits = (code & 0x07) as u64;
6288        let shift = (code >> 3) as i64 - 1;
6289        let decoded = if shift < 0 {
6290            bits
6291        } else {
6292            (bits | 0x08) << shift
6293        };
6294        // Codes past the largest u32 encoding are never produced; saturate so
6295        // the table stays total.
6296        table[code] = if decoded > u32::MAX as u64 {
6297            u32::MAX
6298        } else {
6299            decoded as u32
6300        };
6301        code += 1;
6302    }
6303    table
6304}
6305
6306#[derive(Debug, Clone)]
6307enum NumTokens {
6308    Owned(Vec<u32>),
6309    Shared(ScalarBuffer<u32>),
6310}
6311
6312impl Default for NumTokens {
6313    fn default() -> Self {
6314        Self::Owned(Vec::new())
6315    }
6316}
6317
6318impl std::ops::Deref for NumTokens {
6319    type Target = [u32];
6320
6321    fn deref(&self) -> &Self::Target {
6322        match self {
6323            Self::Owned(values) => values,
6324            Self::Shared(values) => values,
6325        }
6326    }
6327}
6328
6329impl DeepSizeOf for NumTokens {
6330    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
6331        match self {
6332            Self::Owned(values) => values.deep_size_of_children(context),
6333            Self::Shared(values) => values.deep_size_of_children(context),
6334        }
6335    }
6336}
6337
6338impl NumTokens {
6339    fn with_capacity(capacity: usize) -> Self {
6340        Self::Owned(Vec::with_capacity(capacity))
6341    }
6342
6343    fn into_owned(self) -> Vec<u32> {
6344        match self {
6345            Self::Owned(values) => values,
6346            Self::Shared(values) => values.to_vec(),
6347        }
6348    }
6349
6350    fn push(&mut self, value: u32) {
6351        match self {
6352            Self::Owned(values) => values.push(value),
6353            Self::Shared(values) => {
6354                let mut owned = values.to_vec();
6355                owned.push(value);
6356                *self = Self::Owned(owned);
6357            }
6358        }
6359    }
6360
6361    fn memory_size(&self) -> usize {
6362        match self {
6363            Self::Owned(values) => values.capacity() * std::mem::size_of::<u32>(),
6364            Self::Shared(values) => values.inner().capacity(),
6365        }
6366    }
6367}
6368
6369// DocSet is a mapping from row ids to the number of tokens in the document
6370// It's used to sort the documents by the bm25 score
6371#[derive(Debug, Clone, Default)]
6372pub struct DocSet {
6373    row_ids: Vec<u64>,
6374    num_tokens: NumTokens,
6375    // (row_id, doc_id) pairs sorted by row_id
6376    inv: Vec<(u64, u32)>,
6377
6378    total_tokens: u64,
6379
6380    // 256-document-block partitions score with quantized document lengths: the
6381    // flag is set at partition load and the byte-norm slab bakes lazily on
6382    // first scoring use (shared by clones of the loaded set). 128-block
6383    // partitions never set the flag and keep exact scoring.
6384    scoring_quantized: bool,
6385    norms: Arc<std::sync::OnceLock<Box<[u8]>>>,
6386}
6387
6388impl DeepSizeOf for DocSet {
6389    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
6390        self.row_ids.deep_size_of_children(context)
6391            + self.num_tokens.deep_size_of_children(context)
6392            + self.inv.deep_size_of_children(context)
6393            + self
6394                .norms
6395                .get()
6396                .map(|slab| std::mem::size_of_val(slab.as_ref()))
6397                .unwrap_or(0)
6398    }
6399}
6400
6401impl DocSet {
6402    #[inline]
6403    pub fn len(&self) -> usize {
6404        // Use num_tokens instead of row_ids so the deferred-row_ids
6405        // scoring path (which constructs a DocSet via
6406        // [`Self::from_num_tokens_only`]) still reports the right doc
6407        // count.
6408        self.num_tokens.len()
6409    }
6410
6411    pub fn is_empty(&self) -> bool {
6412        self.len() == 0
6413    }
6414
6415    /// True iff the per-doc `row_id` array is populated. The
6416    /// deferred-row_id scoring path constructs DocSets with the array
6417    /// left empty so wand can skip the load; callers that need to do
6418    /// row_id lookups in the inner loop must check this and fall back
6419    /// to async resolution otherwise.
6420    #[inline]
6421    pub fn has_row_ids(&self) -> bool {
6422        !self.row_ids.is_empty()
6423    }
6424
6425    pub fn iter(&self) -> impl Iterator<Item = (&u64, &u32)> {
6426        self.row_ids.iter().zip(self.num_tokens.iter())
6427    }
6428
6429    pub fn row_id(&self, doc_id: u32) -> u64 {
6430        self.row_ids[doc_id as usize]
6431    }
6432
6433    /// Resolve a `row_id` to every `doc_id` it owns.
6434    ///
6435    /// Modern indexes map each row to a single document. Older list indexes
6436    /// may have indexed each list element as its own document, so a single
6437    /// `row_id` can still own several `doc_id`s sharing that key in `inv`.
6438    /// The prefilter path (`flat_search`) walks an allow-list of row_ids and
6439    /// must evaluate all legacy documents for that row.
6440    pub fn doc_ids(&self, row_id: u64) -> impl Iterator<Item = u64> + '_ {
6441        if self.inv.is_empty() {
6442            // in legacy format, the row id is doc id (one document per row)
6443            let found = self.row_ids.binary_search(&row_id).is_ok();
6444            Either::Left(found.then_some(row_id).into_iter())
6445        } else {
6446            // `inv` is sorted by row_id, so the entries sharing this key form a
6447            // contiguous run; yield the doc_id of each.
6448            let lo = self.inv.partition_point(|entry| entry.0 < row_id);
6449            let hi = self.inv.partition_point(|entry| entry.0 <= row_id);
6450            Either::Right(self.inv[lo..hi].iter().map(|entry| entry.1 as u64))
6451        }
6452    }
6453    pub fn total_tokens_num(&self) -> u64 {
6454        self.total_tokens
6455    }
6456
6457    #[inline]
6458    pub fn average_length(&self) -> f32 {
6459        self.total_tokens as f32 / self.len() as f32
6460    }
6461
6462    pub fn calculate_block_max_scores<'a>(
6463        &self,
6464        doc_ids: impl Iterator<Item = &'a u32>,
6465        freqs: impl Iterator<Item = &'a u32>,
6466    ) -> Vec<f32> {
6467        self.calculate_block_max_scores_with_block_size(doc_ids, freqs, LEGACY_BLOCK_SIZE)
6468    }
6469
6470    pub fn calculate_block_max_scores_with_block_size<'a>(
6471        &self,
6472        doc_ids: impl Iterator<Item = &'a u32>,
6473        freqs: impl Iterator<Item = &'a u32>,
6474        block_size: usize,
6475    ) -> Vec<f32> {
6476        validate_block_size(block_size).expect("invalid posting list block size");
6477        let avgdl = self.average_length();
6478        let length = doc_ids.size_hint().0;
6479        let num_blocks = length.div_ceil(block_size);
6480        let mut block_max_scores = Vec::with_capacity(num_blocks);
6481        let idf_scale = idf(length, self.len()) * (K1 + 1.0);
6482        let mut max_score = f32::MIN;
6483        for (i, (doc_id, freq)) in doc_ids.zip(freqs).enumerate() {
6484            let doc_norm = K1 * (1.0 - B + B * self.num_tokens(*doc_id) as f32 / avgdl);
6485            let freq = *freq as f32;
6486            let score = freq / (freq + doc_norm);
6487            if score > max_score {
6488                max_score = score;
6489            }
6490            if (i + 1) % block_size == 0 {
6491                max_score *= idf_scale;
6492                block_max_scores.push(max_score);
6493                max_score = f32::MIN;
6494            }
6495        }
6496        if !length.is_multiple_of(block_size) {
6497            max_score *= idf_scale;
6498            block_max_scores.push(max_score);
6499        }
6500        block_max_scores
6501    }
6502
6503    pub fn to_batch(&self) -> Result<RecordBatch> {
6504        let row_id_col = UInt64Array::from_iter_values(self.row_ids.iter().cloned());
6505        let num_tokens_col = UInt32Array::from_iter_values(self.num_tokens.iter().cloned());
6506
6507        let schema = arrow_schema::Schema::new(vec![
6508            arrow_schema::Field::new(ROW_ID, DataType::UInt64, false),
6509            arrow_schema::Field::new(NUM_TOKEN_COL, DataType::UInt32, false),
6510        ]);
6511
6512        let batch = RecordBatch::try_new(
6513            Arc::new(schema),
6514            vec![
6515                Arc::new(row_id_col) as ArrayRef,
6516                Arc::new(num_tokens_col) as ArrayRef,
6517            ],
6518        )?;
6519        Ok(batch)
6520    }
6521
6522    pub async fn load(
6523        reader: Arc<dyn IndexReader>,
6524        is_legacy: bool,
6525        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
6526    ) -> Result<Self> {
6527        let batch = reader.read_range(0..reader.num_rows(), None).await?;
6528        let row_id_col = batch[ROW_ID].as_primitive::<datatypes::UInt64Type>();
6529        let num_tokens_col = batch[NUM_TOKEN_COL].as_primitive::<datatypes::UInt32Type>();
6530        Self::from_columns(row_id_col, num_tokens_col, is_legacy, frag_reuse_index)
6531    }
6532
6533    /// Build a `DocSet` carrying only the per-doc `num_tokens` array;
6534    /// `row_ids` and `inv` are left empty. Used by the deferred-row_id
6535    /// scoring path: wand checks `has_row_ids()` to skip `row_id` /
6536    /// `num_tokens_by_row_id` calls, and the per-partition caller
6537    /// resolves doc_id → row_id for the surviving top-K post-wand.
6538    pub fn from_num_tokens_only(num_tokens_col: &arrow_array::UInt32Array) -> Self {
6539        let total_tokens = num_tokens_col.values().iter().map(|&n| n as u64).sum();
6540        Self::from_cached_num_tokens(num_tokens_col, total_tokens)
6541    }
6542
6543    /// Build a zero-copy num-tokens-only view from an Arrow column and its
6544    /// already-computed total. The caller must guarantee that `total_tokens`
6545    /// is the sum of `num_tokens_col`.
6546    pub(crate) fn from_cached_num_tokens(
6547        num_tokens_col: &arrow_array::UInt32Array,
6548        total_tokens: u64,
6549    ) -> Self {
6550        Self {
6551            row_ids: Vec::new(),
6552            num_tokens: NumTokens::Shared(num_tokens_col.values().clone()),
6553            inv: Vec::new(),
6554            total_tokens,
6555            scoring_quantized: false,
6556            norms: Arc::new(std::sync::OnceLock::new()),
6557        }
6558    }
6559
6560    /// Build a `DocSet` from already-loaded `row_id` and `num_tokens`
6561    /// arrow columns. Lets callers that have one column already in hand
6562    /// (e.g. `LazyDocSet` after `total_tokens_num` pre-fetched
6563    /// `num_tokens`) skip re-reading that column.
6564    pub fn from_columns(
6565        row_id_col: &UInt64Array,
6566        num_tokens_col: &arrow_array::UInt32Array,
6567        is_legacy: bool,
6568        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
6569    ) -> Result<Self> {
6570        // for legacy format, the row id is doc id; sorting keeps binary search viable
6571        if is_legacy {
6572            let (row_ids, num_tokens): (Vec<_>, Vec<_>) = row_id_col
6573                .values()
6574                .iter()
6575                .filter_map(|id| {
6576                    if let Some(frag_reuse_index_ref) = frag_reuse_index.as_ref() {
6577                        frag_reuse_index_ref.remap_row_id(*id)
6578                    } else {
6579                        Some(*id)
6580                    }
6581                })
6582                .zip(num_tokens_col.values().iter())
6583                .sorted_unstable_by_key(|x| x.0)
6584                .unzip();
6585
6586            let total_tokens = num_tokens.iter().map(|&x| x as u64).sum();
6587            return Ok(Self {
6588                row_ids,
6589                num_tokens: NumTokens::Owned(num_tokens),
6590                inv: Vec::new(),
6591                total_tokens,
6592                scoring_quantized: false,
6593                norms: Arc::new(std::sync::OnceLock::new()),
6594            });
6595        }
6596
6597        // If frag reuse happened, remap the row_ids through it. Crucially we
6598        // must NOT drop the rows the reuse index deleted, because the posting
6599        // lists reference doc_ids *positionally* (a doc_id is an index into
6600        // these arrays, fixed at build time). Dropping deleted rows would
6601        // renumber every later doc_id and desync the posting lists, so wand
6602        // would index `num_tokens`/`row_ids` out of bounds or score the wrong
6603        // doc. Instead we tombstone deleted rows in place: their slot survives
6604        // (so doc_ids stay aligned with the posting lists) carrying
6605        // `RowAddress::TOMBSTONE_ROW`, which wand skips, and they are left out
6606        // of `inv` so a row_id lookup never resolves to a deleted doc. The
6607        // heavyweight physical remap (`DocSet::remap`) is what actually
6608        // renumbers and compacts; this load-time path only has to stay
6609        // consistent until then.
6610        if let Some(frag_reuse_index_ref) = frag_reuse_index.as_ref() {
6611            let mut row_ids = Vec::with_capacity(row_id_col.len());
6612            let num_tokens = num_tokens_col.values().to_vec();
6613            let mut inv = Vec::with_capacity(row_id_col.len());
6614            for (doc_id, row_id) in row_id_col.values().iter().enumerate() {
6615                match frag_reuse_index_ref.remap_row_id(*row_id) {
6616                    Some(new_row_id) => {
6617                        row_ids.push(new_row_id);
6618                        inv.push((new_row_id, doc_id as u32));
6619                    }
6620                    None => {
6621                        // Deleted: keep the slot (doc_ids must not shift) but
6622                        // tombstone it and leave it out of `inv`.
6623                        row_ids.push(RowAddress::TOMBSTONE_ROW);
6624                    }
6625                }
6626            }
6627            inv.sort_unstable_by_key(|entry| entry.0);
6628
6629            let total_tokens = num_tokens.iter().map(|&x| x as u64).sum();
6630            return Ok(Self {
6631                row_ids,
6632                num_tokens: NumTokens::Owned(num_tokens),
6633                inv,
6634                total_tokens,
6635                scoring_quantized: false,
6636                norms: Arc::new(std::sync::OnceLock::new()),
6637            });
6638        }
6639
6640        let row_ids = row_id_col.values().to_vec();
6641        let num_tokens = num_tokens_col.values().to_vec();
6642        let mut inv: Vec<(u64, u32)> = row_ids
6643            .iter()
6644            .enumerate()
6645            .map(|(doc_id, row_id)| (*row_id, doc_id as u32))
6646            .collect();
6647        if !row_ids.is_sorted() {
6648            inv.sort_unstable_by_key(|entry| entry.0);
6649        }
6650        let total_tokens = num_tokens.iter().map(|&x| x as u64).sum();
6651        Ok(Self {
6652            row_ids,
6653            num_tokens: NumTokens::Owned(num_tokens),
6654            inv,
6655            total_tokens,
6656            scoring_quantized: false,
6657            norms: Arc::new(std::sync::OnceLock::new()),
6658        })
6659    }
6660
6661    // remap the row ids to the new row ids
6662    // returns the removed doc ids
6663    pub fn remap(&mut self, mapping: &RowAddrRemap) -> Vec<u32> {
6664        let mut removed = Vec::new();
6665        let len = self.len();
6666        let row_ids = std::mem::replace(&mut self.row_ids, Vec::with_capacity(len));
6667        let num_tokens =
6668            std::mem::replace(&mut self.num_tokens, NumTokens::with_capacity(len)).into_owned();
6669        self.invalidate_norms();
6670        self.total_tokens = 0;
6671        for (doc_id, (row_id, num_token)) in std::iter::zip(row_ids, num_tokens).enumerate() {
6672            match mapping.get(row_id) {
6673                Some(Some(new_row_id)) => {
6674                    self.row_ids.push(new_row_id);
6675                    self.num_tokens.push(num_token);
6676                    self.total_tokens += num_token as u64;
6677                }
6678                Some(None) => {
6679                    removed.push(doc_id as u32);
6680                }
6681                None => {
6682                    self.row_ids.push(row_id);
6683                    self.num_tokens.push(num_token);
6684                    self.total_tokens += num_token as u64;
6685                }
6686            }
6687        }
6688        removed
6689    }
6690
6691    #[inline]
6692    pub fn num_tokens(&self, doc_id: u32) -> u32 {
6693        self.num_tokens[doc_id as usize]
6694    }
6695
6696    /// Enable quantized document-length scoring for 256-document-block partitions.
6697    pub fn set_quantized_scoring(&mut self, quantized: bool) {
6698        self.scoring_quantized = quantized;
6699    }
6700
6701    /// The quantized document-length slab when this set scores quantized,
6702    /// baked on first use; `None` for exact-scoring sets.
6703    pub fn scoring_norms(&self) -> Option<&[u8]> {
6704        if !self.scoring_quantized {
6705            return None;
6706        }
6707        Some(
6708            self.norms
6709                .get_or_init(|| {
6710                    self.num_tokens
6711                        .iter()
6712                        .map(|&n| quantize_doc_length(n))
6713                        .collect()
6714                })
6715                .as_ref(),
6716        )
6717    }
6718
6719    /// Document length as scoring sees it: the quantized bucket floor for
6720    /// 256-document-block partitions, the exact value otherwise.
6721    #[inline]
6722    pub fn scoring_num_tokens(&self, doc_id: u32) -> u32 {
6723        match self.scoring_norms() {
6724            Some(norms) => dequantize_doc_length(norms[doc_id as usize]),
6725            None => self.num_tokens[doc_id as usize],
6726        }
6727    }
6728
6729    // this can be used only if it's a legacy format,
6730    // which store the sorted row ids so that we can use binary search
6731    #[inline]
6732    pub fn num_tokens_by_row_id(&self, row_id: u64) -> u32 {
6733        self.row_ids
6734            .binary_search(&row_id)
6735            .map(|idx| self.num_tokens[idx])
6736            .unwrap_or(0)
6737    }
6738
6739    // append a document to the doc set
6740    // returns the doc_id (the number of documents before appending)
6741    pub fn append(&mut self, row_id: u64, num_tokens: u32) -> u32 {
6742        self.row_ids.push(row_id);
6743        self.num_tokens.push(num_tokens);
6744        self.total_tokens += num_tokens as u64;
6745        self.invalidate_norms();
6746        self.row_ids.len() as u32 - 1
6747    }
6748
6749    // Drop the baked norm slab after a mutation; it re-bakes on the next
6750    // scoring use.
6751    fn invalidate_norms(&mut self) {
6752        if self.norms.get().is_some() {
6753            self.norms = Arc::new(std::sync::OnceLock::new());
6754        }
6755    }
6756
6757    pub(crate) fn memory_size(&self) -> usize {
6758        self.row_ids.capacity() * std::mem::size_of::<u64>()
6759            + self.num_tokens.memory_size()
6760            + self.inv.capacity() * std::mem::size_of::<(u64, u32)>()
6761    }
6762}
6763
6764pub fn flat_full_text_search(
6765    batches: &[&RecordBatch],
6766    doc_col: &str,
6767    query: &str,
6768    tokenizer: Option<Box<dyn LanceTokenizer>>,
6769) -> Result<Vec<u64>> {
6770    if batches.is_empty() {
6771        return Ok(vec![]);
6772    }
6773
6774    if is_phrase_query(query) {
6775        return Err(Error::invalid_input(
6776            "phrase query is not supported for flat full text search, try using FTS index",
6777        ));
6778    }
6779
6780    match batches[0][doc_col].data_type() {
6781        DataType::Utf8 => do_flat_full_text_search::<i32>(batches, doc_col, query, tokenizer),
6782        DataType::LargeUtf8 => do_flat_full_text_search::<i64>(batches, doc_col, query, tokenizer),
6783        DataType::List(_) => {
6784            do_flat_full_text_search_list::<i32>(batches, doc_col, query, tokenizer)
6785        }
6786        DataType::LargeList(_) => {
6787            do_flat_full_text_search_list::<i64>(batches, doc_col, query, tokenizer)
6788        }
6789        data_type => Err(Error::invalid_input(format!(
6790            "unsupported data type {} for inverted index",
6791            data_type
6792        ))),
6793    }
6794}
6795
6796fn do_flat_full_text_search<Offset: OffsetSizeTrait>(
6797    batches: &[&RecordBatch],
6798    doc_col: &str,
6799    query: &str,
6800    tokenizer: Option<Box<dyn LanceTokenizer>>,
6801) -> Result<Vec<u64>> {
6802    let mut results = Vec::new();
6803    let mut tokenizer =
6804        tokenizer.unwrap_or_else(|| InvertedIndexParams::default().build().unwrap());
6805    let query_tokens = collect_query_tokens(query, &mut tokenizer);
6806
6807    for batch in batches {
6808        let row_id_array = batch[ROW_ID].as_primitive::<UInt64Type>();
6809        let doc_array = batch[doc_col].as_string::<Offset>();
6810        for i in 0..row_id_array.len() {
6811            let doc = doc_array.value(i);
6812            if has_query_token(doc, &mut tokenizer, &query_tokens) {
6813                results.push(row_id_array.value(i));
6814                // What is this assertion for?  Why would doc contain query?  Don't we reach
6815                // here only if they share at least one token?  Why is it not debug_assert?
6816                assert!(doc.contains(query));
6817            }
6818        }
6819    }
6820
6821    Ok(results)
6822}
6823
6824fn do_flat_full_text_search_list<ListOffset: OffsetSizeTrait>(
6825    batches: &[&RecordBatch],
6826    doc_col: &str,
6827    query: &str,
6828    tokenizer: Option<Box<dyn LanceTokenizer>>,
6829) -> Result<Vec<u64>> {
6830    let mut results = Vec::new();
6831    let mut tokenizer =
6832        tokenizer.unwrap_or_else(|| InvertedIndexParams::default().build().unwrap());
6833    let query_tokens = collect_query_tokens(query, &mut tokenizer);
6834
6835    for batch in batches {
6836        let row_id_array = batch[ROW_ID].as_primitive::<UInt64Type>();
6837        let doc_array = batch[doc_col].as_list::<ListOffset>();
6838        match doc_array.value_type() {
6839            DataType::Utf8 | DataType::LargeUtf8 => {}
6840            data_type => {
6841                return Err(Error::invalid_input(format!(
6842                    "unsupported list item data type {} for inverted index",
6843                    data_type
6844                )));
6845            }
6846        }
6847        for i in 0..row_id_array.len() {
6848            if doc_array.is_null(i) {
6849                continue;
6850            }
6851            let elements = doc_array.value(i);
6852            if iter_str_array(elements.as_ref())
6853                .flatten()
6854                .any(|element| has_query_token(element, &mut tokenizer, &query_tokens))
6855            {
6856                results.push(row_id_array.value(i));
6857            }
6858        }
6859    }
6860
6861    Ok(results)
6862}
6863
6864const FLAT_ROW_ID_COL_IDX: usize = 0;
6865const FLAT_ALL_TOKENS_COL_IDX: usize = 1;
6866const FLAT_QUERY_TOKEN_COUNTS_COL_IDX: usize = 2;
6867
6868/// If we accumulate this many bytes we warn the user they probably want to use an FTS index instead.
6869const BYTES_ACCUMULATED_WARNING_THRESHOLD: u64 = 1024 * 1024 * 1024; // 1GB
6870
6871/// Consumes a stream of record batches and produces token counts
6872///
6873/// The resulting batch will have three columns:
6874/// - row_id: the row id of the document
6875/// - all_tokens: the total number of tokens in the document
6876/// - query_token_counts: a fixed size list of the count of each query token in the document
6877///
6878/// This is an unbounded accumulation, however, for most queries, the per-row
6879/// growth will be fairly small.  As a result we can process millions of tokens
6880/// with fairly modest memory usage.
6881///
6882/// However, it is unwise to do a flat search across billions of rows.  An FTS
6883/// index should be created instead.
6884async fn tokenize_and_count(
6885    input: impl Stream<Item = DataFusionResult<RecordBatch>> + Send,
6886    tokenizer: Box<dyn LanceTokenizer>,
6887    query_tokens: Arc<Tokens>,
6888    doc_col_idx: usize,
6889    elapsed_compute: Option<Time>,
6890) -> DataFusionResult<RecordBatch> {
6891    let output_schema = Arc::new(Schema::new(vec![
6892        ROW_ID_FIELD.clone(),
6893        Field::new("all_tokens", DataType::UInt64, false),
6894        Field::new(
6895            "query_token_counts",
6896            DataType::FixedSizeList(
6897                Arc::new(Field::new("item", DataType::UInt64, true)),
6898                query_tokens.len() as i32,
6899            ),
6900            false,
6901        ),
6902    ]));
6903    let output_schema_clone = output_schema.clone();
6904    let query_token_indices = Arc::new(query_token_indices(query_tokens.as_ref()));
6905    let bytes_accumulated = Arc::new(AtomicU64::new(0));
6906    let bytes_warning_emitted = Arc::new(AtomicBool::new(false));
6907
6908    let batches = input
6909        .map(move |batch| {
6910            let mut tokenizer = tokenizer.box_clone();
6911            let output_schema = output_schema.clone();
6912            let query_tokens = query_tokens.clone();
6913            let query_token_indices = query_token_indices.clone();
6914            let bytes_accumulated = bytes_accumulated.clone();
6915            let bytes_warning_emitted = bytes_warning_emitted.clone();
6916            let elapsed_compute = elapsed_compute.clone();
6917            spawn_cpu(move || {
6918                // Time the per-batch CPU work so callers can attribute it to
6919                // `elapsed_compute` on a metric handle (the spawn_cpu worker
6920                // thread is invisible to the caller's poll timer otherwise).
6921                let start = std::time::Instant::now();
6922                let batch = batch?;
6923                let row_id_array = batch[ROW_ID].as_primitive::<UInt64Type>();
6924                let mut row_ids = UInt64Builder::with_capacity(batch.num_rows());
6925                let mut all_token_counts = UInt64Builder::with_capacity(batch.num_rows());
6926                let mut query_token_counts = FixedSizeListBuilder::with_capacity(
6927                    UInt64Builder::with_capacity(batch.num_rows() * query_tokens.len()),
6928                    query_tokens.len() as i32,
6929                    batch.num_rows(),
6930                );
6931                let mut temp_query_token_counts = Vec::with_capacity(query_tokens.len());
6932                let mut count_text = |doc: &str, temp_query_token_counts: &mut Vec<u64>| -> u64 {
6933                    let mut stream = tokenizer.token_stream_for_doc(doc);
6934                    let mut all_tokens = 0;
6935                    while let Some(token) = stream.next() {
6936                        all_tokens += 1;
6937                        if let Some(token_indices) = query_token_indices.get(&token.text) {
6938                            for token_index in token_indices {
6939                                temp_query_token_counts[*token_index] += 1;
6940                            }
6941                        }
6942                    }
6943                    all_tokens
6944                };
6945                let mut append_counts =
6946                    |row_id: u64, all_tokens: u64, temp_query_token_counts: &[u64]| {
6947                        row_ids.append_value(row_id);
6948                        all_token_counts.append_value(all_tokens);
6949                        for count in temp_query_token_counts.iter().copied() {
6950                            query_token_counts.values().append_value(count);
6951                        }
6952                        query_token_counts.append(true);
6953                    };
6954                match batch.column(doc_col_idx).data_type() {
6955                    DataType::Utf8 | DataType::LargeUtf8 => {
6956                        let doc_iter = iter_str_array(batch.column(doc_col_idx));
6957                        for (doc, row_id) in doc_iter.zip(row_id_array.values().iter()) {
6958                            temp_query_token_counts.clear();
6959                            temp_query_token_counts
6960                                .extend(std::iter::repeat_n(0, query_tokens.len()));
6961
6962                            let Some(doc) = doc else {
6963                                continue;
6964                            };
6965
6966                            let all_tokens = count_text(doc, &mut temp_query_token_counts);
6967                            if all_tokens > 0 {
6968                                append_counts(*row_id, all_tokens, &temp_query_token_counts);
6969                            }
6970                        }
6971                    }
6972                    DataType::List(_) => {
6973                        tokenize_and_count_list::<i32>(
6974                            batch.column(doc_col_idx),
6975                            row_id_array,
6976                            &mut count_text,
6977                            &mut append_counts,
6978                            &mut temp_query_token_counts,
6979                            query_tokens.len(),
6980                        )?;
6981                    }
6982                    DataType::LargeList(_) => {
6983                        tokenize_and_count_list::<i64>(
6984                            batch.column(doc_col_idx),
6985                            row_id_array,
6986                            &mut count_text,
6987                            &mut append_counts,
6988                            &mut temp_query_token_counts,
6989                            query_tokens.len(),
6990                        )?;
6991                    }
6992                    data_type => {
6993                        return DataFusionResult::Err(datafusion_common::DataFusionError::Execution(
6994                            format!("unsupported data type {} for flat full text search", data_type),
6995                        ));
6996                    }
6997                }
6998                let row_ids = row_ids.finish();
6999                let all_token_counts = all_token_counts.finish();
7000                let query_token_counts = query_token_counts.finish();
7001                let result_batch = RecordBatch::try_new(
7002                    output_schema,
7003                    vec![
7004                        Arc::new(row_ids) as ArrayRef,
7005                        Arc::new(all_token_counts) as ArrayRef,
7006                        Arc::new(query_token_counts) as ArrayRef,
7007                    ],
7008                )?;
7009                let bytes_accumulated = bytes_accumulated.fetch_add(result_batch.get_array_memory_size() as u64, Ordering::Relaxed);
7010                if bytes_accumulated > BYTES_ACCUMULATED_WARNING_THRESHOLD && !bytes_warning_emitted.swap(true, Ordering::Relaxed) {
7011                    tracing::warn!("Flat full text search is accumulating a large number of bytes.  Consider using an FTS index instead.");
7012                }
7013
7014                if let Some(t) = &elapsed_compute {
7015                    t.add_duration(start.elapsed());
7016                }
7017                DataFusionResult::Ok(result_batch)
7018            })
7019        })
7020        .buffered(get_num_compute_intensive_cpus())
7021        .try_collect::<Vec<_>>()
7022        .await?;
7023
7024    Ok(arrow::compute::concat_batches(
7025        &output_schema_clone,
7026        &batches,
7027    )?)
7028}
7029
7030fn tokenize_and_count_list<ListOffset: OffsetSizeTrait>(
7031    doc_col: &ArrayRef,
7032    row_id_array: &arrow_array::PrimitiveArray<UInt64Type>,
7033    count_text: &mut impl FnMut(&str, &mut Vec<u64>) -> u64,
7034    append_counts: &mut impl FnMut(u64, u64, &[u64]),
7035    temp_query_token_counts: &mut Vec<u64>,
7036    query_tokens_len: usize,
7037) -> DataFusionResult<()> {
7038    let doc_array = doc_col.as_list::<ListOffset>();
7039    match doc_array.value_type() {
7040        DataType::Utf8 | DataType::LargeUtf8 => {}
7041        data_type => {
7042            return Err(datafusion_common::DataFusionError::Execution(format!(
7043                "unsupported list item data type {} for flat full text search",
7044                data_type
7045            )));
7046        }
7047    }
7048
7049    for i in 0..row_id_array.len() {
7050        if doc_array.is_null(i) {
7051            continue;
7052        }
7053
7054        temp_query_token_counts.clear();
7055        temp_query_token_counts.extend(std::iter::repeat_n(0, query_tokens_len));
7056
7057        let elements = doc_array.value(i);
7058        let mut all_tokens = 0;
7059        for element in iter_str_array(elements.as_ref()).flatten() {
7060            all_tokens += count_text(element, temp_query_token_counts);
7061        }
7062
7063        if all_tokens > 0 {
7064            append_counts(row_id_array.value(i), all_tokens, temp_query_token_counts);
7065        }
7066    }
7067
7068    Ok(())
7069}
7070
7071fn query_token_indices(query_tokens: &Tokens) -> HashMap<String, Vec<usize>> {
7072    let mut indices = HashMap::new();
7073    for idx in 0..query_tokens.len() {
7074        indices
7075            .entry(query_tokens.get_token(idx).to_string())
7076            .or_insert_with(Vec::new)
7077            .push(idx);
7078    }
7079    indices
7080}
7081
7082/// Initialize the BM25 scorer
7083///
7084/// In order to calculate BM25 scores we need to know token counts for the entire corpus.  We extract these from the
7085/// counted input of the flat search combined with any counts recorded for the indexed portion.
7086fn initialize_scorer(
7087    base_scorer: Option<&MemBM25Scorer>,
7088    query_tokens: &Tokens,
7089    counted_input: &RecordBatch,
7090) -> MemBM25Scorer {
7091    let mut total_tokens = 0;
7092    let mut num_docs = 0;
7093    let mut all_token_counts = vec![0; query_tokens.len()];
7094
7095    if let Some(base_scorer) = base_scorer {
7096        total_tokens += base_scorer.total_tokens;
7097        num_docs += base_scorer.num_docs;
7098        for (token_index, token) in query_tokens.into_iter().enumerate() {
7099            all_token_counts[token_index] = base_scorer.num_docs_containing_token(token) as u64;
7100        }
7101    }
7102
7103    num_docs += counted_input.num_rows();
7104    total_tokens += arrow::compute::sum(
7105        counted_input
7106            .column(FLAT_ALL_TOKENS_COL_IDX)
7107            .as_primitive::<UInt64Type>(),
7108    )
7109    .unwrap_or_default();
7110
7111    let mut input_token_counters = counted_input
7112        .column(FLAT_QUERY_TOKEN_COUNTS_COL_IDX)
7113        .as_fixed_size_list()
7114        .values()
7115        .as_primitive::<UInt64Type>()
7116        .values()
7117        .iter()
7118        .copied();
7119
7120    for _ in 0..counted_input.num_rows() {
7121        for token_count in all_token_counts.iter_mut() {
7122            if input_token_counters.next().unwrap_or_default() > 0 {
7123                *token_count += 1;
7124            }
7125        }
7126    }
7127
7128    let token_counts_map = all_token_counts
7129        .into_iter()
7130        .enumerate()
7131        .map(|(token_index, count)| {
7132            (
7133                query_tokens.get_token(token_index).to_string(),
7134                count as usize,
7135            )
7136        })
7137        .collect::<HashMap<String, usize>>();
7138    MemBM25Scorer::new(total_tokens, num_docs, token_counts_map)
7139}
7140
7141fn flat_bm25_score(
7142    query_tokens: &Tokens,
7143    counted_input: &RecordBatch,
7144    scorer: &MemBM25Scorer,
7145    operator: Operator,
7146) -> Result<RecordBatch> {
7147    let mut row_ids_builder = UInt64Builder::with_capacity(counted_input.num_rows());
7148    let mut scores_builder = Float32Builder::with_capacity(counted_input.num_rows());
7149    let query_groups = query_position_groups(query_tokens);
7150
7151    let mut row_ids_iter = counted_input
7152        .column(FLAT_ROW_ID_COL_IDX)
7153        .as_primitive::<UInt64Type>()
7154        .values()
7155        .iter()
7156        .copied();
7157    let mut all_token_counts_iter = counted_input
7158        .column(FLAT_ALL_TOKENS_COL_IDX)
7159        .as_primitive::<UInt64Type>()
7160        .values()
7161        .iter()
7162        .copied();
7163    let mut query_token_counts_iter = counted_input
7164        .column(FLAT_QUERY_TOKEN_COUNTS_COL_IDX)
7165        .as_fixed_size_list()
7166        .values()
7167        .as_primitive::<UInt64Type>()
7168        .values()
7169        .iter()
7170        .copied();
7171    for _ in 0..counted_input.num_rows() {
7172        let num_tokens_in_doc = all_token_counts_iter.next().expect_ok()?;
7173        let row_id = row_ids_iter.next().expect_ok()?;
7174        let mut query_token_counts = Vec::with_capacity(query_tokens.len());
7175        for _ in query_tokens {
7176            query_token_counts.push(query_token_counts_iter.next().expect_ok()?);
7177        }
7178        if num_tokens_in_doc == 0 {
7179            continue;
7180        }
7181        if operator == Operator::And
7182            && !query_groups
7183                .iter()
7184                .all(|group| group.iter().any(|idx| query_token_counts[*idx] > 0))
7185        {
7186            continue;
7187        }
7188        let doc_norm = K1 * (1.0 - B + B * num_tokens_in_doc as f32 / scorer.avg_doc_length());
7189        let mut score = 0.0;
7190        for (token, freq) in query_tokens.into_iter().zip(query_token_counts) {
7191            let freq = freq as f32;
7192            let idf = idf(scorer.num_docs_containing_token(token), scorer.num_docs());
7193            score += idf * (freq * (K1 + 1.0) / (freq + doc_norm));
7194        }
7195        if score > 0.0 {
7196            row_ids_builder.append_value(row_id);
7197            scores_builder.append_value(score);
7198        }
7199    }
7200
7201    let row_ids = row_ids_builder.finish();
7202    let scores = scores_builder.finish();
7203    let batch = RecordBatch::try_new(
7204        FTS_SCHEMA.clone(),
7205        vec![Arc::new(row_ids) as ArrayRef, Arc::new(scores) as ArrayRef],
7206    )?;
7207    Ok(batch)
7208}
7209
7210fn query_position_groups(query_tokens: &Tokens) -> Vec<Vec<usize>> {
7211    let mut groups = Vec::new();
7212    let mut current_position = None;
7213    for idx in 0..query_tokens.len() {
7214        let position = query_tokens.position(idx);
7215        if current_position != Some(position) {
7216            current_position = Some(position);
7217            groups.push(Vec::new());
7218        }
7219        groups
7220            .last_mut()
7221            .expect("a group should exist after pushing for position")
7222            .push(idx);
7223    }
7224    groups
7225}
7226
7227#[deprecated(
7228    note = "use `flat_bm25_search_stream_with_metrics` to record CPU compute \
7229            time on a metric handle; pass `None` for the old behavior"
7230)]
7231pub async fn flat_bm25_search_stream(
7232    input: SendableRecordBatchStream,
7233    doc_col: String,
7234    query: String,
7235    tokenizer: Box<dyn LanceTokenizer>,
7236    base_scorer: Option<MemBM25Scorer>,
7237    target_batch_size: usize,
7238) -> DataFusionResult<SendableRecordBatchStream> {
7239    flat_bm25_search_stream_with_metrics(
7240        input,
7241        doc_col,
7242        query,
7243        tokenizer,
7244        base_scorer,
7245        target_batch_size,
7246        None,
7247    )
7248    .await
7249}
7250
7251/// Same as [`flat_bm25_search_stream`] but accepts an optional `Time` handle
7252/// that, if provided, will receive the CPU time spent in (a) per-batch
7253/// tokenization on the `spawn_cpu` worker threads and (b) the synchronous
7254/// scoring phase. This lets a calling `ExecutionPlan` report accurate
7255/// `elapsed_compute` without double-counting upstream poll time.
7256pub async fn flat_bm25_search_stream_with_metrics(
7257    input: SendableRecordBatchStream,
7258    doc_col: String,
7259    query: String,
7260    tokenizer: Box<dyn LanceTokenizer>,
7261    base_scorer: Option<MemBM25Scorer>,
7262    target_batch_size: usize,
7263    elapsed_compute: Option<Time>,
7264) -> DataFusionResult<SendableRecordBatchStream> {
7265    flat_bm25_search_stream_with_metrics_and_operator(
7266        input,
7267        doc_col,
7268        query,
7269        tokenizer,
7270        base_scorer,
7271        target_batch_size,
7272        Operator::Or,
7273        elapsed_compute,
7274    )
7275    .await
7276}
7277
7278/// Same as [`flat_bm25_search_stream_with_metrics`] but applies the provided
7279/// match operator when deciding whether a flat-scanned row is a hit.
7280///
7281/// # Examples
7282///
7283/// ```no_run
7284/// # async fn example(
7285/// #     input: datafusion::execution::SendableRecordBatchStream,
7286/// # ) -> Result<(), Box<dyn std::error::Error>> {
7287/// use lance_index::scalar::inverted::{
7288///     flat_bm25_search_stream_with_metrics_and_operator, query::Operator, InvertedIndexParams,
7289/// };
7290///
7291/// let tokenizer = InvertedIndexParams::code().build()?;
7292/// let _stream = flat_bm25_search_stream_with_metrics_and_operator(
7293///     input,
7294///     "code".to_string(),
7295///     "Result".to_string(),
7296///     tokenizer,
7297///     None,
7298///     1024,
7299///     Operator::And,
7300///     None,
7301/// )
7302/// .await?;
7303/// # Ok(())
7304/// # }
7305/// ```
7306#[allow(clippy::too_many_arguments)]
7307pub async fn flat_bm25_search_stream_with_metrics_and_operator(
7308    input: SendableRecordBatchStream,
7309    doc_col: String,
7310    query: String,
7311    tokenizer: Box<dyn LanceTokenizer>,
7312    base_scorer: Option<MemBM25Scorer>,
7313    target_batch_size: usize,
7314    operator: Operator,
7315    elapsed_compute: Option<Time>,
7316) -> DataFusionResult<SendableRecordBatchStream> {
7317    let mut tokenizer = tokenizer;
7318
7319    // Pre-await synchronous work: query tokenization + chunk-stream setup.
7320    let pre_await_start = std::time::Instant::now();
7321    let query_tokens = Arc::new(collect_query_tokens(&query, &mut tokenizer));
7322
7323    // A query that tokenizes to no terms (e.g. only stop words) has no
7324    // searchable content and matches nothing. Return early rather than
7325    // proceeding. This mirrors the indexed search path, which already
7326    // short-circuits on empty query tokens.
7327    if query_tokens.is_empty() {
7328        return Ok(Box::pin(RecordBatchStreamAdapter::new(
7329            FTS_SCHEMA.clone(),
7330            stream::empty::<DataFusionResult<RecordBatch>>(),
7331        )));
7332    }
7333
7334    let input_schema = input.schema();
7335    let doc_col_idx = input_schema.index_of(&doc_col)?;
7336
7337    // Accumulate small batches until this threshold before dispatching a task.
7338    const ACCUMULATE_BYTES: usize = 256 * 1024;
7339    // Slice oversized batches down to roughly this size.
7340    const SLICE_BYTES: usize = 512 * 1024;
7341
7342    // Phase 1 - rechunk the input stream into appropriately sized chunks.  Tokenization is
7343    // fairly CPU-intensive, and we don't need too much data to justify a new thread task.
7344    let chunked = lance_arrow::stream::rechunk_stream_by_size(
7345        input,
7346        input_schema,
7347        ACCUMULATE_BYTES,
7348        SLICE_BYTES,
7349    );
7350    if let Some(t) = &elapsed_compute {
7351        t.add_duration(pre_await_start.elapsed());
7352    }
7353
7354    // Phase 2 - For each row we need to know the total number of tokens and the count of each
7355    // of the query tokens.  For example, if the query is "book" and the row is "the book shop"
7356    // and we are tokenizing with a whitespace tokenizer, we need to know that there are 3 tokens
7357    // and the token book appears once.
7358    let counted_input = tokenize_and_count(
7359        chunked,
7360        tokenizer,
7361        query_tokens.clone(),
7362        doc_col_idx,
7363        elapsed_compute.clone(),
7364    )
7365    .await?;
7366
7367    // Phase 3 - Calculate final scores (this is fairly cheap, probably don't need to parallelize).
7368    // All post-await work is synchronous; time the scorer + score + slicing loop together.
7369    let post_await_start = std::time::Instant::now();
7370    let scorer = initialize_scorer(base_scorer.as_ref(), query_tokens.as_ref(), &counted_input);
7371    let scores = flat_bm25_score(query_tokens.as_ref(), &counted_input, &scorer, operator)?;
7372
7373    // Finally we emit batches according to the target batch size
7374    let num_out_batches = scores.num_rows().div_ceil(target_batch_size);
7375    let mut batches = Vec::with_capacity(num_out_batches);
7376    for i in 0..num_out_batches {
7377        let start = i * target_batch_size;
7378        let len = (scores.num_rows() - start).min(target_batch_size);
7379        batches.push(Ok(scores.slice(start, len)));
7380    }
7381    if let Some(t) = &elapsed_compute {
7382        t.add_duration(post_await_start.elapsed());
7383    }
7384    Ok(Box::pin(RecordBatchStreamAdapter::new(
7385        FTS_SCHEMA.clone(),
7386        stream::iter(batches),
7387    )))
7388}
7389
7390pub fn is_phrase_query(query: &str) -> bool {
7391    query.starts_with('\"') && query.ends_with('\"')
7392}
7393
7394#[cfg(test)]
7395mod tests {
7396    use crate::scalar::inverted::document_tokenizer::DocType;
7397    use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
7398    use futures::stream;
7399    use lance_core::cache::LanceCache;
7400    use lance_core::utils::tempfile::TempObjDir;
7401    use lance_io::object_store::ObjectStore;
7402
7403    use crate::metrics::{LocalMetricsCollector, NoOpMetricsCollector};
7404    use crate::prefilter::NoFilter;
7405    use crate::scalar::ScalarIndex;
7406    use crate::scalar::inverted::builder::{
7407        InnerBuilder, InvertedIndexBuilder, PositionRecorder, doc_file_path, inverted_list_schema,
7408        inverted_list_schema_for_version_with_block_size,
7409        inverted_list_schema_for_version_with_block_size_and_impacts, posting_file_path,
7410        token_file_path,
7411    };
7412    use crate::scalar::inverted::encoding::{
7413        compress_positions, compress_posting_list_with_tail_codec,
7414        decompress_posting_list_with_tail_codec, encode_position_stream_block_into,
7415    };
7416    use crate::scalar::inverted::query::{FtsSearchParams, Operator};
7417    use crate::scalar::lance_format::LanceIndexStore;
7418    use arrow::array::{
7419        AsArray, GenericListBuilder, GenericStringBuilder, Int32Builder, LargeBinaryBuilder,
7420        ListBuilder, UInt32Builder,
7421    };
7422    use arrow::datatypes::{Float32Type, UInt32Type};
7423    use arrow_array::{ArrayRef, Float32Array, RecordBatch, StringArray, UInt32Array, UInt64Array};
7424    use arrow_schema::{DataType, Field, Schema};
7425    use std::collections::HashMap;
7426    use std::sync::Arc;
7427    use std::sync::atomic::{AtomicU32, Ordering};
7428
7429    use crate::scalar::inverted::tokenizer::document_tokenizer::TextTokenizer;
7430    use lance_tokenizer::{Language, SimpleTokenizer, StopWordFilter, TextAnalyzer};
7431
7432    use super::*;
7433
7434    #[derive(Debug)]
7435    struct MetadataAccessDeniedStore {
7436        inner: Arc<dyn IndexStore>,
7437    }
7438
7439    impl DeepSizeOf for MetadataAccessDeniedStore {
7440        fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
7441            self.inner.deep_size_of_children(context)
7442        }
7443    }
7444
7445    #[async_trait]
7446    impl IndexStore for MetadataAccessDeniedStore {
7447        fn as_any(&self) -> &dyn std::any::Any {
7448            self
7449        }
7450
7451        fn clone_arc(&self) -> Arc<dyn IndexStore> {
7452            Arc::new(Self {
7453                inner: self.inner.clone(),
7454            })
7455        }
7456
7457        fn io_parallelism(&self) -> usize {
7458            self.inner.io_parallelism()
7459        }
7460
7461        async fn new_index_file(
7462            &self,
7463            name: &str,
7464            schema: Arc<Schema>,
7465        ) -> Result<Box<dyn crate::scalar::IndexWriter>> {
7466            self.inner.new_index_file(name, schema).await
7467        }
7468
7469        async fn open_index_file(&self, name: &str) -> Result<Arc<dyn IndexReader>> {
7470            if name == METADATA_FILE {
7471                Err(Error::io("metadata access denied"))
7472            } else {
7473                self.inner.open_index_file(name).await
7474            }
7475        }
7476
7477        fn with_io_priority(&self, io_priority: u64) -> Arc<dyn IndexStore> {
7478            Arc::new(Self {
7479                inner: self.inner.with_io_priority(io_priority),
7480            })
7481        }
7482
7483        async fn copy_index_file(
7484            &self,
7485            name: &str,
7486            dest_store: &dyn IndexStore,
7487        ) -> Result<crate::scalar::IndexFile> {
7488            self.inner.copy_index_file(name, dest_store).await
7489        }
7490
7491        async fn rename_index_file(
7492            &self,
7493            name: &str,
7494            new_name: &str,
7495        ) -> Result<crate::scalar::IndexFile> {
7496            self.inner.rename_index_file(name, new_name).await
7497        }
7498
7499        async fn delete_index_file(&self, name: &str) -> Result<()> {
7500            self.inner.delete_index_file(name).await
7501        }
7502
7503        async fn list_files_with_sizes(&self) -> Result<Vec<crate::scalar::IndexFile>> {
7504            self.inner.list_files_with_sizes().await
7505        }
7506    }
7507
7508    #[tokio::test]
7509    async fn params_legacy_fallback_probes_tokens_after_metadata_access_denied() {
7510        let tmpdir = TempObjDir::default();
7511        let inner: Arc<dyn IndexStore> = Arc::new(LanceIndexStore::new(
7512            ObjectStore::local().into(),
7513            tmpdir.clone(),
7514            Arc::new(LanceCache::no_cache()),
7515        ));
7516        let expected = InvertedIndexParams::default();
7517        let metadata = HashMap::from([(
7518            "tokenizer".to_owned(),
7519            serde_json::to_string(&expected).unwrap(),
7520        )]);
7521        let mut writer = inner
7522            .new_index_file(TOKENS_FILE, Arc::new(Schema::empty()))
7523            .await
7524            .unwrap();
7525        writer.finish_with_metadata(metadata).await.unwrap();
7526        let store = MetadataAccessDeniedStore { inner };
7527
7528        let actual = InvertedIndex::load_params(&store).await.unwrap();
7529        assert_eq!(
7530            serde_json::to_value(actual).unwrap(),
7531            serde_json::to_value(expected).unwrap()
7532        );
7533    }
7534
7535    #[tokio::test]
7536    async fn params_legacy_probe_preserves_metadata_error_when_tokens_are_missing() {
7537        let tmpdir = TempObjDir::default();
7538        let inner: Arc<dyn IndexStore> = Arc::new(LanceIndexStore::new(
7539            ObjectStore::local().into(),
7540            tmpdir.clone(),
7541            Arc::new(LanceCache::no_cache()),
7542        ));
7543        let store = MetadataAccessDeniedStore { inner };
7544
7545        let error = InvertedIndex::load_params(&store).await.unwrap_err();
7546        assert!(matches!(error, Error::IO { .. }));
7547        assert!(error.to_string().contains("metadata access denied"));
7548    }
7549
7550    #[tokio::test]
7551    async fn params_metadata_ignores_unknown_fields() {
7552        let tmpdir = TempObjDir::default();
7553        let store = Arc::new(LanceIndexStore::new(
7554            ObjectStore::local().into(),
7555            tmpdir.clone(),
7556            Arc::new(LanceCache::no_cache()),
7557        ));
7558        let expected = InvertedIndexParams::default();
7559        let mut params = serde_json::to_value(&expected).unwrap();
7560        let params = params.as_object_mut().unwrap();
7561        params.insert("skip_merge".to_owned(), true.into());
7562        params.insert(
7563            "future_parameter".to_owned(),
7564            serde_json::json!({ "enabled": true }),
7565        );
7566        let metadata =
7567            HashMap::from([("params".to_owned(), serde_json::to_string(params).unwrap())]);
7568        let mut writer = store
7569            .new_index_file(METADATA_FILE, Arc::new(Schema::empty()))
7570            .await
7571            .unwrap();
7572        writer.finish_with_metadata(metadata).await.unwrap();
7573
7574        let actual = InvertedIndex::load_params(store.as_ref()).await.unwrap();
7575        assert_eq!(
7576            serde_json::to_value(actual).unwrap(),
7577            serde_json::to_value(expected).unwrap()
7578        );
7579    }
7580
7581    async fn write_single_partition_index(
7582        store: Arc<LanceIndexStore>,
7583        params: InvertedIndexParams,
7584        token_set_format: TokenSetFormat,
7585        token: &str,
7586        row_id: u64,
7587    ) -> Result<Arc<InvertedIndex>> {
7588        let block_size = params.posting_block_size();
7589        let format_version = params.resolved_format_version();
7590        let mut partition = InnerBuilder::new_with_format_version_and_block_size(
7591            0,
7592            false,
7593            token_set_format,
7594            format_version,
7595            block_size,
7596        );
7597        partition.tokens.add(token.to_owned());
7598        let mut posting_list = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
7599            false,
7600            format_version.posting_tail_codec(),
7601            block_size,
7602        );
7603        posting_list.add(0, PositionRecorder::Count(1));
7604        partition.posting_lists.push(posting_list);
7605        partition.docs.append(row_id, 1);
7606        partition.write(store.as_ref()).await?;
7607
7608        let metadata = HashMap::from([
7609            (
7610                "partitions".to_owned(),
7611                serde_json::to_string(&vec![0_u64]).unwrap(),
7612            ),
7613            ("params".to_owned(), serde_json::to_string(&params).unwrap()),
7614            (
7615                TOKEN_SET_FORMAT_KEY.to_owned(),
7616                token_set_format.to_string(),
7617            ),
7618            (
7619                POSTING_TAIL_CODEC_KEY.to_owned(),
7620                format_version.posting_tail_codec().as_str().to_owned(),
7621            ),
7622            (
7623                FTS_FORMAT_VERSION_KEY.to_owned(),
7624                format_version.index_version().to_string(),
7625            ),
7626            (POSTING_BLOCK_SIZE_KEY.to_owned(), block_size.to_string()),
7627        ]);
7628        let mut writer = store
7629            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
7630            .await?;
7631        writer.finish_with_metadata(metadata).await?;
7632
7633        InvertedIndex::load(store, None, &LanceCache::no_cache()).await
7634    }
7635
7636    fn empty_doc_stream() -> SendableRecordBatchStream {
7637        let schema = Arc::new(Schema::new(vec![
7638            Field::new("doc", DataType::Utf8, true),
7639            Field::new(ROW_ID, DataType::UInt64, false),
7640        ]));
7641        Box::pin(RecordBatchStreamAdapter::new(
7642            schema,
7643            stream::iter(Vec::<datafusion::error::Result<RecordBatch>>::new()),
7644        ))
7645    }
7646
7647    #[test]
7648    fn test_posting_block_size_schema_metadata() {
7649        assert_eq!(parse_posting_block_size(&HashMap::new()).unwrap(), 128);
7650
7651        let metadata = HashMap::from([(POSTING_BLOCK_SIZE_KEY.to_owned(), "512".to_owned())]);
7652        let err = parse_posting_block_size(&metadata).unwrap_err();
7653        assert!(err.to_string().contains("block_size"));
7654
7655        let metadata = HashMap::from([(POSTING_BLOCK_SIZE_KEY.to_owned(), "129".to_owned())]);
7656        let err = parse_posting_block_size(&metadata).unwrap_err();
7657        assert!(err.to_string().contains("block_size"));
7658    }
7659
7660    #[test]
7661    fn test_num_tokens_only_reuses_sliced_arrow_storage() {
7662        let docs = {
7663            let source = UInt32Array::from(vec![999, 7, 16, 1024, 888]);
7664            let sliced = source.slice(1, 3);
7665            let mut docs = DocSet::from_num_tokens_only(&sliced);
7666
7667            let NumTokens::Shared(values) = &docs.num_tokens else {
7668                panic!("num-tokens-only DocSet must retain shared Arrow storage");
7669            };
7670            assert!(values.ptr_eq(sliced.values()));
7671            assert_eq!(values.as_ref(), &[7, 16, 1024]);
7672            assert_eq!(docs.total_tokens_num(), 1047);
7673            docs.set_quantized_scoring(true);
7674            assert_eq!(docs.scoring_norms().unwrap().len(), 3);
7675            assert_eq!(
7676                docs.scoring_num_tokens(0),
7677                dequantize_doc_length(quantize_doc_length(7))
7678            );
7679            assert_eq!(
7680                docs.scoring_num_tokens(2),
7681                dequantize_doc_length(quantize_doc_length(1024))
7682            );
7683            docs
7684        };
7685
7686        assert_eq!(docs.len(), 3);
7687        assert_eq!(docs.num_tokens(0), 7);
7688        assert_eq!(docs.num_tokens(2), 1024);
7689    }
7690
7691    #[test]
7692    fn test_cached_num_tokens_uses_supplied_total_and_full_stays_owned() {
7693        const CACHED_TOTAL_MARKER: u64 = 123_456;
7694
7695        let num_tokens = UInt32Array::from(vec![3, 5, 8]);
7696        let docs = DocSet::from_cached_num_tokens(&num_tokens, CACHED_TOTAL_MARKER);
7697        assert_eq!(docs.total_tokens_num(), CACHED_TOTAL_MARKER);
7698        assert!(matches!(&docs.num_tokens, NumTokens::Shared(_)));
7699
7700        let row_ids = UInt64Array::from(vec![10, 20, 30]);
7701        let full = DocSet::from_columns(&row_ids, &num_tokens, false, None).unwrap();
7702        assert!(matches!(&full.num_tokens, NumTokens::Owned(_)));
7703        assert_eq!(full.total_tokens_num(), 16);
7704        assert_eq!(full.row_id(1), 20);
7705    }
7706
7707    #[test]
7708    fn test_posting_builder_writes_impacts_for_supported_block_sizes() {
7709        for block_size in [128, 256] {
7710            let format_version = default_fts_format_version_for_block_size(block_size).unwrap();
7711            let num_docs = block_size * 33 + 1;
7712            let mut docs = DocSet::default();
7713            let mut posting = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
7714                false,
7715                format_version.posting_tail_codec(),
7716                block_size,
7717            );
7718            for doc_id in 0..num_docs {
7719                docs.append(doc_id as u64, (doc_id % 5 + 1) as u32);
7720                posting.add(
7721                    doc_id as u32,
7722                    PositionRecorder::Count((doc_id % 3 + 1) as u32),
7723                );
7724            }
7725            let schema =
7726                inverted_list_schema_for_version_with_block_size(false, format_version, block_size);
7727            let batch = posting.to_batch_with_docs(&docs, schema).unwrap();
7728            assert!(batch.column_by_name(IMPACT_COL).is_some());
7729            let max_score = batch[MAX_SCORE_COL].as_primitive::<Float32Type>().value(0);
7730            let length = batch[LENGTH_COL].as_primitive::<UInt32Type>().value(0);
7731            let posting = PostingList::from_batch(&batch, Some(max_score), Some(length)).unwrap();
7732            let PostingList::Compressed(posting) = posting else {
7733                panic!("expected compressed posting list");
7734            };
7735            let impacts = posting.impacts.expect("posting should include impacts");
7736            assert_eq!(impacts.level0_len(), posting.blocks.len());
7737            assert_eq!(impacts.level1_len(), posting.blocks.len().div_ceil(32));
7738            assert_eq!(
7739                impacts.entries().len(),
7740                impacts.level0_len() + impacts.level1_len()
7741            );
7742        }
7743    }
7744
7745    #[test]
7746    fn test_posting_builder_without_impact_column_roundtrips_without_impacts() {
7747        let mut posting = PostingListBuilder::new(false);
7748        for doc_id in 0..BLOCK_SIZE + 3 {
7749            posting.add(doc_id as u32, PositionRecorder::Count(1));
7750        }
7751        let batch = posting.to_batch(vec![1.0, 1.0]).unwrap();
7752        assert!(batch.column_by_name(IMPACT_COL).is_none());
7753        let posting =
7754            PostingList::from_batch(&batch, Some(1.0), Some((BLOCK_SIZE + 3) as u32)).unwrap();
7755        assert!(!posting.has_impacts());
7756    }
7757
7758    #[tokio::test]
7759    async fn test_build_search_uses_configured_posting_block_size() {
7760        let tmpdir = TempObjDir::default();
7761        let store = Arc::new(LanceIndexStore::new(
7762            ObjectStore::local().into(),
7763            tmpdir.clone(),
7764            Arc::new(LanceCache::no_cache()),
7765        ));
7766
7767        let params = InvertedIndexParams::default().block_size(256).unwrap();
7768        let format_version = params.resolved_format_version();
7769        let block_size = params.posting_block_size();
7770        let num_docs = block_size + 7;
7771
7772        let mut builder = InnerBuilder::new_with_format_version_and_block_size(
7773            0,
7774            false,
7775            TokenSetFormat::default(),
7776            format_version,
7777            block_size,
7778        );
7779        builder.tokens.add("needle".to_owned());
7780        let mut posting_list = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
7781            false,
7782            format_version.posting_tail_codec(),
7783            block_size,
7784        );
7785        for doc_id in 0..num_docs {
7786            posting_list.add(doc_id as u32, PositionRecorder::Count(1));
7787            builder.docs.append(1_000 + doc_id as u64, 1);
7788        }
7789        builder.posting_lists.push(posting_list);
7790        builder.write(store.as_ref()).await.unwrap();
7791        write_test_metadata(&store, vec![0], params).await;
7792
7793        let cache = Arc::new(LanceCache::with_capacity(4096));
7794        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
7795            .await
7796            .unwrap();
7797        assert_eq!(index.partitions[0].inverted_list.block_size(), block_size);
7798
7799        let posting = index.partitions[0]
7800            .inverted_list
7801            .posting_list(0, false, &NoOpMetricsCollector)
7802            .await
7803            .unwrap();
7804        let PostingList::Compressed(posting) = posting else {
7805            panic!("expected compressed posting list");
7806        };
7807        assert_eq!(posting.block_size, block_size);
7808        assert_eq!(posting.blocks.len(), num_docs.div_ceil(block_size));
7809        let impacts = posting
7810            .impacts
7811            .as_ref()
7812            .expect("newly written posting list should include impacts");
7813        assert_eq!(impacts.level0_len(), posting.blocks.len());
7814        assert_eq!(impacts.level1_len(), posting.blocks.len().div_ceil(32));
7815        assert_eq!(
7816            impacts.entries().len(),
7817            impacts.level0_len() + impacts.level1_len()
7818        );
7819
7820        let tokens = Arc::new(Tokens::new(vec!["needle".to_owned()], DocType::Text));
7821        let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
7822        let prefilter = Arc::new(NoFilter);
7823        let metrics = Arc::new(NoOpMetricsCollector);
7824        let (row_ids, scores) = index
7825            .bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
7826            .await
7827            .unwrap();
7828
7829        assert_eq!(row_ids.len(), 10);
7830        assert_eq!(scores.len(), 10);
7831        assert!(row_ids.iter().all(|row_id| *row_id >= 1_000));
7832    }
7833
7834    #[tokio::test]
7835    async fn test_posting_builder_remap() {
7836        let posting_tail_codec = PostingTailCodec::Fixed32;
7837        let mut builder =
7838            PostingListBuilder::new_with_posting_tail_codec(false, posting_tail_codec);
7839        let n = BLOCK_SIZE + 3;
7840        for i in 0..n {
7841            builder.add(i as u32, PositionRecorder::Count(1));
7842        }
7843        let removed = vec![5, 7];
7844        builder.remap(&removed);
7845
7846        let mut expected =
7847            PostingListBuilder::new_with_posting_tail_codec(false, posting_tail_codec);
7848        for i in 0..n - removed.len() {
7849            expected.add(i as u32, PositionRecorder::Count(1));
7850        }
7851        let expected_entries = expected.iter().collect::<Vec<_>>();
7852        let actual_entries = builder.iter().collect::<Vec<_>>();
7853        assert_eq!(actual_entries, expected_entries);
7854
7855        // BLOCK_SIZE + 3 elements should be reduced to BLOCK_SIZE + 1,
7856        // there are still 2 blocks.
7857        let batch = builder.to_batch(vec![1.0, 2.0]).unwrap();
7858        let (doc_ids, freqs) = decompress_posting_list_with_tail_codec(
7859            (n - removed.len()) as u32,
7860            batch[POSTING_COL]
7861                .as_list::<i32>()
7862                .value(0)
7863                .as_binary::<i64>(),
7864            posting_tail_codec,
7865        )
7866        .unwrap();
7867        assert!(
7868            doc_ids
7869                .iter()
7870                .zip(expected_entries.iter().map(|(doc_id, _, _)| doc_id))
7871                .all(|(a, b)| a == b)
7872        );
7873        assert!(
7874            freqs
7875                .iter()
7876                .zip(expected_entries.iter().map(|(_, freq, _)| freq))
7877                .all(|(a, b)| a == b)
7878        );
7879    }
7880
7881    #[test]
7882    fn test_posting_builder_size_tracking_matches_structure() {
7883        fn tracked_memory_size(builder: &PostingListBuilder) -> u64 {
7884            let encoded_blocks_size = builder
7885                .encoded_blocks
7886                .iter()
7887                .map(|encoded_blocks| std::mem::size_of::<EncodedBlocks>() + encoded_blocks.size())
7888                .sum::<usize>();
7889            let encoded_positions_size = builder
7890                .encoded_position_blocks
7891                .as_ref()
7892                .map(|positions| std::mem::size_of::<EncodedPositionBlocks>() + positions.size())
7893                .unwrap_or(0usize);
7894            (encoded_blocks_size
7895                + builder.tail_entries.capacity() * std::mem::size_of::<RawDocInfo>()
7896                + builder.tail_positions.size()
7897                + encoded_positions_size) as u64
7898        }
7899
7900        let mut builder = PostingListBuilder::new(true);
7901        for doc_id in 0..(BLOCK_SIZE + 5) as u32 {
7902            builder.add(
7903                doc_id,
7904                PositionRecorder::Position(smallvec::smallvec![1, 3, 5]),
7905            );
7906        }
7907
7908        assert_eq!(builder.size(), tracked_memory_size(&builder));
7909    }
7910
7911    #[test]
7912    fn test_posting_builder_flush_releases_tail_position_capacity() {
7913        let mut builder = PostingListBuilder::new(true);
7914        let positions = smallvec::SmallVec::<[u32; 2]>::from_vec((0..1024).collect());
7915        for doc_id in 0..BLOCK_SIZE as u32 {
7916            builder.add(doc_id, PositionRecorder::Position(positions.clone()));
7917        }
7918
7919        assert_eq!(builder.tail_positions.size(), 0);
7920        assert_eq!(builder.size(), {
7921            let encoded_blocks_size = builder
7922                .encoded_blocks
7923                .iter()
7924                .map(|encoded_blocks| std::mem::size_of::<EncodedBlocks>() + encoded_blocks.size())
7925                .sum::<usize>();
7926            let encoded_positions_size = builder
7927                .encoded_position_blocks
7928                .as_ref()
7929                .map(|positions| std::mem::size_of::<EncodedPositionBlocks>() + positions.size())
7930                .unwrap_or(0usize);
7931            (encoded_blocks_size
7932                + builder.tail_entries.capacity() * std::mem::size_of::<RawDocInfo>()
7933                + builder.tail_positions.size()
7934                + encoded_positions_size) as u64
7935        });
7936    }
7937
7938    #[test]
7939    fn test_posting_builder_streamed_positions_roundtrip() {
7940        let mut builder = PostingListBuilder::new(true);
7941        assert!(builder.add_occurrence(0, 1).unwrap());
7942        assert!(!builder.add_occurrence(0, 4).unwrap());
7943        assert!(!builder.add_occurrence(0, 9).unwrap());
7944        builder.finish_open_doc(0).unwrap();
7945
7946        assert!(builder.add_occurrence(2, 3).unwrap());
7947        builder.finish_open_doc(2).unwrap();
7948
7949        let entries = builder.iter().collect::<Vec<_>>();
7950        assert_eq!(
7951            entries,
7952            vec![
7953                (0_u32, 3_u32, Some(vec![1_u32, 4_u32, 9_u32])),
7954                (2_u32, 1_u32, Some(vec![3_u32])),
7955            ]
7956        );
7957    }
7958
7959    #[test]
7960    fn test_shared_position_stream_clone_shares_block_offsets() {
7961        let stream = SharedPositionStream::new(
7962            PositionStreamCodec::PackedDelta,
7963            vec![0_u32, 4, 11],
7964            bytes::Bytes::from_static(b"shared position bytes"),
7965        );
7966        let original_offsets = stream.block_offsets().as_ptr();
7967
7968        let cloned = stream.clone();
7969
7970        assert_eq!(cloned.block_offsets(), stream.block_offsets());
7971        assert_eq!(cloned.block_offsets().as_ptr(), original_offsets);
7972    }
7973
7974    #[test]
7975    fn test_posting_builder_roundtrip_shared_positions() {
7976        let entries = vec![
7977            (0_u32, vec![1_u32, 5]),
7978            (2, vec![0, 4, 9]),
7979            (4, vec![7]),
7980            (8, vec![3, 10]),
7981            (13, vec![2, 11, 30]),
7982        ];
7983        let mut builder =
7984            PostingListBuilder::new_with_posting_tail_codec(true, PostingTailCodec::VarintDelta);
7985        for (doc_id, positions) in &entries {
7986            builder.add(
7987                *doc_id,
7988                PositionRecorder::Position(positions.clone().into()),
7989            );
7990        }
7991
7992        let batch = builder.to_batch(vec![1.0]).unwrap();
7993        assert!(batch.column_by_name(COMPRESSED_POSITION_COL).is_some());
7994        assert!(batch.column_by_name(POSITION_COL).is_none());
7995        assert_eq!(
7996            batch.schema_ref().metadata().get(POSTING_TAIL_CODEC_KEY),
7997            Some(&PostingTailCodec::VarintDelta.as_str().to_owned())
7998        );
7999        assert_eq!(
8000            batch.schema_ref().metadata().get(POSITIONS_LAYOUT_KEY),
8001            Some(&POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned())
8002        );
8003        assert_eq!(
8004            batch.schema_ref().metadata().get(POSITIONS_CODEC_KEY),
8005            Some(&PositionStreamCodec::PackedDelta.as_str().to_owned())
8006        );
8007
8008        let posting =
8009            PostingList::from_batch(&batch, Some(1.0), Some(entries.len() as u32)).unwrap();
8010        let actual = posting
8011            .iter()
8012            .map(|(doc_id, freq, positions)| {
8013                (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
8014            })
8015            .collect::<Vec<_>>();
8016        let expected = entries
8017            .iter()
8018            .map(|(doc_id, positions)| (*doc_id, positions.len() as u32, positions.clone()))
8019            .collect::<Vec<_>>();
8020        assert_eq!(actual, expected);
8021    }
8022
8023    #[test]
8024    fn test_posting_builder_roundtrip_legacy_positions() {
8025        let entries = vec![(0_u32, vec![1_u32, 5]), (2, vec![0, 4, 9]), (4, vec![7])];
8026        let mut builder =
8027            PostingListBuilder::new_with_posting_tail_codec(true, PostingTailCodec::Fixed32);
8028        for (doc_id, positions) in &entries {
8029            builder.add(
8030                *doc_id,
8031                PositionRecorder::Position(positions.clone().into()),
8032            );
8033        }
8034
8035        let batch = builder.to_batch(vec![1.0]).unwrap();
8036        assert!(batch.column_by_name(POSITION_COL).is_some());
8037        assert!(batch.column_by_name(COMPRESSED_POSITION_COL).is_none());
8038        assert_eq!(
8039            batch.schema_ref().metadata().get(POSTING_TAIL_CODEC_KEY),
8040            None
8041        );
8042        assert_eq!(
8043            batch.schema_ref().metadata().get(POSITIONS_LAYOUT_KEY),
8044            None
8045        );
8046        assert_eq!(batch.schema_ref().metadata().get(POSITIONS_CODEC_KEY), None);
8047
8048        let posting =
8049            PostingList::from_batch(&batch, Some(1.0), Some(entries.len() as u32)).unwrap();
8050        let actual = posting
8051            .iter()
8052            .map(|(doc_id, freq, positions)| {
8053                (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
8054            })
8055            .collect::<Vec<_>>();
8056        let expected = entries
8057            .iter()
8058            .map(|(doc_id, positions)| (*doc_id, positions.len() as u32, positions.clone()))
8059            .collect::<Vec<_>>();
8060        assert_eq!(actual, expected);
8061    }
8062
8063    #[test]
8064    fn test_resolve_fts_format_version_defaults_to_v2() {
8065        assert_eq!(
8066            resolve_fts_format_version(None).unwrap(),
8067            InvertedListFormatVersion::V2
8068        );
8069        assert_eq!(
8070            resolve_fts_format_version(Some("2")).unwrap(),
8071            InvertedListFormatVersion::V2
8072        );
8073        assert_eq!(
8074            resolve_fts_format_version(Some("3")).unwrap(),
8075            InvertedListFormatVersion::V3
8076        );
8077        assert!(resolve_fts_format_version(Some("4")).is_err());
8078    }
8079
8080    #[test]
8081    fn test_block_size_256_metadata_resolves_to_v3() {
8082        let metadata = HashMap::from([(POSTING_BLOCK_SIZE_KEY.to_owned(), "256".to_owned())]);
8083        assert_eq!(
8084            parse_format_version_from_metadata(&metadata).unwrap(),
8085            InvertedListFormatVersion::V3
8086        );
8087    }
8088
8089    #[test]
8090    fn test_legacy_compressed_positions_still_readable() {
8091        let doc_ids = [1_u32, 3_u32];
8092        let frequencies = [2_u32, 3_u32];
8093        let posting = compress_posting_list_with_tail_codec(
8094            doc_ids.len(),
8095            doc_ids.iter(),
8096            frequencies.iter(),
8097            std::iter::once(1.0_f32),
8098            PostingTailCodec::Fixed32,
8099        )
8100        .unwrap();
8101
8102        let mut posting_builder = ListBuilder::new(LargeBinaryBuilder::new());
8103        for idx in 0..posting.len() {
8104            posting_builder.values().append_value(posting.value(idx));
8105        }
8106        posting_builder.append(true);
8107
8108        let mut positions_builder = ListBuilder::new(ListBuilder::new(LargeBinaryBuilder::new()));
8109        for positions in [vec![1_u32, 5_u32], vec![0_u32, 4_u32, 9_u32]] {
8110            let compressed = compress_positions(&positions).unwrap();
8111            let doc_builder = positions_builder.values();
8112            for idx in 0..compressed.len() {
8113                doc_builder.values().append_value(compressed.value(idx));
8114            }
8115            doc_builder.append(true);
8116        }
8117        positions_builder.append(true);
8118
8119        let schema = Arc::new(Schema::new(vec![
8120            Field::new(
8121                POSTING_COL,
8122                DataType::List(Arc::new(Field::new("item", DataType::LargeBinary, true))),
8123                false,
8124            ),
8125            Field::new(MAX_SCORE_COL, DataType::Float32, false),
8126            Field::new(LENGTH_COL, DataType::UInt32, false),
8127            Field::new(
8128                POSITION_COL,
8129                DataType::List(Arc::new(Field::new(
8130                    "item",
8131                    DataType::List(Arc::new(Field::new("item", DataType::LargeBinary, true))),
8132                    true,
8133                ))),
8134                false,
8135            ),
8136        ]));
8137        let batch = RecordBatch::try_new(
8138            schema,
8139            vec![
8140                Arc::new(posting_builder.finish()) as ArrayRef,
8141                Arc::new(Float32Array::from(vec![1.0])) as ArrayRef,
8142                Arc::new(UInt32Array::from(vec![doc_ids.len() as u32])) as ArrayRef,
8143                Arc::new(positions_builder.finish()) as ArrayRef,
8144            ],
8145        )
8146        .unwrap();
8147
8148        let posting =
8149            PostingList::from_batch(&batch, Some(1.0), Some(doc_ids.len() as u32)).unwrap();
8150        let actual = posting
8151            .iter()
8152            .map(|(doc_id, freq, positions)| {
8153                (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
8154            })
8155            .collect::<Vec<_>>();
8156        assert_eq!(actual, vec![(1, 2, vec![1, 5]), (3, 3, vec![0, 4, 9]),]);
8157    }
8158
8159    #[test]
8160    fn test_shared_stream_v2_without_codec_still_readable() {
8161        let doc_ids = [1_u32, 3_u32];
8162        let frequencies = [2_u32, 3_u32];
8163        let posting = compress_posting_list_with_tail_codec(
8164            doc_ids.len(),
8165            doc_ids.iter(),
8166            frequencies.iter(),
8167            std::iter::once(1.0_f32),
8168            PostingTailCodec::Fixed32,
8169        )
8170        .unwrap();
8171
8172        let mut posting_builder = ListBuilder::new(LargeBinaryBuilder::new());
8173        for idx in 0..posting.len() {
8174            posting_builder.values().append_value(posting.value(idx));
8175        }
8176        posting_builder.append(true);
8177
8178        let positions = vec![1_u32, 5_u32, 0_u32, 4_u32, 9_u32];
8179        let mut encoded_positions = Vec::new();
8180        encode_position_stream_block_into(
8181            &positions,
8182            &frequencies,
8183            PositionStreamCodec::VarintDocDelta,
8184            &mut encoded_positions,
8185        )
8186        .unwrap();
8187
8188        let mut position_offsets = ListBuilder::new(UInt32Builder::new());
8189        position_offsets.values().append_value(0);
8190        position_offsets.append(true);
8191
8192        let schema = Arc::new(Schema::new_with_metadata(
8193            vec![
8194                Field::new(
8195                    POSTING_COL,
8196                    DataType::List(Arc::new(Field::new("item", DataType::LargeBinary, true))),
8197                    false,
8198                ),
8199                Field::new(MAX_SCORE_COL, DataType::Float32, false),
8200                Field::new(LENGTH_COL, DataType::UInt32, false),
8201                Field::new(COMPRESSED_POSITION_COL, DataType::LargeBinary, false),
8202                Field::new(
8203                    POSITION_BLOCK_OFFSET_COL,
8204                    DataType::List(Arc::new(Field::new("item", DataType::UInt32, true))),
8205                    false,
8206                ),
8207            ],
8208            HashMap::from([(
8209                POSITIONS_LAYOUT_KEY.to_owned(),
8210                POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(),
8211            )]),
8212        ));
8213        let batch = RecordBatch::try_new(
8214            schema,
8215            vec![
8216                Arc::new(posting_builder.finish()) as ArrayRef,
8217                Arc::new(Float32Array::from(vec![1.0])) as ArrayRef,
8218                Arc::new(UInt32Array::from(vec![doc_ids.len() as u32])) as ArrayRef,
8219                Arc::new(arrow_array::LargeBinaryArray::from(vec![Some(
8220                    encoded_positions.as_slice(),
8221                )])) as ArrayRef,
8222                Arc::new(position_offsets.finish()) as ArrayRef,
8223            ],
8224        )
8225        .unwrap();
8226
8227        let posting =
8228            PostingList::from_batch(&batch, Some(1.0), Some(doc_ids.len() as u32)).unwrap();
8229        let actual = posting
8230            .iter()
8231            .map(|(doc_id, freq, positions)| {
8232                (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
8233            })
8234            .collect::<Vec<_>>();
8235        assert_eq!(actual, vec![(1, 2, vec![1, 5]), (3, 3, vec![0, 4, 9]),]);
8236    }
8237
8238    #[test]
8239    fn test_shared_position_stream_is_smaller_for_sparse_positions() {
8240        let mut builder =
8241            PostingListBuilder::new_with_posting_tail_codec(true, PostingTailCodec::VarintDelta);
8242        let mut legacy_positions = Vec::with_capacity(BLOCK_SIZE * 4);
8243        for doc_id in 0..(BLOCK_SIZE * 4) as u32 {
8244            let mut positions = vec![doc_id * 3 + 1];
8245            if doc_id % 8 == 0 {
8246                positions.push(doc_id * 3 + 2);
8247            }
8248            builder.add(doc_id, PositionRecorder::Position(positions.clone().into()));
8249            legacy_positions.push(positions);
8250        }
8251
8252        let batch = builder.to_batch(vec![1.0; 4]).unwrap();
8253        let shared_positions_size = batch[COMPRESSED_POSITION_COL].get_buffer_memory_size()
8254            + batch[POSITION_BLOCK_OFFSET_COL].get_buffer_memory_size();
8255
8256        let mut positions_builder = ListBuilder::new(ListBuilder::new(LargeBinaryBuilder::new()));
8257        for positions in legacy_positions {
8258            let compressed = compress_positions(&positions).unwrap();
8259            let doc_builder = positions_builder.values();
8260            for idx in 0..compressed.len() {
8261                doc_builder.values().append_value(compressed.value(idx));
8262            }
8263            doc_builder.append(true);
8264        }
8265        positions_builder.append(true);
8266        let legacy_positions_size = positions_builder.finish().get_buffer_memory_size();
8267
8268        assert!(
8269            shared_positions_size < legacy_positions_size,
8270            "expected shared position stream to be smaller than legacy per-doc storage, shared={shared_positions_size}, legacy={legacy_positions_size}",
8271        );
8272    }
8273
8274    #[test]
8275    fn test_posting_list_batch_matches_docset_scoring() {
8276        let mut docs = DocSet::default();
8277        let num_docs = BLOCK_SIZE + 3;
8278        for doc_id in 0..num_docs as u32 {
8279            docs.append(doc_id as u64, doc_id % 7 + 1);
8280        }
8281
8282        let doc_ids = (0..num_docs as u32).collect::<Vec<_>>();
8283        let freqs = doc_ids
8284            .iter()
8285            .map(|doc_id| doc_id % 5 + 1)
8286            .collect::<Vec<_>>();
8287
8288        let mut builder_scores = PostingListBuilder::new(false);
8289        let mut builder_docs = PostingListBuilder::new(false);
8290        for (&doc_id, &freq) in doc_ids.iter().zip(freqs.iter()) {
8291            builder_scores.add(doc_id, PositionRecorder::Count(freq));
8292            builder_docs.add(doc_id, PositionRecorder::Count(freq));
8293        }
8294
8295        let block_max_scores = docs.calculate_block_max_scores(doc_ids.iter(), freqs.iter());
8296        let batch_scores = builder_scores.to_batch(block_max_scores).unwrap();
8297        let batch_docs = builder_docs
8298            .to_batch_with_docs(&docs, inverted_list_schema(false))
8299            .unwrap();
8300
8301        let scores_posting = batch_scores[POSTING_COL].as_list::<i32>().value(0);
8302        let scores_posting = scores_posting.as_binary::<i64>();
8303        let docs_posting = batch_docs[POSTING_COL].as_list::<i32>().value(0);
8304        let docs_posting = docs_posting.as_binary::<i64>();
8305        assert_eq!(scores_posting, docs_posting);
8306
8307        let score_left = batch_scores[MAX_SCORE_COL]
8308            .as_primitive::<Float32Type>()
8309            .value(0);
8310        let score_right = batch_docs[MAX_SCORE_COL]
8311            .as_primitive::<Float32Type>()
8312            .value(0);
8313        assert!((score_left - score_right).abs() < 1e-6);
8314
8315        let len_left = batch_scores[LENGTH_COL]
8316            .as_primitive::<UInt32Type>()
8317            .value(0);
8318        let len_right = batch_docs[LENGTH_COL].as_primitive::<UInt32Type>().value(0);
8319        assert_eq!(len_left, len_right);
8320    }
8321
8322    #[tokio::test]
8323    async fn test_remap_to_empty_posting_list() {
8324        let tmpdir = TempObjDir::default();
8325        let store = Arc::new(LanceIndexStore::new(
8326            ObjectStore::local().into(),
8327            tmpdir.clone(),
8328            Arc::new(LanceCache::no_cache()),
8329        ));
8330
8331        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
8332
8333        // index of docs:
8334        // 0: lance
8335        // 1: lake lake
8336        // 2: lake lake lake
8337        builder.tokens.add("lance".to_owned());
8338        builder.tokens.add("lake".to_owned());
8339        builder.posting_lists.push(PostingListBuilder::new(false));
8340        builder.posting_lists.push(PostingListBuilder::new(false));
8341        builder.posting_lists[0].add(0, PositionRecorder::Count(1));
8342        builder.posting_lists[1].add(1, PositionRecorder::Count(2));
8343        builder.posting_lists[1].add(2, PositionRecorder::Count(3));
8344        builder.docs.append(0, 1);
8345        builder.docs.append(1, 1);
8346        builder.docs.append(2, 1);
8347        builder.write(store.as_ref()).await.unwrap();
8348
8349        let index = InvertedPartition::load(
8350            store.clone(),
8351            0,
8352            None,
8353            &LanceCache::no_cache(),
8354            TokenSetFormat::default(),
8355        )
8356        .await
8357        .unwrap();
8358        let mut builder = index.into_builder().await.unwrap();
8359
8360        let mapping = HashMap::from([(0, None), (2, Some(3))]);
8361        builder.remap(&RowAddrRemap::direct(mapping)).await.unwrap();
8362
8363        // after remap, the doc 0 is removed, and the doc 2 is updated to 3
8364        assert_eq!(builder.tokens.len(), 1);
8365        assert_eq!(builder.tokens.get("lake"), Some(0));
8366        assert_eq!(builder.posting_lists.len(), 1);
8367        assert_eq!(builder.posting_lists[0].len(), 2);
8368        assert_eq!(builder.docs.len(), 2);
8369        assert_eq!(builder.docs.row_id(0), 1);
8370        assert_eq!(builder.docs.row_id(1), 3);
8371
8372        builder.write(store.as_ref()).await.unwrap();
8373
8374        // remap to delete all docs
8375        let mapping = HashMap::from([(1, None), (3, None)]);
8376        builder.remap(&RowAddrRemap::direct(mapping)).await.unwrap();
8377
8378        assert_eq!(builder.tokens.len(), 0);
8379        assert_eq!(builder.posting_lists.len(), 0);
8380        assert_eq!(builder.docs.len(), 0);
8381
8382        builder.write(store.as_ref()).await.unwrap();
8383    }
8384
8385    #[tokio::test]
8386    async fn test_posting_cache_conflict_across_partitions() {
8387        let tmpdir = TempObjDir::default();
8388        let store = Arc::new(LanceIndexStore::new(
8389            ObjectStore::local().into(),
8390            tmpdir.clone(),
8391            Arc::new(LanceCache::no_cache()),
8392        ));
8393
8394        // Create first partition with one token and posting list length 1
8395        let mut builder1 = InnerBuilder::new(0, false, TokenSetFormat::default());
8396        builder1.tokens.add("test".to_owned());
8397        builder1.posting_lists.push(PostingListBuilder::new(false));
8398        builder1.posting_lists[0].add(0, PositionRecorder::Count(1));
8399        builder1.docs.append(100, 1); // row_id=100, num_tokens=1
8400        builder1.write(store.as_ref()).await.unwrap();
8401
8402        // Create second partition with one token and posting list length 4
8403        let mut builder2 = InnerBuilder::new(1, false, TokenSetFormat::default());
8404        builder2.tokens.add("test".to_owned()); // Use same token to test cache prefix fix
8405        builder2.posting_lists.push(PostingListBuilder::new(false));
8406        builder2.posting_lists[0].add(0, PositionRecorder::Count(2));
8407        builder2.posting_lists[0].add(1, PositionRecorder::Count(1));
8408        builder2.posting_lists[0].add(2, PositionRecorder::Count(3));
8409        builder2.posting_lists[0].add(3, PositionRecorder::Count(1));
8410        builder2.docs.append(200, 2); // row_id=200, num_tokens=2
8411        builder2.docs.append(201, 1); // row_id=201, num_tokens=1
8412        builder2.docs.append(202, 3); // row_id=202, num_tokens=3
8413        builder2.docs.append(203, 1); // row_id=203, num_tokens=1
8414        builder2.write(store.as_ref()).await.unwrap();
8415
8416        // Create metadata file with both partitions
8417        let metadata = std::collections::HashMap::from_iter(vec![
8418            (
8419                "partitions".to_owned(),
8420                serde_json::to_string(&vec![0u64, 1u64]).unwrap(),
8421            ),
8422            (
8423                "params".to_owned(),
8424                serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
8425            ),
8426            (
8427                TOKEN_SET_FORMAT_KEY.to_owned(),
8428                TokenSetFormat::default().to_string(),
8429            ),
8430        ]);
8431        let mut writer = store
8432            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
8433            .await
8434            .unwrap();
8435        writer.finish_with_metadata(metadata).await.unwrap();
8436
8437        // Load the inverted index
8438        let cache = Arc::new(LanceCache::with_capacity(4096));
8439        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
8440            .await
8441            .unwrap();
8442
8443        // Verify the index structure
8444        assert_eq!(index.partitions.len(), 2);
8445        assert_eq!(index.partitions[0].tokens.len(), 1);
8446        assert_eq!(index.partitions[1].tokens.len(), 1);
8447
8448        // Verify the partitions were loaded correctly
8449
8450        // Verify posting list lengths (note: partition order may differ from creation order).
8451        // `posting_len_for_token` works for both legacy and v2 layouts without
8452        // forcing the V2-only bulk metadata load.
8453        let pl_0_0 = index.partitions[0]
8454            .inverted_list
8455            .posting_len_for_token(0)
8456            .await
8457            .unwrap();
8458        let pl_1_0 = index.partitions[1]
8459            .inverted_list
8460            .posting_len_for_token(0)
8461            .await
8462            .unwrap();
8463        if index.partitions[0].id() == 0 {
8464            assert_eq!(pl_0_0, 1);
8465            assert_eq!(pl_1_0, 4);
8466            assert_eq!(index.partitions[0].docs.len(), 1);
8467            assert_eq!(index.partitions[1].docs.len(), 4);
8468        } else {
8469            assert_eq!(pl_0_0, 4);
8470            assert_eq!(pl_1_0, 1);
8471            assert_eq!(index.partitions[0].docs.len(), 4);
8472            assert_eq!(index.partitions[1].docs.len(), 1);
8473        }
8474
8475        // Prewarm the inverted index (this loads posting lists into cache)
8476        index.prewarm().await.unwrap();
8477
8478        let tokens = Arc::new(Tokens::new(vec!["test".to_string()], DocType::Text));
8479        let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
8480        let prefilter = Arc::new(NoFilter);
8481        let metrics = Arc::new(NoOpMetricsCollector);
8482
8483        let (row_ids, scores) = index
8484            .bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
8485            .await
8486            .unwrap();
8487
8488        // Verify that we got search results
8489        // Expected to find 5 documents: 1 from first partition, 4 from second partition
8490        assert_eq!(row_ids.len(), 5, "row_ids: {:?}", row_ids);
8491        assert!(!row_ids.is_empty(), "Should find at least some documents");
8492        assert_eq!(row_ids.len(), scores.len());
8493
8494        // All scores should be positive since all documents contain the search token
8495        for &score in &scores {
8496            assert!(score > 0.0, "All scores should be positive");
8497        }
8498
8499        // Check that we got results from both partitions
8500        assert!(
8501            row_ids.contains(&100),
8502            "Should contain row_id from partition 0"
8503        );
8504        assert!(
8505            row_ids.iter().any(|&id| id >= 200),
8506            "Should contain row_id from partition 1"
8507        );
8508    }
8509
8510    #[tokio::test]
8511    async fn test_modern_prewarm_packs_group_with_shared_posting_buffer() {
8512        let tmpdir = TempObjDir::default();
8513        let store = Arc::new(LanceIndexStore::new(
8514            ObjectStore::local().into(),
8515            tmpdir.clone(),
8516            Arc::new(LanceCache::no_cache()),
8517        ));
8518
8519        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
8520        builder.tokens.add("alpha".to_owned());
8521        builder.tokens.add("beta".to_owned());
8522        builder.posting_lists.push(PostingListBuilder::new(false));
8523        builder.posting_lists.push(PostingListBuilder::new(false));
8524        builder.posting_lists[0].add(0, PositionRecorder::Count(1));
8525        builder.posting_lists[0].add(1, PositionRecorder::Count(2));
8526        builder.posting_lists[1].add(2, PositionRecorder::Count(3));
8527        builder.posting_lists[1].add(3, PositionRecorder::Count(4));
8528        builder.docs.append(100, 1);
8529        builder.docs.append(101, 2);
8530        builder.docs.append(102, 3);
8531        builder.docs.append(103, 4);
8532        builder.write(store.as_ref()).await.unwrap();
8533
8534        let metadata = std::collections::HashMap::from_iter(vec![
8535            (
8536                "partitions".to_owned(),
8537                serde_json::to_string(&vec![0u64]).unwrap(),
8538            ),
8539            (
8540                "params".to_owned(),
8541                serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
8542            ),
8543            (
8544                TOKEN_SET_FORMAT_KEY.to_owned(),
8545                TokenSetFormat::default().to_string(),
8546            ),
8547        ]);
8548        let mut writer = store
8549            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
8550            .await
8551            .unwrap();
8552        writer.finish_with_metadata(metadata).await.unwrap();
8553
8554        let cache = Arc::new(LanceCache::with_capacity(4096));
8555        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
8556            .await
8557            .unwrap();
8558        let inverted_list = &index.partitions[0].inverted_list;
8559        assert!(
8560            !inverted_list.is_legacy_layout(),
8561            "test should use modern posting layout"
8562        );
8563        assert!(
8564            inverted_list.has_impacts,
8565            "modern posting fixture should include impact skip data"
8566        );
8567
8568        inverted_list.prewarm_posting_lists(false, 2).await.unwrap();
8569
8570        // The two tiny tokens land in a single cache group [0, 2) (issue
8571        // #7040); both postings are read out of that group entry.
8572        let (start, end) = inverted_list.group_range_for_token(0).unwrap();
8573        let group = inverted_list
8574            .index_cache
8575            .get_with_key(&posting_list_group_cache_key(
8576                start,
8577                end,
8578                inverted_list.has_impacts,
8579            ))
8580            .await
8581            .unwrap();
8582
8583        assert!(
8584            group.is_packed(),
8585            "no-position prewarm should pack v2 groups"
8586        );
8587        assert!(
8588            group.needs_external_metadata(),
8589            "prewarmed packed groups must not duplicate reader score/length metadata"
8590        );
8591        let (alpha_score, alpha_len) = inverted_list.bulk_metadata_for_token(0);
8592        let PostingList::Compressed(alpha) = group
8593            .posting_list(0, alpha_score, alpha_len)
8594            .unwrap()
8595            .unwrap()
8596        else {
8597            panic!("expected compressed posting list for token 0");
8598        };
8599        let PostingList::Compressed(alpha_again) = group
8600            .posting_list(0, alpha_score, alpha_len)
8601            .unwrap()
8602            .unwrap()
8603        else {
8604            panic!("expected compressed posting list for repeated token 0 access");
8605        };
8606        let (beta_score, beta_len) = inverted_list.bulk_metadata_for_token(1);
8607        let PostingList::Compressed(beta) = group
8608            .posting_list(1, beta_score, beta_len)
8609            .unwrap()
8610            .unwrap()
8611        else {
8612            panic!("expected compressed posting list for token 1");
8613        };
8614
8615        assert!(
8616            alpha.impacts.is_some() && beta.impacts.is_some(),
8617            "packed prewarm must preserve impact skip data"
8618        );
8619        assert!(
8620            alpha
8621                .impacts
8622                .as_ref()
8623                .unwrap()
8624                .shares_derived_state_with(alpha_again.impacts.as_ref().unwrap()),
8625            "repeated packed slot access must share decoded impact state"
8626        );
8627        assert!(
8628            alpha.shares_first_docs_with(&alpha_again),
8629            "repeated packed slot access must share decoded block heads"
8630        );
8631        assert_eq!(
8632            alpha.block_first_docs().as_ptr(),
8633            alpha_again.block_first_docs().as_ptr(),
8634            "packed block heads should be decoded only once per slot"
8635        );
8636        assert_eq!(
8637            alpha.blocks.values().as_ptr(),
8638            beta.blocks.values().as_ptr(),
8639            "packed posting views should share the group's values buffer"
8640        );
8641    }
8642
8643    #[tokio::test]
8644    async fn test_packed_prewarm_groups_do_not_retain_the_full_chunk() {
8645        let tmpdir = TempObjDir::default();
8646        let store = Arc::new(LanceIndexStore::new(
8647            ObjectStore::local().into(),
8648            tmpdir.clone(),
8649            Arc::new(LanceCache::no_cache()),
8650        ));
8651
8652        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
8653        for token_id in 0..4u32 {
8654            builder.tokens.add(format!("t{token_id}"));
8655            let mut posting = PostingListBuilder::new(false);
8656            posting.add(token_id, PositionRecorder::Count(1));
8657            builder.posting_lists.push(posting);
8658            builder.docs.append(1000 + token_id as u64, 1);
8659        }
8660        builder.write(store.as_ref()).await.unwrap();
8661
8662        let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
8663        let cache = LanceCache::with_capacity(1 << 20);
8664        let mut posting_reader = PostingListReader::try_new(reader, &cache).await.unwrap();
8665        posting_reader.grouping = PostingGrouping::SyntheticFixed { group_size: 2 };
8666
8667        assert_eq!(
8668            posting_reader
8669                .prewarm_posting_lists_chunked(false, Some(4), 1)
8670                .await
8671                .unwrap(),
8672            1,
8673            "the test must read both groups in one prewarm chunk"
8674        );
8675
8676        let first_group = posting_reader
8677            .index_cache
8678            .get_with_key(&posting_list_group_cache_key(
8679                0,
8680                2,
8681                posting_reader.has_impacts,
8682            ))
8683            .await
8684            .unwrap();
8685        let second_group = posting_reader
8686            .index_cache
8687            .get_with_key(&posting_list_group_cache_key(
8688                2,
8689                4,
8690                posting_reader.has_impacts,
8691            ))
8692            .await
8693            .unwrap();
8694        let (first_score, first_len) = posting_reader.bulk_metadata_for_token(0);
8695        let PostingList::Compressed(first) = first_group
8696            .posting_list(0, first_score, first_len)
8697            .unwrap()
8698            .unwrap()
8699        else {
8700            panic!("expected compressed posting list in first group");
8701        };
8702        let (neighbor_score, neighbor_len) = posting_reader.bulk_metadata_for_token(1);
8703        let PostingList::Compressed(first_neighbor) = first_group
8704            .posting_list(1, neighbor_score, neighbor_len)
8705            .unwrap()
8706            .unwrap()
8707        else {
8708            panic!("expected compressed posting list in first group");
8709        };
8710        let (second_score, second_len) = posting_reader.bulk_metadata_for_token(2);
8711        let PostingList::Compressed(second) = second_group
8712            .posting_list(0, second_score, second_len)
8713            .unwrap()
8714            .unwrap()
8715        else {
8716            panic!("expected compressed posting list in second group");
8717        };
8718
8719        assert_eq!(
8720            first.blocks.values().as_ptr(),
8721            first_neighbor.blocks.values().as_ptr(),
8722            "postings in one group should share the group's values buffer"
8723        );
8724        assert_ne!(
8725            first.blocks.values().as_ptr(),
8726            second.blocks.values().as_ptr(),
8727            "each group must own a compact buffer instead of retaining the full chunk"
8728        );
8729    }
8730
8731    #[test]
8732    fn test_prewarm_chunk_ranges_preserve_group_boundaries() {
8733        let grouping = PostingGrouping::SyntheticFixed { group_size: 4 };
8734        assert_eq!(
8735            prewarm_chunk_ranges(&grouping, 13, 5),
8736            vec![(0, 4), (4, 8), (8, 13)],
8737            "grouped chunks may contain multiple groups but must never split one"
8738        );
8739        assert_eq!(
8740            prewarm_chunk_ranges(&PostingGrouping::None, 13, 5),
8741            vec![(0, 5), (5, 10), (10, 13)],
8742            "ungrouped chunk ranges should use plain token ranges"
8743        );
8744    }
8745
8746    #[test]
8747    fn test_synthetic_grouping_preserves_fixed_boundaries() {
8748        let grouping = PostingGrouping::SyntheticFixed { group_size: 4 };
8749        assert_eq!(
8750            grouping.range_for_token(5, 10),
8751            Some((4, 8)),
8752            "synthetic token groups should be fixed-size ranges"
8753        );
8754        assert_eq!(
8755            grouping.range_for_token(9, 10),
8756            Some((8, 10)),
8757            "the final synthetic group should end at token_count"
8758        );
8759        assert_eq!(
8760            prewarm_chunk_ranges(&grouping, 10, 6),
8761            vec![(0, 4), (4, 10)],
8762            "prewarm chunks may contain multiple synthetic groups but must not split one"
8763        );
8764        assert_eq!(
8765            grouping.ranges_for_chunk(4, 10, 10),
8766            vec![(4, 8), (8, 10)],
8767            "publish selection should enumerate synthetic groups in a chunk"
8768        );
8769    }
8770
8771    /// Prewarming a large partition in multiple chunks must end up holding exactly the
8772    /// same per-token posting lists (doc ids and frequencies) as the whole-file path.
8773    /// Parametrized over layout: the legacy-v1 chunk path rebases global offsets to
8774    /// chunk-local rows, while the modern one-row-per-token path covers both
8775    /// legacy-sized v2 and 256-doc v3 posting blocks.
8776    #[rstest::rstest]
8777    #[case::v1(InvertedListFormatVersion::V1, LEGACY_BLOCK_SIZE)]
8778    #[case::v2(InvertedListFormatVersion::V2, LEGACY_BLOCK_SIZE)]
8779    #[case::v3(InvertedListFormatVersion::V3, 256)]
8780    #[tokio::test]
8781    async fn test_prewarm_streams_in_chunks_preserves_content(
8782        #[case] format_version: InvertedListFormatVersion,
8783        #[case] block_size: usize,
8784    ) {
8785        let tmpdir = TempObjDir::default();
8786        let store = Arc::new(LanceIndexStore::new(
8787            ObjectStore::local().into(),
8788            tmpdir.clone(),
8789            Arc::new(LanceCache::no_cache()),
8790        ));
8791
8792        // One partition with enough tokens to span multiple runtime synthetic
8793        // groups and several docs per token.
8794        let num_tokens = runtime_posting_group_tokens() as u32 + 4;
8795        const DOCS_PER_TOKEN: u32 = 3;
8796        let posting_tail_codec = format_version.posting_tail_codec();
8797        let mut builder = InnerBuilder::new_with_format_version_and_block_size(
8798            0,
8799            false,
8800            TokenSetFormat::default(),
8801            format_version,
8802            block_size,
8803        );
8804        // expected[token] = [(doc_id, frequency)] in stored (doc-id) order.
8805        let mut expected: Vec<Vec<(u32, u32)>> = Vec::new();
8806        let mut doc_id = 0u64;
8807        for t in 0..num_tokens {
8808            builder.tokens.add(format!("tok_{t:03}"));
8809            let mut posting = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
8810                false,
8811                posting_tail_codec,
8812                block_size,
8813            );
8814            let mut docs = Vec::new();
8815            for _ in 0..DOCS_PER_TOKEN {
8816                posting.add(doc_id as u32, PositionRecorder::Count(1));
8817                builder.docs.append(doc_id, 1);
8818                docs.push((doc_id as u32, 1));
8819                doc_id += 1;
8820            }
8821            expected.push(docs);
8822            builder.posting_lists.push(posting);
8823        }
8824        builder.write(store.as_ref()).await.unwrap();
8825
8826        let params = InvertedIndexParams::default()
8827            .block_size(block_size)
8828            .unwrap();
8829        let metadata = std::collections::HashMap::from_iter(vec![
8830            (
8831                "partitions".to_owned(),
8832                serde_json::to_string(&vec![0u64]).unwrap(),
8833            ),
8834            ("params".to_owned(), serde_json::to_string(&params).unwrap()),
8835            (
8836                TOKEN_SET_FORMAT_KEY.to_owned(),
8837                TokenSetFormat::default().to_string(),
8838            ),
8839            (
8840                POSTING_TAIL_CODEC_KEY.to_owned(),
8841                posting_tail_codec.as_str().to_owned(),
8842            ),
8843            (
8844                FTS_FORMAT_VERSION_KEY.to_owned(),
8845                format_version.index_version().to_string(),
8846            ),
8847            (POSTING_BLOCK_SIZE_KEY.to_owned(), block_size.to_string()),
8848        ]);
8849        let mut writer = store
8850            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
8851            .await
8852            .unwrap();
8853        writer.finish_with_metadata(metadata).await.unwrap();
8854
8855        let cache = Arc::new(LanceCache::with_capacity(1 << 20));
8856        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
8857            .await
8858            .unwrap();
8859        let inverted_list = &index.partitions[0].inverted_list;
8860        assert_eq!(inverted_list.len(), num_tokens as usize);
8861        assert_eq!(inverted_list.block_size(), block_size);
8862
8863        // Force a small target chunk. Since CHUNK_TOKENS is below the runtime
8864        // group size, synthetic group alignment should still split only at
8865        // group boundaries.
8866        const CHUNK_TOKENS: usize = 6;
8867        let chunk_count = inverted_list
8868            .prewarm_posting_lists_chunked(false, Some(CHUNK_TOKENS), 2)
8869            .await
8870            .unwrap();
8871
8872        // (1) The partition was streamed in multiple chunks. The exact count is
8873        // group-alignment-dependent (chunks snap to whole groups), so just
8874        // require more than one.
8875        assert!(
8876            chunk_count > 1,
8877            "single partition must be streamed in more than one chunk, got {chunk_count}"
8878        );
8879
8880        if block_size == 256 {
8881            let (start, end) = inverted_list.group_range_for_token(0).unwrap();
8882            let group = inverted_list
8883                .index_cache
8884                .get_with_key(&posting_list_group_cache_key(
8885                    start,
8886                    end,
8887                    inverted_list.has_impacts,
8888                ))
8889                .await
8890                .expect("256-document blocks should populate the packed group cache");
8891            assert!(group.is_packed());
8892            let (max_score, length) = inverted_list.bulk_metadata_for_token(0);
8893            let PostingList::Compressed(posting) =
8894                group.posting_list(0, max_score, length).unwrap().unwrap()
8895            else {
8896                panic!("expected compressed posting list");
8897            };
8898            assert_eq!(posting.block_size, 256);
8899            assert!(
8900                posting.impacts.is_some(),
8901                "packed prewarm must preserve impact skip data"
8902            );
8903        }
8904
8905        // (2) Correctness: every token's posting list round-trips with exactly
8906        // the doc ids and frequencies of the whole-file path.
8907        for token_id in 0..num_tokens {
8908            let actual = inverted_list
8909                .posting_list(token_id, false, &NoOpMetricsCollector)
8910                .await
8911                .unwrap()
8912                .iter()
8913                .map(|(doc_id, freq, _positions)| (doc_id as u32, freq))
8914                .collect::<Vec<_>>();
8915            assert_eq!(
8916                actual, expected[token_id as usize],
8917                "token {token_id} posting list mismatch after chunked prewarm"
8918            );
8919        }
8920    }
8921
8922    /// With positions, the chunked prewarm must strip positions into their own
8923    /// per-token cache entries (leaving the posting cache positions-free) and still
8924    /// round-trip exact doc ids, frequencies, and positions across chunk boundaries.
8925    #[tokio::test]
8926    async fn test_prewarm_streams_in_chunks_with_positions() {
8927        let tmpdir = TempObjDir::default();
8928        let store = Arc::new(LanceIndexStore::new(
8929            ObjectStore::local().into(),
8930            tmpdir.clone(),
8931            Arc::new(LanceCache::no_cache()),
8932        ));
8933
8934        let format_version = InvertedListFormatVersion::V2;
8935        let posting_tail_codec = format_version.posting_tail_codec();
8936        let num_tokens = runtime_posting_group_tokens() as u32 + 4;
8937        const DOCS_PER_TOKEN: u32 = 3;
8938        let mut builder = InnerBuilder::new_with_format_version(
8939            0,
8940            true,
8941            TokenSetFormat::default(),
8942            format_version,
8943        );
8944        // expected[token] = [(doc_id, frequency, positions)].
8945        let mut expected: Vec<Vec<(u32, u32, Vec<u32>)>> = Vec::new();
8946        let mut doc_id = 0u64;
8947        for t in 0..num_tokens {
8948            builder.tokens.add(format!("tok_{t:03}"));
8949            let mut posting =
8950                PostingListBuilder::new_with_posting_tail_codec(true, posting_tail_codec);
8951            let mut docs = Vec::new();
8952            for _ in 0..DOCS_PER_TOKEN {
8953                let positions = vec![t % 3, t % 3 + 2, t % 3 + 5];
8954                posting.add(
8955                    doc_id as u32,
8956                    PositionRecorder::Position(positions.clone().into()),
8957                );
8958                builder.docs.append(doc_id, positions.len() as u32);
8959                docs.push((doc_id as u32, positions.len() as u32, positions));
8960                doc_id += 1;
8961            }
8962            expected.push(docs);
8963            builder.posting_lists.push(posting);
8964        }
8965        builder.write(store.as_ref()).await.unwrap();
8966
8967        let metadata = std::collections::HashMap::from_iter(vec![
8968            (
8969                "partitions".to_owned(),
8970                serde_json::to_string(&vec![0u64]).unwrap(),
8971            ),
8972            (
8973                "params".to_owned(),
8974                serde_json::to_string(&InvertedIndexParams::default().with_position(true)).unwrap(),
8975            ),
8976            (
8977                TOKEN_SET_FORMAT_KEY.to_owned(),
8978                TokenSetFormat::default().to_string(),
8979            ),
8980            (
8981                POSTING_TAIL_CODEC_KEY.to_owned(),
8982                posting_tail_codec.as_str().to_owned(),
8983            ),
8984            (
8985                POSITIONS_LAYOUT_KEY.to_owned(),
8986                POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(),
8987            ),
8988            (
8989                POSITIONS_CODEC_KEY.to_owned(),
8990                PositionStreamCodec::PackedDelta.as_str().to_owned(),
8991            ),
8992        ]);
8993        let mut writer = store
8994            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
8995            .await
8996            .unwrap();
8997        writer.finish_with_metadata(metadata).await.unwrap();
8998
8999        let cache = Arc::new(LanceCache::with_capacity(1 << 20));
9000        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
9001            .await
9002            .unwrap();
9003        let inverted_list = &index.partitions[0].inverted_list;
9004
9005        const CHUNK_TOKENS: usize = 5;
9006        let chunk_count = inverted_list
9007            .prewarm_posting_lists_chunked(true, Some(CHUNK_TOKENS), 2)
9008            .await
9009            .unwrap();
9010        assert!(
9011            chunk_count > 1,
9012            "partition must be streamed in more than one chunk, got {chunk_count}"
9013        );
9014
9015        for token_id in 0..num_tokens {
9016            // The prewarmed posting cache entry is positions-free.
9017            let (start, end) = inverted_list.group_range_for_token(token_id).unwrap();
9018            let group = inverted_list
9019                .index_cache
9020                .get_with_key(&posting_list_group_cache_key(
9021                    start,
9022                    end,
9023                    inverted_list.has_impacts,
9024                ))
9025                .await
9026                .unwrap();
9027            let slot = (token_id - start) as usize;
9028            assert!(
9029                !group.is_packed(),
9030                "with-position prewarm should retain the materialized fallback"
9031            );
9032            assert!(
9033                !group
9034                    .posting_list(slot, None, None)
9035                    .unwrap()
9036                    .unwrap()
9037                    .has_position(),
9038                "token {token_id} posting cache entry must be positions-free after prewarm"
9039            );
9040
9041            // Full content (doc ids, frequencies, positions) round-trips; the
9042            // positions come from the dedicated per-token cache prewarm populated.
9043            let actual = inverted_list
9044                .posting_list(token_id, true, &NoOpMetricsCollector)
9045                .await
9046                .unwrap()
9047                .iter()
9048                .map(|(doc_id, freq, positions)| {
9049                    (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
9050                })
9051                .collect::<Vec<_>>();
9052            assert_eq!(
9053                actual, expected[token_id as usize],
9054                "token {token_id} posting list / positions mismatch after chunked prewarm"
9055            );
9056        }
9057    }
9058
9059    /// IO accounting for the IO-counting stats test below: tracks bytes
9060    /// pulled from the posting file so we can assert that the stats path is
9061    /// O(1) in num_unique_tokens.
9062    #[derive(Debug, Default)]
9063    struct PostingMetadataCounter {
9064        rows_read: std::sync::atomic::AtomicUsize,
9065        metadata_rows_read: std::sync::atomic::AtomicUsize,
9066        read_range_calls: std::sync::atomic::AtomicUsize,
9067    }
9068
9069    impl PostingMetadataCounter {
9070        fn rows_read(&self) -> usize {
9071            self.rows_read.load(std::sync::atomic::Ordering::Relaxed)
9072        }
9073        fn metadata_rows_read(&self) -> usize {
9074            self.metadata_rows_read
9075                .load(std::sync::atomic::Ordering::Relaxed)
9076        }
9077        fn read_range_calls(&self) -> usize {
9078            self.read_range_calls
9079                .load(std::sync::atomic::Ordering::Relaxed)
9080        }
9081    }
9082
9083    struct CountingPostingReader {
9084        inner: Arc<dyn IndexReader>,
9085        counter: Arc<PostingMetadataCounter>,
9086    }
9087
9088    #[async_trait]
9089    impl IndexReader for CountingPostingReader {
9090        async fn read_record_batch(&self, n: u64, batch_size: u64) -> Result<RecordBatch> {
9091            self.inner.read_record_batch(n, batch_size).await
9092        }
9093        async fn read_global_buffer(&self, index: u32) -> Result<bytes::Bytes> {
9094            self.inner.read_global_buffer(index).await
9095        }
9096        async fn read_range(
9097            &self,
9098            range: std::ops::Range<usize>,
9099            projection: Option<&[&str]>,
9100        ) -> Result<RecordBatch> {
9101            let n = range.end - range.start;
9102            self.counter
9103                .read_range_calls
9104                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9105            self.counter
9106                .rows_read
9107                .fetch_add(n, std::sync::atomic::Ordering::Relaxed);
9108            let touches_metadata = projection
9109                .map(|cols| cols.contains(&MAX_SCORE_COL) || cols.contains(&LENGTH_COL))
9110                .unwrap_or(false);
9111            if touches_metadata {
9112                self.counter
9113                    .metadata_rows_read
9114                    .fetch_add(n, std::sync::atomic::Ordering::Relaxed);
9115            }
9116            self.inner.read_range(range, projection).await
9117        }
9118        async fn num_batches(&self, batch_size: u64) -> u32 {
9119            self.inner.num_batches(batch_size).await
9120        }
9121        fn num_rows(&self) -> usize {
9122            self.inner.num_rows()
9123        }
9124        fn schema(&self) -> &lance_core::datatypes::Schema {
9125            self.inner.schema()
9126        }
9127    }
9128
9129    #[derive(Debug)]
9130    struct CountingStore {
9131        inner: Arc<dyn IndexStore>,
9132        posting_file: String,
9133        counter: Arc<PostingMetadataCounter>,
9134    }
9135
9136    impl DeepSizeOf for CountingStore {
9137        fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
9138            self.inner.deep_size_of_children(context)
9139        }
9140    }
9141
9142    #[async_trait]
9143    impl IndexStore for CountingStore {
9144        fn as_any(&self) -> &dyn std::any::Any {
9145            self
9146        }
9147        fn clone_arc(&self) -> Arc<dyn IndexStore> {
9148            Arc::new(Self {
9149                inner: self.inner.clone(),
9150                posting_file: self.posting_file.clone(),
9151                counter: self.counter.clone(),
9152            })
9153        }
9154        fn io_parallelism(&self) -> usize {
9155            self.inner.io_parallelism()
9156        }
9157        fn with_io_priority(&self, io_priority: u64) -> Arc<dyn IndexStore> {
9158            Arc::new(Self {
9159                inner: self.inner.with_io_priority(io_priority),
9160                posting_file: self.posting_file.clone(),
9161                counter: self.counter.clone(),
9162            })
9163        }
9164        async fn new_index_file(
9165            &self,
9166            name: &str,
9167            schema: Arc<arrow_schema::Schema>,
9168        ) -> Result<Box<dyn crate::scalar::IndexWriter>> {
9169            self.inner.new_index_file(name, schema).await
9170        }
9171        async fn open_index_file(&self, name: &str) -> Result<Arc<dyn IndexReader>> {
9172            let reader = self.inner.open_index_file(name).await?;
9173            if name == self.posting_file {
9174                Ok(Arc::new(CountingPostingReader {
9175                    inner: reader,
9176                    counter: self.counter.clone(),
9177                }))
9178            } else {
9179                Ok(reader)
9180            }
9181        }
9182        async fn copy_index_file(
9183            &self,
9184            name: &str,
9185            dest_store: &dyn IndexStore,
9186        ) -> Result<crate::scalar::IndexFile> {
9187            self.inner.copy_index_file(name, dest_store).await
9188        }
9189        async fn copy_index_file_to(
9190            &self,
9191            name: &str,
9192            new_name: &str,
9193            dest_store: &dyn IndexStore,
9194        ) -> Result<crate::scalar::IndexFile> {
9195            self.inner
9196                .copy_index_file_to(name, new_name, dest_store)
9197                .await
9198        }
9199        async fn rename_index_file(
9200            &self,
9201            name: &str,
9202            new_name: &str,
9203        ) -> Result<crate::scalar::IndexFile> {
9204            self.inner.rename_index_file(name, new_name).await
9205        }
9206        async fn delete_index_file(&self, name: &str) -> Result<()> {
9207            self.inner.delete_index_file(name).await
9208        }
9209        async fn list_files_with_sizes(&self) -> Result<Vec<crate::scalar::IndexFile>> {
9210            self.inner.list_files_with_sizes().await
9211        }
9212    }
9213
9214    // Returns the `TempObjDir` guard so callers keep the backing store alive
9215    // for the index's lifetime: the deferred DocSet re-opens the docs file on
9216    // demand (it does not pin an open handle), so the files must still exist
9217    // when the test exercises a scoring path.
9218    async fn load_counted_v2_index(
9219        num_tokens: usize,
9220        cache: LanceCache,
9221    ) -> (Arc<InvertedIndex>, Arc<PostingMetadataCounter>, TempObjDir) {
9222        let tmpdir = TempObjDir::default();
9223        let inner_store = Arc::new(LanceIndexStore::new(
9224            ObjectStore::local().into(),
9225            tmpdir.clone(),
9226            Arc::new(LanceCache::no_cache()),
9227        ));
9228
9229        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
9230        for i in 0..num_tokens {
9231            builder.tokens.add(format!("t{}", i));
9232            let mut pl = PostingListBuilder::new(false);
9233            pl.add(i as u32, PositionRecorder::Count(1));
9234            builder.posting_lists.push(pl);
9235            builder.docs.append(i as u64, 1);
9236        }
9237        builder.write(inner_store.as_ref()).await.unwrap();
9238
9239        let metadata = HashMap::from([
9240            (
9241                "partitions".to_owned(),
9242                serde_json::to_string(&vec![0u64]).unwrap(),
9243            ),
9244            (
9245                "params".to_owned(),
9246                serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
9247            ),
9248            (
9249                TOKEN_SET_FORMAT_KEY.to_owned(),
9250                TokenSetFormat::default().to_string(),
9251            ),
9252        ]);
9253        let mut writer = inner_store
9254            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
9255            .await
9256            .unwrap();
9257        writer.finish_with_metadata(metadata).await.unwrap();
9258
9259        let counter = Arc::new(PostingMetadataCounter::default());
9260        let counting_store: Arc<dyn IndexStore> = Arc::new(CountingStore {
9261            inner: inner_store,
9262            posting_file: posting_file_path(0),
9263            counter: counter.clone(),
9264        });
9265        let index = InvertedIndex::load(counting_store, None, &cache)
9266            .await
9267            .unwrap();
9268        (index, counter, tmpdir)
9269    }
9270
9271    /// IO regression test for the lazy posting-metadata refactor. Builds a
9272    /// v2 InvertedIndex with `num_tokens` tokens in a single partition,
9273    /// wraps the IndexStore so reads against the posting file are counted,
9274    /// then asserts:
9275    ///
9276    /// * `InvertedIndex::load` does not touch the posting file at all
9277    ///   (`InvertedPartition::load` only needs the token file and docs file).
9278    /// * `bm25_stats_for_terms(["t0"])` reads exactly one metadata row from
9279    ///   the posting file for token 0 regardless of how many unique tokens the
9280    ///   partition has.
9281    ///
9282    /// Before this refactor, `PostingListReader::try_new` did
9283    /// `read_range(0..num_rows, [MAX_SCORE_COL, LENGTH_COL])`, so the
9284    /// `metadata_rows_read` figure scaled linearly with `num_tokens` even
9285    /// when nobody asked for those stats. The cases below exercise that
9286    /// scaling explicitly.
9287    #[rstest::rstest]
9288    #[case::tokens_10(10)]
9289    #[case::tokens_100(100)]
9290    #[case::tokens_1000(1000)]
9291    #[tokio::test]
9292    async fn test_bm25_stats_for_terms_is_lazy(#[case] num_tokens: usize) {
9293        let (index, counter, _tmpdir) =
9294            load_counted_v2_index(num_tokens, LanceCache::no_cache()).await;
9295        assert!(
9296            !index.partitions[0].inverted_list.is_legacy_layout(),
9297            "this test only proves the lazy path for v2 indexes",
9298        );
9299
9300        // Opening the partition must not pull anything from the posting file.
9301        // Pre-fix, `PostingListReader::try_new` issued one read_range here for
9302        // [MAX_SCORE_COL, LENGTH_COL] covering every unique token.
9303        assert_eq!(
9304            counter.read_range_calls(),
9305            0,
9306            "InvertedIndex::load must not read the posting file (was {} calls)",
9307            counter.read_range_calls(),
9308        );
9309        assert_eq!(counter.rows_read(), 0);
9310
9311        let (total_tokens, num_docs, dfs) = index
9312            .bm25_stats_for_terms(&["t0".to_string()])
9313            .await
9314            .unwrap();
9315        assert_eq!(total_tokens, num_tokens as u64);
9316        assert_eq!(num_docs, num_tokens);
9317        assert_eq!(dfs, vec![1]);
9318
9319        // Stats must pull a constant number of metadata rows from the posting
9320        // file regardless of how many tokens the partition has. One term, one
9321        // partition, one row.
9322        assert_eq!(
9323            counter.metadata_rows_read(),
9324            1,
9325            "stats path should read exactly 1 metadata row per (term, partition); \
9326             got {} (read_range_calls={}, rows_read={}, num_tokens={})",
9327            counter.metadata_rows_read(),
9328            counter.read_range_calls(),
9329            counter.rows_read(),
9330            num_tokens,
9331        );
9332    }
9333
9334    #[tokio::test]
9335    async fn test_bm25_stats_for_terms_reuses_posting_metadata_cache() {
9336        let cache = LanceCache::with_capacity(1024 * 1024);
9337        let (index, counter, _tmpdir) = load_counted_v2_index(100, cache.clone()).await;
9338
9339        let terms = ["t0".to_string()];
9340        let first = index.bm25_stats_for_terms(&terms).await.unwrap();
9341        assert_eq!(first, (100, 100, vec![1]));
9342        assert_eq!(counter.metadata_rows_read(), 1);
9343
9344        let second = index.bm25_stats_for_terms(&terms).await.unwrap();
9345        assert_eq!(second, first);
9346        assert_eq!(
9347            counter.metadata_rows_read(),
9348            1,
9349            "repeated stats for the same token should reuse cached posting metadata",
9350        );
9351    }
9352
9353    #[tokio::test]
9354    async fn test_aggregate_corpus_stats_reuses_cached_value() {
9355        let (index, _counter, _tmpdir) = load_counted_v2_index(100, LanceCache::no_cache()).await;
9356        assert!(index.corpus_stats.get().is_none());
9357
9358        let first = index.aggregate_corpus_stats().await.unwrap();
9359        assert_eq!(first, (100, 100));
9360        assert_eq!(index.corpus_stats.get().copied(), Some(first));
9361
9362        let second = index.aggregate_corpus_stats().await.unwrap();
9363        assert_eq!(second, first);
9364    }
9365
9366    #[tokio::test]
9367    async fn test_stats_then_num_tokens_view_reuses_shared_storage() {
9368        let (index, _counter, _tmpdir) = load_counted_v2_index(100, LanceCache::no_cache()).await;
9369        let partition = index.partitions[0].clone();
9370
9371        assert_eq!(index.aggregate_corpus_stats().await.unwrap(), (100, 100));
9372        assert_eq!(partition.docs.total_tokens_cached(), Some(100));
9373
9374        let views =
9375            futures::future::join_all((0..8).map(|_| partition.docs.ensure_num_tokens_loaded()))
9376                .await
9377                .into_iter()
9378                .collect::<Result<Vec<_>>>()
9379                .unwrap();
9380        let first = &views[0];
9381        assert!(views.iter().all(|view| Arc::ptr_eq(first, view)));
9382        assert!(!first.has_row_ids());
9383        assert!(matches!(&first.num_tokens, NumTokens::Shared(_)));
9384        assert_eq!(first.total_tokens_num(), 100);
9385
9386        let all_rows = RowAddrMask::all_rows();
9387        let wand_view = partition.docs.docs_for_wand(&all_rows).await.unwrap();
9388        assert!(Arc::ptr_eq(first, &wand_view));
9389
9390        let filtered = RowAddrMask::allow_nothing();
9391        let full = partition.docs.docs_for_wand(&filtered).await.unwrap();
9392        assert!(full.has_row_ids());
9393        assert!(matches!(&full.num_tokens, NumTokens::Owned(_)));
9394        assert_eq!(full.total_tokens_num(), 100);
9395        assert_eq!(
9396            partition.docs.resolve_row_ids(&[0, 99]).await.unwrap(),
9397            [0, 99]
9398        );
9399    }
9400
9401    #[tokio::test]
9402    async fn test_concurrent_total_and_num_tokens_view_initialization() {
9403        let (index, _counter, _tmpdir) = load_counted_v2_index(100, LanceCache::no_cache()).await;
9404        let docs = index.partitions[0].docs.clone();
9405
9406        let totals = futures::future::join_all((0..8).map(|_| docs.total_tokens_num()));
9407        let views = futures::future::join_all((0..8).map(|_| docs.ensure_num_tokens_loaded()));
9408        let (totals, views) = tokio::join!(totals, views);
9409
9410        let totals = totals.into_iter().collect::<Result<Vec<_>>>().unwrap();
9411        assert_eq!(totals, vec![100; 8]);
9412        let views = views.into_iter().collect::<Result<Vec<_>>>().unwrap();
9413        let first = &views[0];
9414        assert!(views.iter().all(|view| Arc::ptr_eq(first, view)));
9415        assert!(matches!(&first.num_tokens, NumTokens::Shared(_)));
9416        assert_eq!(docs.total_tokens_cached(), Some(100));
9417    }
9418
9419    #[tokio::test]
9420    async fn test_grouped_posting_lists_read_one_group_per_neighborhood() {
9421        // Cold-start scoring must not bulk-read the full `0..num_tokens`
9422        // metadata table. With small-posting grouping (issue #7040), scoring
9423        // K adjacent cold tokens shares a single group cache entry: one
9424        // read_range bounded by the group size, independent of the partition's
9425        // total token count.
9426        let runtime_group_size = runtime_posting_group_tokens().max(1);
9427        let queried_token_count = runtime_group_size.min(4);
9428        let queried_tokens = (0..queried_token_count as u32).collect::<Vec<_>>();
9429        let num_tokens = runtime_group_size
9430            .saturating_mul(2)
9431            .max(queried_token_count + 1)
9432            .min(1024);
9433        let (index, counter, _tmpdir) =
9434            load_counted_v2_index(num_tokens, LanceCache::no_cache()).await;
9435        let inverted_list = index.partitions[0].inverted_list.clone();
9436        assert!(
9437            !inverted_list.is_legacy_layout(),
9438            "this test only proves the lazy path for v2 indexes",
9439        );
9440        assert!(
9441            matches!(
9442                &inverted_list.grouping,
9443                PostingGrouping::SyntheticFixed { .. }
9444            ),
9445            "freshly written v2 index should use runtime synthetic groups",
9446        );
9447
9448        // This fixture uses a no-op cache, so each call re-reads; that isolates
9449        // the per-query read shape. Each posting_list call reads exactly its
9450        // own group — bounded by the group size, never the full token table.
9451        let metrics = Arc::new(NoOpMetricsCollector);
9452        for &token_id in &queried_tokens {
9453            inverted_list
9454                .posting_list(token_id, false, metrics.as_ref())
9455                .await
9456                .unwrap();
9457        }
9458
9459        let (start, end) = inverted_list.group_range_for_token(0).unwrap();
9460        let group_len = (end - start) as usize;
9461        assert!(
9462            (queried_tokens.len()..=num_tokens).contains(&group_len),
9463            "group [{start}, {end}) should cover the queried neighborhood and \
9464             stay bounded by the {num_tokens}-token table",
9465        );
9466        assert_eq!(
9467            counter.read_range_calls(),
9468            queried_tokens.len(),
9469            "each cold token should read exactly its own group, no bulk read",
9470        );
9471        assert_eq!(
9472            counter.metadata_rows_read(),
9473            queried_tokens.len() * group_len,
9474            "each query reads one group's metadata rows ({group_len}), not the \
9475             full {num_tokens}-row table",
9476        );
9477    }
9478
9479    /// Build a single-partition v2 index where every token's posting list spans
9480    /// `docs_per_token` docs. Runtime grouping packs consecutive token rows
9481    /// into shared cache groups.
9482    async fn load_v2_index_with_grouped_postings(
9483        num_tokens: usize,
9484        docs_per_token: usize,
9485    ) -> (Arc<InvertedIndex>, Arc<LanceCache>) {
9486        let tmpdir = TempObjDir::default();
9487        let store = Arc::new(LanceIndexStore::new(
9488            ObjectStore::local().into(),
9489            tmpdir.clone(),
9490            Arc::new(LanceCache::no_cache()),
9491        ));
9492
9493        let num_docs = num_tokens * docs_per_token;
9494        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
9495        for token_id in 0..num_tokens {
9496            builder.tokens.add(format!("t{token_id}"));
9497            let mut pl = PostingListBuilder::new(false);
9498            for d in 0..docs_per_token {
9499                let doc_id = (token_id * docs_per_token + d) as u32;
9500                pl.add(doc_id, PositionRecorder::Count(1));
9501            }
9502            builder.posting_lists.push(pl);
9503        }
9504        for doc in 0..num_docs {
9505            builder.docs.append(doc as u64, 1);
9506        }
9507        builder.write(store.as_ref()).await.unwrap();
9508
9509        let metadata = HashMap::from([
9510            (
9511                "partitions".to_owned(),
9512                serde_json::to_string(&vec![0u64]).unwrap(),
9513            ),
9514            (
9515                "params".to_owned(),
9516                serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
9517            ),
9518            (
9519                TOKEN_SET_FORMAT_KEY.to_owned(),
9520                TokenSetFormat::default().to_string(),
9521            ),
9522        ]);
9523        let mut writer = store
9524            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
9525            .await
9526            .unwrap();
9527        writer.finish_with_metadata(metadata).await.unwrap();
9528
9529        // The inverted list keeps only a `WeakLanceCache`, so the caller must
9530        // hold this `Arc<LanceCache>` alive for the cache to stay usable.
9531        let cache = Arc::new(LanceCache::with_capacity(1 << 30));
9532        let index = InvertedIndex::load(store, None, cache.as_ref())
9533            .await
9534            .unwrap();
9535        (index, cache)
9536    }
9537
9538    /// Packed groups charge their Arrow buffers and contiguous metadata once,
9539    /// avoiding the per-member enum/array object graph of a materialized group.
9540    #[tokio::test]
9541    async fn test_packed_group_deep_size_is_smaller_than_materialized_graph() {
9542        let (index, _cache) = load_v2_index_with_grouped_postings(512, 1).await;
9543        let inverted_list = index.partitions[0].inverted_list.clone();
9544        assert!(!inverted_list.is_legacy_layout(), "expected v2 layout");
9545        assert!(
9546            matches!(
9547                &inverted_list.grouping,
9548                PostingGrouping::SyntheticFixed { .. }
9549            ),
9550            "expected grouped posting lists"
9551        );
9552
9553        // Populate the group cache via the same path a query uses.
9554        inverted_list
9555            .posting_list(0, false, &NoOpMetricsCollector)
9556            .await
9557            .unwrap();
9558        let (start, end) = inverted_list.group_range_for_token(0).unwrap();
9559        let group = inverted_list
9560            .index_cache
9561            .get_with_key(&posting_list_group_cache_key(
9562                start,
9563                end,
9564                inverted_list.has_impacts,
9565            ))
9566            .await
9567            .unwrap();
9568        assert!(group.is_packed(), "cold v2 group should use packed storage");
9569        inverted_list.ensure_metadata_loaded().await.unwrap();
9570
9571        let mut distinct_buffers = std::collections::HashSet::new();
9572        let mut materialized = Vec::with_capacity(group.len());
9573        for slot in 0..group.len() {
9574            let (max_score, length) = inverted_list.bulk_metadata_for_token(start + slot as u32);
9575            let posting = group
9576                .posting_list(slot, max_score, length)
9577                .unwrap()
9578                .unwrap();
9579            let PostingList::Compressed(compressed) = posting else {
9580                panic!("expected compressed posting lists");
9581            };
9582            distinct_buffers.insert(compressed.blocks.values().as_ptr());
9583            materialized.push(PostingList::Compressed(compressed));
9584        }
9585        let posting_count = materialized.len();
9586
9587        assert!(
9588            posting_count > 1,
9589            "default grouping should pack multiple tiny postings into one group"
9590        );
9591        assert_eq!(
9592            distinct_buffers.len(),
9593            1,
9594            "read-path postings in a group should share one backing buffer"
9595        );
9596        let packed_size = group.deep_size_of();
9597        let materialized_size = PostingListGroup::new(materialized).deep_size_of();
9598        assert!(
9599            packed_size * 4 < materialized_size * 3,
9600            "packed group deep_size_of {packed_size}B should be at least 25% smaller than the \
9601             {materialized_size}B materialized graph for {posting_count} postings"
9602        );
9603    }
9604
9605    // ===========================================================================
9606    // Regression tests for index-cache size accounting of cached posting lists.
9607    //
9608    // A cached posting list is a *slice* of a buffer read for a whole posting-list
9609    // group, so its `DeepSizeOf` impl must charge only the bytes the slice
9610    // references, not the full shared backing buffer. These lock that in: each
9611    // builds an array that references a small slice of a much larger buffer and
9612    // asserts `deep_size_of()` tracks the slice, not the buffer.
9613    // ===========================================================================
9614
9615    /// Build a `List<Int32>` of `num_sublists` x `ints_per_sublist`, then return
9616    /// the slice `[off, off + len)`. The returned array shares the full backing
9617    /// buffers, so `values().get_buffer_memory_size()` still reports the whole
9618    /// thing — the slicing-unaware over-count the fix targets.
9619    fn sliced_int32_list(
9620        num_sublists: usize,
9621        ints_per_sublist: usize,
9622        off: usize,
9623        len: usize,
9624    ) -> ListArray {
9625        let mut builder = ListBuilder::new(Int32Builder::new());
9626        for s in 0..num_sublists {
9627            for i in 0..ints_per_sublist {
9628                builder
9629                    .values()
9630                    .append_value((s * ints_per_sublist + i) as i32);
9631            }
9632            builder.append(true);
9633        }
9634        builder.finish().slice(off, len)
9635    }
9636
9637    #[test]
9638    fn test_compressed_posting_deep_size_counts_only_referenced_blocks_slice() {
9639        const ELEM_BYTES: usize = 256;
9640        const TOTAL_ELEMS: usize = 64;
9641        const SLICE_OFF: usize = 10;
9642        const SLICE_LEN: usize = 2;
9643
9644        let mut builder = LargeBinaryBuilder::new();
9645        for _ in 0..TOTAL_ELEMS {
9646            builder.append_value(vec![7u8; ELEM_BYTES]);
9647        }
9648        let full = builder.finish();
9649        let blocks = full.slice(SLICE_OFF, SLICE_LEN);
9650
9651        let posting = CompressedPostingList::new(
9652            blocks,
9653            1.0,
9654            SLICE_LEN as u32,
9655            PostingTailCodec::Fixed32,
9656            LEGACY_BLOCK_SIZE,
9657            None,
9658            None,
9659        );
9660
9661        let full_backing = full.get_buffer_memory_size();
9662        let slice_bytes = SLICE_LEN * ELEM_BYTES;
9663        let reported = posting.deep_size_of();
9664
9665        assert!(
9666            reported < full_backing / 4,
9667            "deep_size_of {reported}B must not count the {full_backing}B shared buffer"
9668        );
9669        assert!(
9670            reported <= slice_bytes * 2,
9671            "deep_size_of {reported}B should track the ~{slice_bytes}B referenced slice"
9672        );
9673    }
9674
9675    #[test]
9676    fn test_plain_posting_deep_size_counts_only_referenced_positions_slice() {
9677        const SUBLISTS: usize = 64;
9678        const INTS: usize = 64;
9679        const SLICE_LEN: usize = 2;
9680
9681        let positions = sliced_int32_list(SUBLISTS, INTS, 10, SLICE_LEN);
9682        let row_ids = ScalarBuffer::from(vec![0u64, 1]);
9683        let frequencies = ScalarBuffer::from(vec![1.0f32, 1.0]);
9684        let posting =
9685            PlainPostingList::new(row_ids, frequencies, Some(1.0), Some(positions.clone()));
9686
9687        let full_backing = positions.values().get_buffer_memory_size();
9688        let slice_bytes = SLICE_LEN * INTS * std::mem::size_of::<i32>();
9689        let reported = posting.deep_size_of();
9690
9691        assert!(
9692            reported < full_backing / 4,
9693            "deep_size_of {reported}B must not count the {full_backing}B shared positions buffer"
9694        );
9695        assert!(
9696            reported <= slice_bytes * 2 + 64,
9697            "deep_size_of {reported}B should track the ~{slice_bytes}B referenced slice"
9698        );
9699    }
9700
9701    #[test]
9702    fn test_legacy_per_doc_positions_deep_size_counts_only_referenced_slice() {
9703        const SUBLISTS: usize = 64;
9704        const INTS: usize = 64;
9705        const SLICE_LEN: usize = 2;
9706
9707        let positions = sliced_int32_list(SUBLISTS, INTS, 10, SLICE_LEN);
9708        let full_backing = positions.values().get_buffer_memory_size();
9709        let slice_bytes = SLICE_LEN * INTS * std::mem::size_of::<i32>();
9710
9711        let storage = CompressedPositionStorage::LegacyPerDoc(positions);
9712        let reported = storage.deep_size_of();
9713        assert!(
9714            reported < full_backing / 4,
9715            "CompressedPositionStorage deep_size_of {reported}B must not count the \
9716             {full_backing}B shared buffer"
9717        );
9718        assert!(
9719            reported <= slice_bytes * 2 + 64,
9720            "deep_size_of {reported}B should track the ~{slice_bytes}B referenced slice"
9721        );
9722
9723        // The `Positions` cache wrapper must report the same slice-aware size.
9724        let wrapped = Positions(storage).deep_size_of();
9725        assert!(
9726            wrapped < full_backing / 4,
9727            "Positions deep_size_of {wrapped}B must not count the {full_backing}B shared buffer"
9728        );
9729    }
9730
9731    #[tokio::test]
9732    async fn test_prewarm_with_positions_populates_separate_position_cache() {
9733        let tmpdir = TempObjDir::default();
9734        let store = Arc::new(LanceIndexStore::new(
9735            ObjectStore::local().into(),
9736            tmpdir.clone(),
9737            Arc::new(LanceCache::no_cache()),
9738        ));
9739
9740        let mut builder = InnerBuilder::new_with_format_version(
9741            0,
9742            true,
9743            TokenSetFormat::default(),
9744            InvertedListFormatVersion::V1,
9745        );
9746        builder.tokens.add("hello".to_owned());
9747        builder.tokens.add("world".to_owned());
9748        builder
9749            .posting_lists
9750            .push(PostingListBuilder::new_with_posting_tail_codec(
9751                true,
9752                PostingTailCodec::Fixed32,
9753            ));
9754        builder
9755            .posting_lists
9756            .push(PostingListBuilder::new_with_posting_tail_codec(
9757                true,
9758                PostingTailCodec::Fixed32,
9759            ));
9760        builder.posting_lists[0].add(0, PositionRecorder::Position(vec![0].into()));
9761        builder.posting_lists[1].add(0, PositionRecorder::Position(vec![1].into()));
9762        builder.posting_lists[0].add(1, PositionRecorder::Position(vec![0].into()));
9763        builder.posting_lists[1].add(1, PositionRecorder::Position(vec![2].into()));
9764        builder.docs.append(100, 2);
9765        builder.docs.append(101, 2);
9766        builder.write(store.as_ref()).await.unwrap();
9767
9768        let metadata = std::collections::HashMap::from_iter(vec![
9769            (
9770                "partitions".to_owned(),
9771                serde_json::to_string(&vec![0_u64]).unwrap(),
9772            ),
9773            (
9774                "params".to_owned(),
9775                serde_json::to_string(&InvertedIndexParams::default().with_position(true)).unwrap(),
9776            ),
9777            (
9778                TOKEN_SET_FORMAT_KEY.to_owned(),
9779                TokenSetFormat::default().to_string(),
9780            ),
9781        ]);
9782        let mut writer = store
9783            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
9784            .await
9785            .unwrap();
9786        writer.finish_with_metadata(metadata).await.unwrap();
9787
9788        let cache = Arc::new(LanceCache::with_capacity(4096));
9789        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
9790            .await
9791            .unwrap();
9792
9793        index
9794            .prewarm_with_options(&FtsPrewarmOptions::new().with_position(true))
9795            .await
9796            .unwrap();
9797
9798        let inverted_list = &index.partitions[0].inverted_list;
9799        // The posting cache entry is grouped (issue #7040); the group holds
9800        // positions-free lists while positions live in their own per-token
9801        // entries.
9802        let (start, end) = inverted_list.group_range_for_token(0).unwrap();
9803        let group = inverted_list
9804            .index_cache
9805            .get_with_key(&posting_list_group_cache_key(
9806                start,
9807                end,
9808                inverted_list.has_impacts,
9809            ))
9810            .await
9811            .unwrap();
9812        assert!(
9813            !group.is_packed(),
9814            "with-position prewarm should retain the materialized fallback"
9815        );
9816        assert!(
9817            !group
9818                .posting_list(0, None, None)
9819                .unwrap()
9820                .unwrap()
9821                .has_position(),
9822            "posting cache should remain positions-free after prewarm"
9823        );
9824
9825        let positions = inverted_list
9826            .index_cache
9827            .get_with_key(&PositionKey { token_id: 0 })
9828            .await
9829            .unwrap();
9830        assert!(
9831            matches!(
9832                positions.as_ref().0,
9833                CompressedPositionStorage::LegacyPerDoc(_)
9834            ),
9835            "positions should be stored in the dedicated position cache"
9836        );
9837    }
9838
9839    #[tokio::test]
9840    async fn test_prewarm_with_v2_positions_preserves_shared_stream_codec() {
9841        let tmpdir = TempObjDir::default();
9842        let store = Arc::new(LanceIndexStore::new(
9843            ObjectStore::local().into(),
9844            tmpdir.clone(),
9845            Arc::new(LanceCache::no_cache()),
9846        ));
9847
9848        let format_version = InvertedListFormatVersion::V2;
9849        let posting_tail_codec = format_version.posting_tail_codec();
9850        let mut builder = InnerBuilder::new_with_format_version(
9851            0,
9852            true,
9853            TokenSetFormat::default(),
9854            format_version,
9855        );
9856        builder.tokens.add("body".to_owned());
9857
9858        let mut posting_list =
9859            PostingListBuilder::new_with_posting_tail_codec(true, posting_tail_codec);
9860        let expected = (0..(BLOCK_SIZE + 5) as u32)
9861            .map(|doc_id| {
9862                let positions = vec![doc_id % 3, doc_id % 3 + 2, doc_id % 3 + 5];
9863                posting_list.add(doc_id, PositionRecorder::Position(positions.clone().into()));
9864                builder.docs.append(30_000 + doc_id as u64, 20 + doc_id % 7);
9865                (doc_id, positions.len() as u32, positions)
9866            })
9867            .collect::<Vec<_>>();
9868        builder.posting_lists.push(posting_list);
9869        builder.write(store.as_ref()).await.unwrap();
9870
9871        let metadata = HashMap::from([
9872            (
9873                "partitions".to_owned(),
9874                serde_json::to_string(&vec![0_u64]).unwrap(),
9875            ),
9876            (
9877                "params".to_owned(),
9878                serde_json::to_string(&InvertedIndexParams::default().with_position(true)).unwrap(),
9879            ),
9880            (
9881                TOKEN_SET_FORMAT_KEY.to_owned(),
9882                TokenSetFormat::default().to_string(),
9883            ),
9884            (
9885                POSTING_TAIL_CODEC_KEY.to_owned(),
9886                posting_tail_codec.as_str().to_owned(),
9887            ),
9888            (
9889                POSITIONS_LAYOUT_KEY.to_owned(),
9890                POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(),
9891            ),
9892            (
9893                POSITIONS_CODEC_KEY.to_owned(),
9894                PositionStreamCodec::PackedDelta.as_str().to_owned(),
9895            ),
9896        ]);
9897        let mut writer = store
9898            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
9899            .await
9900            .unwrap();
9901        writer.finish_with_metadata(metadata).await.unwrap();
9902
9903        let cache = Arc::new(LanceCache::with_capacity(4096));
9904        let index = InvertedIndex::load(store, None, cache.as_ref())
9905            .await
9906            .unwrap();
9907        index
9908            .prewarm_with_options(&FtsPrewarmOptions::new().with_position(true))
9909            .await
9910            .unwrap();
9911
9912        let actual = index.partitions[0]
9913            .inverted_list
9914            .posting_list(0, true, &NoOpMetricsCollector)
9915            .await
9916            .unwrap()
9917            .iter()
9918            .map(|(doc_id, freq, positions)| {
9919                (doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
9920            })
9921            .collect::<Vec<_>>();
9922
9923        assert_eq!(actual, expected);
9924    }
9925
9926    #[test]
9927    fn test_block_max_scores_capacity_matches_block_count() {
9928        let mut docs = DocSet::default();
9929        let num_docs = BLOCK_SIZE * 3 + 7;
9930        let doc_ids = (0..num_docs as u32).collect::<Vec<_>>();
9931        for doc_id in &doc_ids {
9932            docs.append(*doc_id as u64, 1);
9933        }
9934
9935        let freqs = vec![1_u32; doc_ids.len()];
9936        let block_max_scores = docs.calculate_block_max_scores(doc_ids.iter(), freqs.iter());
9937        let expected_blocks = doc_ids.len().div_ceil(BLOCK_SIZE);
9938
9939        assert_eq!(block_max_scores.len(), expected_blocks);
9940        assert_eq!(block_max_scores.capacity(), expected_blocks);
9941    }
9942
9943    #[tokio::test]
9944    async fn test_bm25_search_uses_global_idf() {
9945        let tmpdir = TempObjDir::default();
9946        let store = Arc::new(LanceIndexStore::new(
9947            ObjectStore::local().into(),
9948            tmpdir.clone(),
9949            Arc::new(LanceCache::no_cache()),
9950        ));
9951
9952        // Partition 0: 3 docs, only one contains "alpha".
9953        let mut builder0 = InnerBuilder::new(0, false, TokenSetFormat::default());
9954        builder0.tokens.add("alpha".to_owned());
9955        builder0.tokens.add("beta".to_owned());
9956        builder0.posting_lists.push(PostingListBuilder::new(false));
9957        builder0.posting_lists.push(PostingListBuilder::new(false));
9958        builder0.posting_lists[0].add(0, PositionRecorder::Count(1));
9959        builder0.posting_lists[1].add(1, PositionRecorder::Count(1));
9960        builder0.posting_lists[1].add(2, PositionRecorder::Count(1));
9961        builder0.docs.append(100, 1);
9962        builder0.docs.append(101, 1);
9963        builder0.docs.append(102, 1);
9964        builder0.write(store.as_ref()).await.unwrap();
9965
9966        // Partition 1: 1 doc, contains "alpha".
9967        let mut builder1 = InnerBuilder::new(1, false, TokenSetFormat::default());
9968        builder1.tokens.add("alpha".to_owned());
9969        builder1.posting_lists.push(PostingListBuilder::new(false));
9970        builder1.posting_lists[0].add(0, PositionRecorder::Count(1));
9971        builder1.docs.append(200, 1);
9972        builder1.write(store.as_ref()).await.unwrap();
9973
9974        let metadata = std::collections::HashMap::from_iter(vec![
9975            (
9976                "partitions".to_owned(),
9977                serde_json::to_string(&vec![0u64, 1u64]).unwrap(),
9978            ),
9979            (
9980                "params".to_owned(),
9981                serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
9982            ),
9983            (
9984                TOKEN_SET_FORMAT_KEY.to_owned(),
9985                TokenSetFormat::default().to_string(),
9986            ),
9987        ]);
9988        let mut writer = store
9989            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
9990            .await
9991            .unwrap();
9992        writer.finish_with_metadata(metadata).await.unwrap();
9993
9994        let cache = Arc::new(LanceCache::with_capacity(4096));
9995        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
9996            .await
9997            .unwrap();
9998
9999        let tokens = Arc::new(Tokens::new(vec!["alpha".to_string()], DocType::Text));
10000        let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
10001        let prefilter = Arc::new(NoFilter);
10002        let metrics = Arc::new(NoOpMetricsCollector);
10003
10004        let (row_ids, scores) = index
10005            .bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
10006            .await
10007            .unwrap();
10008
10009        assert_eq!(row_ids.len(), 2);
10010        assert!(row_ids.contains(&100));
10011        assert!(row_ids.contains(&200));
10012        assert_eq!(row_ids.len(), scores.len());
10013
10014        let expected_idf = idf(2, 4);
10015        for score in scores {
10016            assert!(
10017                (score - expected_idf).abs() < 1e-6,
10018                "score: {}, expected: {}",
10019                score,
10020                expected_idf
10021            );
10022        }
10023    }
10024
10025    async fn write_test_metadata(
10026        store: &Arc<LanceIndexStore>,
10027        partition_ids: Vec<u64>,
10028        params: InvertedIndexParams,
10029    ) {
10030        let format_version = params.resolved_format_version();
10031        let metadata = HashMap::from([
10032            (
10033                "partitions".to_owned(),
10034                serde_json::to_string(&partition_ids).unwrap(),
10035            ),
10036            ("params".to_owned(), serde_json::to_string(&params).unwrap()),
10037            (
10038                TOKEN_SET_FORMAT_KEY.to_owned(),
10039                TokenSetFormat::default().to_string(),
10040            ),
10041            (
10042                POSTING_TAIL_CODEC_KEY.to_owned(),
10043                format_version.posting_tail_codec().as_str().to_owned(),
10044            ),
10045            (
10046                FTS_FORMAT_VERSION_KEY.to_owned(),
10047                format_version.index_version().to_string(),
10048            ),
10049            (
10050                POSTING_BLOCK_SIZE_KEY.to_owned(),
10051                params.posting_block_size().to_string(),
10052            ),
10053        ]);
10054        let mut writer = store
10055            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
10056            .await
10057            .unwrap();
10058        writer.finish_with_metadata(metadata).await.unwrap();
10059    }
10060
10061    async fn write_test_partition_with_optional_impacts(
10062        store: &Arc<LanceIndexStore>,
10063        partition_id: u64,
10064        mut builder: InnerBuilder,
10065        token_set_format: TokenSetFormat,
10066        with_impacts: bool,
10067    ) {
10068        let format_version = InvertedListFormatVersion::V1;
10069        let block_size = LEGACY_BLOCK_SIZE;
10070        let docs = std::mem::take(&mut builder.docs);
10071        let schema = inverted_list_schema_for_version_with_block_size_and_impacts(
10072            false,
10073            format_version,
10074            block_size,
10075            with_impacts,
10076        );
10077
10078        let mut posting_writer = store
10079            .new_index_file(&posting_file_path(partition_id), schema.clone())
10080            .await
10081            .unwrap();
10082        for posting_list in std::mem::take(&mut builder.posting_lists) {
10083            let batch = posting_list
10084                .to_batch_with_docs(&docs, schema.clone())
10085                .unwrap();
10086            posting_writer.write_record_batch(batch).await.unwrap();
10087        }
10088        posting_writer.finish().await.unwrap();
10089
10090        let token_batch = std::mem::take(&mut builder.tokens)
10091            .to_batch(token_set_format)
10092            .unwrap();
10093        let mut token_writer = store
10094            .new_index_file(&token_file_path(partition_id), token_batch.schema())
10095            .await
10096            .unwrap();
10097        token_writer.write_record_batch(token_batch).await.unwrap();
10098        token_writer.finish().await.unwrap();
10099
10100        let doc_batch = docs.to_batch().unwrap();
10101        let mut doc_writer = store
10102            .new_index_file(&doc_file_path(partition_id), doc_batch.schema())
10103            .await
10104            .unwrap();
10105        doc_writer.write_record_batch(doc_batch).await.unwrap();
10106        doc_writer.finish().await.unwrap();
10107    }
10108
10109    async fn load_global_scoring_test_index(
10110        second_partition_has_impacts: bool,
10111    ) -> (TempObjDir, Arc<InvertedIndex>) {
10112        let tmpdir = TempObjDir::default();
10113        let store = Arc::new(LanceIndexStore::new(
10114            ObjectStore::local().into(),
10115            tmpdir.clone(),
10116            Arc::new(LanceCache::no_cache()),
10117        ));
10118        let partition_specs = [
10119            (0, 100, 5_000, 101..111, 5_000, true),
10120            (1, 200, 1_000, 201..301, 1, second_partition_has_impacts),
10121        ];
10122        for (
10123            partition_id,
10124            matching_row_id,
10125            matching_doc_length,
10126            other_row_ids,
10127            other_doc_length,
10128            with_impacts,
10129        ) in partition_specs
10130        {
10131            let mut builder = InnerBuilder::new_with_format_version(
10132                partition_id,
10133                false,
10134                TokenSetFormat::default(),
10135                InvertedListFormatVersion::V1,
10136            );
10137            builder.tokens.add("alpha".to_owned());
10138            builder
10139                .posting_lists
10140                .push(PostingListBuilder::new_with_posting_tail_codec(
10141                    false,
10142                    InvertedListFormatVersion::V1.posting_tail_codec(),
10143                ));
10144            builder.posting_lists[0].add(0, PositionRecorder::Count(1));
10145            builder.docs.append(matching_row_id, matching_doc_length);
10146            for row_id in other_row_ids {
10147                builder.docs.append(row_id, other_doc_length);
10148            }
10149            write_test_partition_with_optional_impacts(
10150                &store,
10151                partition_id,
10152                builder,
10153                TokenSetFormat::default(),
10154                with_impacts,
10155            )
10156            .await;
10157        }
10158
10159        write_test_metadata(&store, vec![0, 1], InvertedIndexParams::default()).await;
10160        let cache = LanceCache::with_capacity(4096);
10161        let index = InvertedIndex::load(store, None, &cache).await.unwrap();
10162        (tmpdir, index)
10163    }
10164
10165    async fn search_test_impact_partition(
10166        partition: &InvertedPartition,
10167        tokens: &Tokens,
10168        params: &FtsSearchParams,
10169        scorer: Arc<MemBM25Scorer>,
10170        shared_threshold: Arc<AtomicU32>,
10171    ) -> Vec<DocCandidate> {
10172        let LoadedPostings {
10173            postings,
10174            grouped_expansions,
10175            impact_safe,
10176            exact_scoring_required,
10177        } = partition
10178            .load_posting_lists(
10179                tokens,
10180                params,
10181                Operator::Or,
10182                scorer.as_ref(),
10183                &NoOpMetricsCollector,
10184            )
10185            .await
10186            .unwrap();
10187        assert!(impact_safe);
10188        assert!(!exact_scoring_required);
10189        assert!(grouped_expansions.is_empty());
10190
10191        let mask = NoFilter.mask();
10192        let docs_for_wand = partition.docs.docs_for_wand(mask.as_ref()).await.unwrap();
10193        let mut candidates = partition
10194            .bm25_search(
10195                docs_for_wand.as_ref(),
10196                params,
10197                Operator::Or,
10198                mask,
10199                postings,
10200                Some(scorer),
10201                &NoOpMetricsCollector,
10202                shared_threshold,
10203            )
10204            .unwrap();
10205        resolve_deferred_candidates(&partition.docs, &mut candidates)
10206            .await
10207            .unwrap();
10208        candidates
10209    }
10210
10211    #[tokio::test]
10212    async fn test_impact_partitions_share_global_threshold_without_pruning_winner() {
10213        // Partition 0 wins under its local corpus statistics but loses under
10214        // the global statistics. If its local score escapes into the shared
10215        // floor, partition 1 will incorrectly prune the real global winner.
10216        let (_tmpdir, index) = load_global_scoring_test_index(true).await;
10217        let first_partition = index
10218            .partitions
10219            .iter()
10220            .find(|partition| partition.id() == 0)
10221            .unwrap();
10222        let second_partition = index
10223            .partitions
10224            .iter()
10225            .find(|partition| partition.id() == 1)
10226            .unwrap();
10227
10228        let tokens = Arc::new(Tokens::new(vec!["alpha".to_owned()], DocType::Text));
10229        let params = Arc::new(FtsSearchParams::new().with_limit(Some(1)));
10230        let scorer = Arc::new(
10231            index
10232                .bm25_base_scorer(tokens.as_ref(), params.as_ref())
10233                .await
10234                .unwrap(),
10235        );
10236        first_partition
10237            .inverted_list
10238            .ensure_metadata_loaded()
10239            .await
10240            .unwrap();
10241        second_partition
10242            .inverted_list
10243            .ensure_metadata_loaded()
10244            .await
10245            .unwrap();
10246        let first_local_scorer = IndexBM25Scorer::new(std::iter::once(first_partition.as_ref()));
10247        let second_local_scorer = IndexBM25Scorer::new(std::iter::once(second_partition.as_ref()));
10248        let first_local_score =
10249            first_local_scorer.query_weight("alpha") * first_local_scorer.doc_weight(1, 5_000);
10250        let second_local_score =
10251            second_local_scorer.query_weight("alpha") * second_local_scorer.doc_weight(1, 1_000);
10252        assert!(first_local_score > second_local_score);
10253        let shared_threshold = Arc::new(AtomicU32::new(f32::NEG_INFINITY.to_bits()));
10254
10255        // Search sequentially so partition 0 deterministically publishes its
10256        // score before partition 1 evaluates its impact upper bound.
10257        let first_candidates = search_test_impact_partition(
10258            first_partition,
10259            tokens.as_ref(),
10260            params.as_ref(),
10261            scorer.clone(),
10262            shared_threshold.clone(),
10263        )
10264        .await;
10265        assert_eq!(first_candidates.len(), 1);
10266        assert!(matches!(
10267            first_candidates[0].addr,
10268            CandidateAddr::RowId(100)
10269        ));
10270        let first_score =
10271            scorer.query_weight("alpha") * scorer.doc_weight(1, first_candidates[0].doc_length);
10272        let published_threshold = f32::from_bits(shared_threshold.load(Ordering::Relaxed));
10273        assert!(
10274            (published_threshold - first_score).abs() < 1e-6,
10275            "published threshold: {published_threshold}, expected global score: {first_score}"
10276        );
10277
10278        let second_candidates = search_test_impact_partition(
10279            second_partition,
10280            tokens.as_ref(),
10281            params.as_ref(),
10282            scorer.clone(),
10283            shared_threshold.clone(),
10284        )
10285        .await;
10286        assert_eq!(second_candidates.len(), 1);
10287        assert!(matches!(
10288            second_candidates[0].addr,
10289            CandidateAddr::RowId(200)
10290        ));
10291        let second_score =
10292            scorer.query_weight("alpha") * scorer.doc_weight(1, second_candidates[0].doc_length);
10293        assert!(
10294            second_score > first_score,
10295            "second score: {second_score}, first score: {first_score}"
10296        );
10297        assert!(
10298            (f32::from_bits(shared_threshold.load(Ordering::Relaxed)) - second_score).abs() < 1e-6
10299        );
10300
10301        let (row_ids, scores) = index
10302            .bm25_search(
10303                tokens,
10304                params,
10305                Operator::Or,
10306                Arc::new(NoFilter),
10307                Arc::new(NoOpMetricsCollector),
10308                None,
10309            )
10310            .await
10311            .unwrap();
10312        assert_eq!(row_ids, vec![200]);
10313        assert_eq!(scores.len(), 1);
10314        assert!((scores[0] - second_score).abs() < 1e-6);
10315    }
10316
10317    #[tokio::test]
10318    async fn test_mixed_impact_and_legacy_partitions_use_global_final_scores() {
10319        let (_tmpdir, index) = load_global_scoring_test_index(false).await;
10320
10321        let impact_partition = index
10322            .partitions
10323            .iter()
10324            .find(|partition| partition.id() == 0)
10325            .unwrap();
10326        let legacy_partition = index
10327            .partitions
10328            .iter()
10329            .find(|partition| partition.id() == 1)
10330            .unwrap();
10331
10332        let impact_posting = impact_partition
10333            .inverted_list
10334            .posting_list(0, false, &NoOpMetricsCollector)
10335            .await
10336            .unwrap();
10337        assert!(impact_posting.has_impacts());
10338
10339        let legacy_posting = legacy_partition
10340            .inverted_list
10341            .posting_list(0, false, &NoOpMetricsCollector)
10342            .await
10343            .unwrap();
10344        assert!(!legacy_posting.has_impacts());
10345
10346        let tokens = Arc::new(Tokens::new(vec!["alpha".to_string()], DocType::Text));
10347        let params = Arc::new(FtsSearchParams::new().with_limit(Some(1)));
10348        let (row_ids, scores) = index
10349            .bm25_search(
10350                tokens.clone(),
10351                params.clone(),
10352                Operator::Or,
10353                Arc::new(NoFilter),
10354                Arc::new(NoOpMetricsCollector),
10355                None,
10356            )
10357            .await
10358            .unwrap();
10359
10360        assert_eq!(row_ids, vec![200]);
10361        assert_eq!(row_ids.len(), scores.len());
10362
10363        let scorer = index
10364            .bm25_base_scorer(tokens.as_ref(), params.as_ref())
10365            .await
10366            .unwrap();
10367        let expected_score = scorer.query_weight("alpha") * scorer.doc_weight(1, 1_000);
10368        assert!(
10369            (scores[0] - expected_score).abs() < 1e-6,
10370            "score: {}, expected: {}",
10371            scores[0],
10372            expected_score
10373        );
10374    }
10375
10376    #[tokio::test]
10377    async fn test_and_query_returns_empty_when_exact_term_missing() {
10378        let tmpdir = TempObjDir::default();
10379        let store = Arc::new(LanceIndexStore::new(
10380            ObjectStore::local().into(),
10381            tmpdir.clone(),
10382            Arc::new(LanceCache::no_cache()),
10383        ));
10384
10385        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
10386        builder.tokens.add("alpha".to_owned());
10387        builder.posting_lists.push(PostingListBuilder::new(false));
10388        builder.posting_lists[0].add(0, PositionRecorder::Count(1));
10389        builder.docs.append(100, 1);
10390        builder.write(store.as_ref()).await.unwrap();
10391
10392        write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
10393        let cache = Arc::new(LanceCache::with_capacity(4096));
10394        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
10395            .await
10396            .unwrap();
10397
10398        let tokens = Arc::new(Tokens::new(
10399            vec!["alpha".to_owned(), "missing".to_owned()],
10400            DocType::Text,
10401        ));
10402        let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
10403        let prefilter = Arc::new(NoFilter);
10404        let metrics = Arc::new(NoOpMetricsCollector);
10405
10406        let (and_row_ids, _) = index
10407            .bm25_search(
10408                tokens.clone(),
10409                params.clone(),
10410                Operator::And,
10411                prefilter.clone(),
10412                metrics.clone(),
10413                None,
10414            )
10415            .await
10416            .unwrap();
10417        assert!(
10418            and_row_ids.is_empty(),
10419            "AND must not match when any required term is missing"
10420        );
10421
10422        let (or_row_ids, _) = index
10423            .bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
10424            .await
10425            .unwrap();
10426        assert_eq!(
10427            or_row_ids,
10428            vec![100],
10429            "OR should still match the present term"
10430        );
10431    }
10432
10433    #[tokio::test]
10434    async fn test_and_query_accepts_same_position_alternatives() {
10435        let tmpdir = TempObjDir::default();
10436        let store = Arc::new(LanceIndexStore::new(
10437            ObjectStore::local().into(),
10438            tmpdir.clone(),
10439            Arc::new(LanceCache::no_cache()),
10440        ));
10441
10442        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
10443        for token in ["getusername", "get", "user", "name"] {
10444            builder.tokens.add(token.to_owned());
10445            builder.posting_lists.push(PostingListBuilder::new(false));
10446        }
10447        // Doc 0 only has the split words. Doc 1 has both the complete
10448        // identifier and split words. A grouped AND query should accept either
10449        // `getusername` or `get` at position 0.
10450        builder.posting_lists[1].add(0, PositionRecorder::Count(1));
10451        builder.posting_lists[2].add(0, PositionRecorder::Count(1));
10452        builder.posting_lists[3].add(0, PositionRecorder::Count(1));
10453        builder.docs.append(100, 3);
10454
10455        builder.posting_lists[0].add(1, PositionRecorder::Count(1));
10456        builder.posting_lists[1].add(1, PositionRecorder::Count(1));
10457        builder.posting_lists[2].add(1, PositionRecorder::Count(1));
10458        builder.posting_lists[3].add(1, PositionRecorder::Count(1));
10459        builder.docs.append(101, 4);
10460        builder.write(store.as_ref()).await.unwrap();
10461
10462        write_test_metadata(&store, vec![0], InvertedIndexParams::code()).await;
10463        let index = InvertedIndex::load(store.clone(), None, &LanceCache::no_cache())
10464            .await
10465            .unwrap();
10466
10467        let tokens = Arc::new(Tokens::with_positions(
10468            vec![
10469                "getusername".to_string(),
10470                "get".to_string(),
10471                "user".to_string(),
10472                "name".to_string(),
10473            ],
10474            vec![0, 0, 1, 2],
10475            DocType::Text,
10476        ));
10477        let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
10478        let (mut row_ids, _) = index
10479            .bm25_search(
10480                tokens,
10481                params,
10482                Operator::And,
10483                Arc::new(NoFilter),
10484                Arc::new(NoOpMetricsCollector),
10485                None,
10486            )
10487            .await
10488            .unwrap();
10489        row_ids.sort_unstable();
10490        assert_eq!(row_ids, vec![100, 101]);
10491    }
10492
10493    #[tokio::test]
10494    async fn test_phrase_query_accepts_same_position_alternatives() {
10495        let tmpdir = TempObjDir::default();
10496        let store = Arc::new(LanceIndexStore::new(
10497            ObjectStore::local().into(),
10498            tmpdir.clone(),
10499            Arc::new(LanceCache::no_cache()),
10500        ));
10501
10502        let mut builder = InnerBuilder::new(0, true, TokenSetFormat::default());
10503        for token in ["getusername", "get", "user", "name"] {
10504            builder.tokens.add(token.to_owned());
10505            builder.posting_lists.push(PostingListBuilder::new(true));
10506        }
10507        // Doc 0 only has split words. Doc 1 has both the complete identifier
10508        // and split words at the same position. Doc 2 has the terms but not as
10509        // an exact phrase.
10510        builder.posting_lists[1].add(0, PositionRecorder::Position(vec![0].into()));
10511        builder.posting_lists[2].add(0, PositionRecorder::Position(vec![1].into()));
10512        builder.posting_lists[3].add(0, PositionRecorder::Position(vec![2].into()));
10513        builder.docs.append(100, 3);
10514
10515        builder.posting_lists[0].add(1, PositionRecorder::Position(vec![0].into()));
10516        builder.posting_lists[1].add(1, PositionRecorder::Position(vec![0].into()));
10517        builder.posting_lists[2].add(1, PositionRecorder::Position(vec![1].into()));
10518        builder.posting_lists[3].add(1, PositionRecorder::Position(vec![2].into()));
10519        builder.docs.append(101, 3);
10520
10521        builder.posting_lists[0].add(2, PositionRecorder::Position(vec![0].into()));
10522        builder.posting_lists[2].add(2, PositionRecorder::Position(vec![2].into()));
10523        builder.posting_lists[3].add(2, PositionRecorder::Position(vec![3].into()));
10524        builder.docs.append(102, 3);
10525
10526        builder.write(store.as_ref()).await.unwrap();
10527
10528        write_test_metadata(
10529            &store,
10530            vec![0],
10531            InvertedIndexParams::code().with_position(true),
10532        )
10533        .await;
10534        let index = InvertedIndex::load(store.clone(), None, &LanceCache::no_cache())
10535            .await
10536            .unwrap();
10537
10538        let tokens = Arc::new(Tokens::with_positions(
10539            vec![
10540                "getusername".to_string(),
10541                "get".to_string(),
10542                "user".to_string(),
10543                "name".to_string(),
10544            ],
10545            vec![0, 0, 1, 2],
10546            DocType::Text,
10547        ));
10548        let params = Arc::new(
10549            FtsSearchParams::new()
10550                .with_limit(Some(10))
10551                .with_phrase_slop(Some(0)),
10552        );
10553        let (mut row_ids, _) = index
10554            .bm25_search(
10555                tokens,
10556                params,
10557                Operator::And,
10558                Arc::new(NoFilter),
10559                Arc::new(NoOpMetricsCollector),
10560                None,
10561            )
10562            .await
10563            .unwrap();
10564        row_ids.sort_unstable();
10565        assert_eq!(row_ids, vec![100, 101]);
10566    }
10567
10568    // Enough distinct tokens that `write_posting_lists` emits several posting-list
10569    // batches (the default batch size is 256 rows), exercising the restructured
10570    // producer and async send path.
10571    const MANY_BATCH_TOKENS: u64 = 1000;
10572    const MANY_BATCH_ROW_ID_BASE: u64 = 1000;
10573
10574    // Writes a single partition whose posting lists span many output batches. Each
10575    // token `tok{i:05}` maps to row id `MANY_BATCH_ROW_ID_BASE + i`.
10576    async fn write_partition_spanning_many_batches(store: &dyn IndexStore) {
10577        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
10578        for i in 0..MANY_BATCH_TOKENS {
10579            // Zero-padded so tokens are inserted in sorted order, as the set expects.
10580            builder.tokens.add(format!("tok{i:05}"));
10581            let doc_id = builder.docs.append(MANY_BATCH_ROW_ID_BASE + i, 1);
10582            let mut posting_list = PostingListBuilder::new(false);
10583            posting_list.add(doc_id, PositionRecorder::Count(1));
10584            builder.posting_lists.push(posting_list);
10585        }
10586        builder
10587            .write(store)
10588            .await
10589            .expect("writing posting lists should succeed");
10590    }
10591
10592    // Correctness guard for the restructured posting-list writer. The producer now
10593    // builds each batch in its own `spawn_cpu` call, handing the builder and the
10594    // remaining posting lists back out so state (the cross-batch cache-group
10595    // accumulator) is preserved, and dispatches with an async `send().await`. This
10596    // verifies that path over many batches by checking representative tokens after
10597    // they cross the producer/consumer boundary.
10598    //
10599    // Note: this does not reproduce the single-thread-pool deadlock the async send
10600    // fixes -- that requires a 1-thread CPU pool (a process-global singleton) plus
10601    // ~8MB of buffered posting data to trigger a consumer-side encoder flush, which
10602    // is impractical as a lightweight unit test.
10603    #[tokio::test]
10604    async fn test_write_many_posting_list_batches_preserves_all_batches() {
10605        let tmpdir = TempObjDir::default();
10606        let store = Arc::new(LanceIndexStore::new(
10607            ObjectStore::local().into(),
10608            tmpdir.clone(),
10609            Arc::new(LanceCache::no_cache()),
10610        ));
10611
10612        write_partition_spanning_many_batches(store.as_ref()).await;
10613
10614        write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
10615        let cache = Arc::new(LanceCache::with_capacity(4096));
10616        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
10617            .await
10618            .unwrap();
10619
10620        // Probe tokens from the first, a middle, and the last batch to confirm they
10621        // remain queryable after crossing the producer/consumer boundary.
10622        for token_idx in [0u64, MANY_BATCH_TOKENS / 2, MANY_BATCH_TOKENS - 1] {
10623            let tokens = Arc::new(Tokens::new(
10624                vec![format!("tok{token_idx:05}")],
10625                DocType::Text,
10626            ));
10627            let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
10628            let (row_ids, _) = index
10629                .bm25_search(
10630                    tokens,
10631                    params,
10632                    Operator::Or,
10633                    Arc::new(NoFilter),
10634                    Arc::new(NoOpMetricsCollector),
10635                    None,
10636                )
10637                .await
10638                .unwrap();
10639            assert_eq!(
10640                row_ids,
10641                vec![MANY_BATCH_ROW_ID_BASE + token_idx],
10642                "token tok{token_idx:05} should map to its single document"
10643            );
10644        }
10645    }
10646
10647    #[tokio::test]
10648    async fn test_and_query_skips_partition_missing_required_term() {
10649        let tmpdir = TempObjDir::default();
10650        let store = Arc::new(LanceIndexStore::new(
10651            ObjectStore::local().into(),
10652            tmpdir.clone(),
10653            Arc::new(LanceCache::no_cache()),
10654        ));
10655
10656        let mut builder0 = InnerBuilder::new(0, false, TokenSetFormat::default());
10657        builder0.tokens.add("alpha".to_owned());
10658        builder0.posting_lists.push(PostingListBuilder::new(false));
10659        builder0.posting_lists[0].add(0, PositionRecorder::Count(1));
10660        builder0.docs.append(100, 1);
10661        builder0.write(store.as_ref()).await.unwrap();
10662
10663        let mut builder1 = InnerBuilder::new(1, false, TokenSetFormat::default());
10664        builder1.tokens.add("alpha".to_owned());
10665        builder1.tokens.add("beta".to_owned());
10666        builder1.posting_lists.push(PostingListBuilder::new(false));
10667        builder1.posting_lists.push(PostingListBuilder::new(false));
10668        builder1.posting_lists[0].add(0, PositionRecorder::Count(1));
10669        builder1.posting_lists[1].add(0, PositionRecorder::Count(1));
10670        builder1.docs.append(200, 2);
10671        builder1.write(store.as_ref()).await.unwrap();
10672
10673        write_test_metadata(&store, vec![0, 1], InvertedIndexParams::default()).await;
10674        let cache = Arc::new(LanceCache::with_capacity(4096));
10675        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
10676            .await
10677            .unwrap();
10678
10679        let tokens = Arc::new(Tokens::new(
10680            vec!["alpha".to_owned(), "beta".to_owned()],
10681            DocType::Text,
10682        ));
10683        let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
10684        let (mut row_ids, _) = index
10685            .bm25_search(
10686                tokens,
10687                params,
10688                Operator::And,
10689                Arc::new(NoFilter),
10690                Arc::new(NoOpMetricsCollector),
10691                None,
10692            )
10693            .await
10694            .unwrap();
10695        row_ids.sort_unstable();
10696        assert_eq!(
10697            row_ids,
10698            vec![200],
10699            "partition missing beta must not contribute alpha-only hits"
10700        );
10701    }
10702
10703    #[tokio::test]
10704    async fn test_fuzzy_and_groups_expansions_by_original_position() {
10705        let tmpdir = TempObjDir::default();
10706        let store = Arc::new(LanceIndexStore::new(
10707            ObjectStore::local().into(),
10708            tmpdir.clone(),
10709            Arc::new(LanceCache::no_cache()),
10710        ));
10711
10712        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
10713        builder.tokens.add("alpha".to_owned());
10714        builder.tokens.add("alphi".to_owned());
10715        builder.tokens.add("beta".to_owned());
10716        builder.posting_lists.push(PostingListBuilder::new(false));
10717        builder.posting_lists.push(PostingListBuilder::new(false));
10718        builder.posting_lists.push(PostingListBuilder::new(false));
10719        builder.posting_lists[0].add(0, PositionRecorder::Count(1));
10720        builder.posting_lists[1].add(1, PositionRecorder::Count(1));
10721        builder.posting_lists[2].add(0, PositionRecorder::Count(1));
10722        builder.posting_lists[2].add(1, PositionRecorder::Count(1));
10723        builder.docs.append(100, 2);
10724        builder.docs.append(101, 2);
10725        builder.write(store.as_ref()).await.unwrap();
10726
10727        write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
10728        let cache = Arc::new(LanceCache::with_capacity(4096));
10729        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
10730            .await
10731            .unwrap();
10732        let params = Arc::new(
10733            FtsSearchParams::new()
10734                .with_limit(Some(10))
10735                .with_fuzziness(Some(1)),
10736        );
10737
10738        let missing_position_tokens = Arc::new(Tokens::new(
10739            vec!["betx".to_owned(), "zzzzz".to_owned()],
10740            DocType::Text,
10741        ));
10742        let (missing_and_row_ids, _) = index
10743            .bm25_search(
10744                missing_position_tokens.clone(),
10745                params.clone(),
10746                Operator::And,
10747                Arc::new(NoFilter),
10748                Arc::new(NoOpMetricsCollector),
10749                None,
10750            )
10751            .await
10752            .unwrap();
10753        assert!(
10754            missing_and_row_ids.is_empty(),
10755            "fuzzy AND must require at least one expansion for every original position"
10756        );
10757
10758        let (mut or_row_ids, _) = index
10759            .bm25_search(
10760                missing_position_tokens,
10761                params.clone(),
10762                Operator::Or,
10763                Arc::new(NoFilter),
10764                Arc::new(NoOpMetricsCollector),
10765                None,
10766            )
10767            .await
10768            .unwrap();
10769        or_row_ids.sort_unstable();
10770        assert_eq!(
10771            or_row_ids,
10772            vec![100, 101],
10773            "OR should still match present fuzzy expansions"
10774        );
10775
10776        let grouped_tokens = Arc::new(Tokens::new(
10777            vec!["alphx".to_owned(), "betx".to_owned()],
10778            DocType::Text,
10779        ));
10780        let (mut grouped_row_ids, _) = index
10781            .bm25_search(
10782                grouped_tokens,
10783                params,
10784                Operator::And,
10785                Arc::new(NoFilter),
10786                Arc::new(NoOpMetricsCollector),
10787                None,
10788            )
10789            .await
10790            .unwrap();
10791        grouped_row_ids.sort_unstable();
10792        assert_eq!(
10793            grouped_row_ids,
10794            vec![100, 101],
10795            "each original fuzzy position should match any one of its expansions"
10796        );
10797    }
10798
10799    #[tokio::test]
10800    async fn test_fuzzy_expansion_cap_applies_to_whole_query() {
10801        let tmpdir = TempObjDir::default();
10802        let store = Arc::new(LanceIndexStore::new(
10803            ObjectStore::local().into(),
10804            tmpdir.clone(),
10805            Arc::new(LanceCache::no_cache()),
10806        ));
10807
10808        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
10809        for token in ["alpha", "alphi", "beta", "beti"] {
10810            builder.tokens.add(token.to_owned());
10811            builder.posting_lists.push(PostingListBuilder::new(false));
10812        }
10813        for token_id in 0..4 {
10814            builder.posting_lists[token_id].add(token_id as u32, PositionRecorder::Count(1));
10815            builder.docs.append(100 + token_id as u64, 1);
10816        }
10817        builder.write(store.as_ref()).await.unwrap();
10818
10819        write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
10820        let cache = Arc::new(LanceCache::with_capacity(4096));
10821        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
10822            .await
10823            .unwrap();
10824        let partition = index.partitions[0].clone();
10825        let params = FtsSearchParams::new()
10826            .with_fuzziness(Some(1))
10827            .with_max_expansions(3);
10828        let tokens = Tokens::new(vec!["alphx".to_owned(), "betx".to_owned()], DocType::Text);
10829
10830        let expanded = partition.expand_fuzzy(&tokens, &params).unwrap();
10831        let expanded_terms = (0..expanded.len())
10832            .map(|idx| (expanded.get_token(idx).to_owned(), expanded.position(idx)))
10833            .collect::<Vec<_>>();
10834
10835        assert_eq!(
10836            expanded_terms,
10837            vec![
10838                ("alpha".to_owned(), 0),
10839                ("alphi".to_owned(), 0),
10840                ("beta".to_owned(), 1),
10841            ],
10842            "max_expansions should cap the whole fuzzy query, not each token"
10843        );
10844    }
10845
10846    /// Write one partition holding `variants` in order, with one
10847    /// single-token doc per variant taken from `row_ids`.
10848    async fn write_variant_partition(
10849        store: &Arc<LanceIndexStore>,
10850        partition_id: u64,
10851        variants: &[&str],
10852        row_ids: &[u64],
10853    ) {
10854        let mut builder = InnerBuilder::new(partition_id, false, TokenSetFormat::default());
10855        for token in variants {
10856            builder.tokens.add((*token).to_owned());
10857            builder.posting_lists.push(PostingListBuilder::new(false));
10858        }
10859        for (local_idx, row_id) in row_ids.iter().enumerate() {
10860            builder.posting_lists[local_idx].add(local_idx as u32, PositionRecorder::Count(1));
10861            builder.docs.append(*row_id, 1);
10862        }
10863        builder.write(store.as_ref()).await.unwrap();
10864    }
10865
10866    #[tokio::test]
10867    async fn test_fuzzy_expansion_cap_is_global_across_partitions() {
10868        let tmpdir = TempObjDir::default();
10869        let store = Arc::new(LanceIndexStore::new(
10870            ObjectStore::local().into(),
10871            tmpdir.clone(),
10872            Arc::new(LanceCache::no_cache()),
10873        ));
10874
10875        write_variant_partition(&store, 0, &["alpha", "alphb"], &[100, 101]).await;
10876        write_variant_partition(&store, 1, &["alphc", "alphd"], &[102, 103]).await;
10877        write_test_metadata(&store, vec![0, 1], InvertedIndexParams::default()).await;
10878        let cache = Arc::new(LanceCache::with_capacity(4096));
10879        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
10880            .await
10881            .unwrap();
10882
10883        let params = FtsSearchParams::new()
10884            .with_fuzziness(Some(1))
10885            .with_max_expansions(3);
10886        let tokens = Tokens::new(vec!["alphx".to_owned()], DocType::Text);
10887
10888        let expanded = index.expand_fuzzy_tokens(&tokens, &params).unwrap();
10889        let expanded_terms = (0..expanded.len())
10890            .map(|idx| expanded.get_token(idx).to_owned())
10891            .collect::<Vec<_>>();
10892        assert_eq!(
10893            expanded_terms,
10894            vec!["alpha".to_owned(), "alphb".to_owned(), "alphc".to_owned()],
10895            "max_expansions must cap the whole query across partitions, \
10896             in lexicographic order"
10897        );
10898    }
10899
10900    #[tokio::test]
10901    async fn test_fuzzy_results_independent_of_partition_shape() {
10902        // The same four single-variant docs, laid out as one partition and
10903        // as two. With a binding max_expansions the two shapes must still
10904        // match the same documents with the same scores.
10905        let single_dir = TempObjDir::default();
10906        let single_store = Arc::new(LanceIndexStore::new(
10907            ObjectStore::local().into(),
10908            single_dir.clone(),
10909            Arc::new(LanceCache::no_cache()),
10910        ));
10911        write_variant_partition(
10912            &single_store,
10913            0,
10914            &["alpha", "alphb", "alphc", "alphd"],
10915            &[100, 101, 102, 103],
10916        )
10917        .await;
10918        write_test_metadata(&single_store, vec![0], InvertedIndexParams::default()).await;
10919
10920        let split_dir = TempObjDir::default();
10921        let split_store = Arc::new(LanceIndexStore::new(
10922            ObjectStore::local().into(),
10923            split_dir.clone(),
10924            Arc::new(LanceCache::no_cache()),
10925        ));
10926        write_variant_partition(&split_store, 0, &["alpha", "alphb"], &[100, 101]).await;
10927        write_variant_partition(&split_store, 1, &["alphc", "alphd"], &[102, 103]).await;
10928        write_test_metadata(&split_store, vec![0, 1], InvertedIndexParams::default()).await;
10929
10930        let params = Arc::new(
10931            FtsSearchParams::new()
10932                .with_limit(Some(10))
10933                .with_fuzziness(Some(1))
10934                .with_max_expansions(3),
10935        );
10936
10937        let mut results = Vec::new();
10938        for store in [single_store, split_store] {
10939            let cache = LanceCache::with_capacity(4096);
10940            let index = InvertedIndex::load(store, None, &cache).await.unwrap();
10941            let tokens = Arc::new(Tokens::new(vec!["alphx".to_owned()], DocType::Text));
10942            let (row_ids, scores) = index
10943                .bm25_search(
10944                    tokens,
10945                    params.clone(),
10946                    Operator::Or,
10947                    Arc::new(NoFilter),
10948                    Arc::new(NoOpMetricsCollector),
10949                    None,
10950                )
10951                .await
10952                .unwrap();
10953            let mut scored = row_ids.into_iter().zip(scores).collect::<Vec<_>>();
10954            scored.sort_unstable_by_key(|(row_id, _)| *row_id);
10955            results.push(scored);
10956        }
10957
10958        assert_eq!(
10959            results[0]
10960                .iter()
10961                .map(|(row_id, _)| *row_id)
10962                .collect::<Vec<_>>(),
10963            vec![100, 101, 102],
10964            "a binding cap keeps the three lexicographically smallest variants"
10965        );
10966        assert_eq!(
10967            results[0], results[1],
10968            "fuzzy results must not depend on the partition shape"
10969        );
10970    }
10971
10972    #[tokio::test]
10973    async fn test_fuzzy_and_scores_grouped_expansions_by_matched_token() {
10974        let tmpdir = TempObjDir::default();
10975        let store = Arc::new(LanceIndexStore::new(
10976            ObjectStore::local().into(),
10977            tmpdir.clone(),
10978            Arc::new(LanceCache::no_cache()),
10979        ));
10980
10981        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
10982        builder.tokens.add("alpha".to_owned());
10983        builder.tokens.add("alphi".to_owned());
10984        builder.tokens.add("beta".to_owned());
10985        builder.posting_lists.push(PostingListBuilder::new(false));
10986        builder.posting_lists.push(PostingListBuilder::new(false));
10987        builder.posting_lists.push(PostingListBuilder::new(false));
10988        builder.posting_lists[0].add(0, PositionRecorder::Count(1));
10989        builder.posting_lists[0].add(2, PositionRecorder::Count(1));
10990        builder.posting_lists[0].add(3, PositionRecorder::Count(1));
10991        builder.posting_lists[0].add(4, PositionRecorder::Count(1));
10992        builder.posting_lists[0].add(5, PositionRecorder::Count(1));
10993        builder.posting_lists[1].add(1, PositionRecorder::Count(1));
10994        builder.posting_lists[2].add(0, PositionRecorder::Count(1));
10995        builder.posting_lists[2].add(1, PositionRecorder::Count(1));
10996        builder.docs.append(100, 2);
10997        builder.docs.append(101, 2);
10998        builder.docs.append(102, 1);
10999        builder.docs.append(103, 1);
11000        builder.docs.append(104, 1);
11001        builder.docs.append(105, 1);
11002        builder.write(store.as_ref()).await.unwrap();
11003
11004        write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
11005        let cache = Arc::new(LanceCache::with_capacity(4096));
11006        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
11007            .await
11008            .unwrap();
11009
11010        let tokens = Arc::new(Tokens::new(
11011            vec!["alphx".to_owned(), "betx".to_owned()],
11012            DocType::Text,
11013        ));
11014        let params = Arc::new(
11015            FtsSearchParams::new()
11016                .with_limit(Some(1))
11017                .with_fuzziness(Some(1)),
11018        );
11019        let (row_ids, _scores) = index
11020            .bm25_search(
11021                tokens,
11022                params,
11023                Operator::And,
11024                Arc::new(NoFilter),
11025                Arc::new(NoOpMetricsCollector),
11026                None,
11027            )
11028            .await
11029            .unwrap();
11030
11031        assert_eq!(
11032            row_ids,
11033            vec![101],
11034            "the rare matched expansion should outrank the common expansion"
11035        );
11036    }
11037
11038    #[rstest::rstest]
11039    #[case::and(Operator::And)]
11040    #[case::or(Operator::Or)]
11041    #[tokio::test]
11042    async fn test_grouped_scoring_keeps_exact_winner_outside_proxy_window(
11043        #[case] operator: Operator,
11044    ) {
11045        let tmpdir = TempObjDir::default();
11046        let store = Arc::new(LanceIndexStore::new(
11047            ObjectStore::local().into(),
11048            tmpdir.clone(),
11049            Arc::new(LanceCache::no_cache()),
11050        ));
11051
11052        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
11053        builder.tokens.add("common".to_owned());
11054        builder.tokens.add("rare".to_owned());
11055        builder.posting_lists.push(PostingListBuilder::new(false));
11056        builder.posting_lists.push(PostingListBuilder::new(false));
11057        for doc_id in 0..3 {
11058            builder.posting_lists[0].add(doc_id, PositionRecorder::Count(1));
11059            builder.docs.append(100 + doc_id as u64, 1);
11060        }
11061        builder.posting_lists[1].add(3, PositionRecorder::Count(1));
11062        builder.docs.append(103, 2);
11063        builder.write(store.as_ref()).await.unwrap();
11064
11065        write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
11066        let cache = Arc::new(LanceCache::with_capacity(4096));
11067        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
11068            .await
11069            .unwrap();
11070
11071        let tokens = Arc::new(Tokens::with_positions(
11072            vec!["common".to_owned(), "rare".to_owned()],
11073            vec![0, 0],
11074            DocType::Text,
11075        ));
11076        let params = Arc::new(FtsSearchParams::new().with_limit(Some(1)));
11077        let (row_ids, _scores) = index
11078            .bm25_search(
11079                tokens,
11080                params,
11081                operator,
11082                Arc::new(NoFilter),
11083                Arc::new(NoOpMetricsCollector),
11084                None,
11085            )
11086            .await
11087            .unwrap();
11088
11089        assert_eq!(
11090            row_ids,
11091            vec![103],
11092            "the rare term's exact IDF must win even when proxy scoring ranks it outside the old candidate cushion"
11093        );
11094    }
11095
11096    #[tokio::test]
11097    async fn test_fuzzy_and_grouped_rescore_keeps_wand_limit_bounded() {
11098        let tmpdir = TempObjDir::default();
11099        let store = Arc::new(LanceIndexStore::new(
11100            ObjectStore::local().into(),
11101            tmpdir.clone(),
11102            Arc::new(LanceCache::no_cache()),
11103        ));
11104
11105        let num_docs = BLOCK_SIZE * 2 + 4;
11106        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
11107        builder.tokens.add("alpha".to_owned());
11108        builder.tokens.add("alphi".to_owned());
11109        builder.tokens.add("beta".to_owned());
11110        builder.posting_lists.push(PostingListBuilder::new(false));
11111        builder.posting_lists.push(PostingListBuilder::new(false));
11112        builder.posting_lists.push(PostingListBuilder::new(false));
11113
11114        builder.posting_lists[0].add(0, PositionRecorder::Count(1));
11115        builder.posting_lists[1].add(1, PositionRecorder::Count(1));
11116        for doc_id in 0..num_docs {
11117            builder.posting_lists[2].add(doc_id as u32, PositionRecorder::Count(1));
11118            if doc_id >= 2 {
11119                builder.posting_lists[0].add(doc_id as u32, PositionRecorder::Count(1));
11120            }
11121            let num_tokens = if doc_id < 2 { 2 } else { 100 };
11122            builder.docs.append(100 + doc_id as u64, num_tokens);
11123        }
11124        builder.write(store.as_ref()).await.unwrap();
11125
11126        write_test_metadata(&store, vec![0], InvertedIndexParams::default()).await;
11127        let cache = Arc::new(LanceCache::with_capacity(4096));
11128        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
11129            .await
11130            .unwrap();
11131
11132        let tokens = Arc::new(Tokens::new(
11133            vec!["alphx".to_owned(), "betx".to_owned()],
11134            DocType::Text,
11135        ));
11136        let params = Arc::new(
11137            FtsSearchParams::new()
11138                .with_limit(Some(1))
11139                .with_fuzziness(Some(1)),
11140        );
11141        let metrics = Arc::new(LocalMetricsCollector::default());
11142        let (row_ids, _scores) = index
11143            .bm25_search(
11144                tokens,
11145                params,
11146                Operator::And,
11147                Arc::new(NoFilter),
11148                metrics.clone(),
11149                None,
11150            )
11151            .await
11152            .unwrap();
11153
11154        assert_eq!(
11155            row_ids,
11156            vec![101],
11157            "final rescoring should still rank by the matched expansion"
11158        );
11159        let comparisons = metrics.comparisons.load(Ordering::Relaxed);
11160        assert!(
11161            comparisons < num_docs,
11162            "grouped fuzzy AND should not clear the WAND top-k bound and scan every candidate; comparisons={comparisons}, num_docs={num_docs}"
11163        );
11164    }
11165
11166    #[tokio::test]
11167    async fn test_phrase_query_reads_legacy_per_doc_positions() {
11168        let tmpdir = TempObjDir::default();
11169        let store = Arc::new(LanceIndexStore::new(
11170            ObjectStore::local().into(),
11171            tmpdir.clone(),
11172            Arc::new(LanceCache::no_cache()),
11173        ));
11174
11175        let mut builder = InnerBuilder::new_with_format_version(
11176            0,
11177            true,
11178            TokenSetFormat::default(),
11179            InvertedListFormatVersion::V1,
11180        );
11181        builder.tokens.add("hello".to_owned());
11182        builder.tokens.add("world".to_owned());
11183        builder
11184            .posting_lists
11185            .push(PostingListBuilder::new_with_posting_tail_codec(
11186                true,
11187                PostingTailCodec::Fixed32,
11188            ));
11189        builder
11190            .posting_lists
11191            .push(PostingListBuilder::new_with_posting_tail_codec(
11192                true,
11193                PostingTailCodec::Fixed32,
11194            ));
11195        builder.posting_lists[0].add(0, PositionRecorder::Position(vec![0].into()));
11196        builder.posting_lists[1].add(0, PositionRecorder::Position(vec![1].into()));
11197        builder.posting_lists[0].add(1, PositionRecorder::Position(vec![0].into()));
11198        builder.posting_lists[1].add(1, PositionRecorder::Position(vec![2].into()));
11199        builder.docs.append(100, 2);
11200        builder.docs.append(101, 2);
11201        builder.write(store.as_ref()).await.unwrap();
11202
11203        let metadata = std::collections::HashMap::from_iter(vec![
11204            (
11205                "partitions".to_owned(),
11206                serde_json::to_string(&vec![0_u64]).unwrap(),
11207            ),
11208            (
11209                "params".to_owned(),
11210                serde_json::to_string(&InvertedIndexParams::default().with_position(true)).unwrap(),
11211            ),
11212            (
11213                TOKEN_SET_FORMAT_KEY.to_owned(),
11214                TokenSetFormat::default().to_string(),
11215            ),
11216        ]);
11217        let mut writer = store
11218            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
11219            .await
11220            .unwrap();
11221        writer.finish_with_metadata(metadata).await.unwrap();
11222
11223        let cache = Arc::new(LanceCache::with_capacity(4096));
11224        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
11225            .await
11226            .unwrap();
11227
11228        let tokens = Arc::new(Tokens::new(
11229            vec!["hello".to_owned(), "world".to_owned()],
11230            DocType::Text,
11231        ));
11232        let params = Arc::new(
11233            FtsSearchParams::new()
11234                .with_limit(Some(10))
11235                .with_phrase_slop(Some(0)),
11236        );
11237        let prefilter = Arc::new(NoFilter);
11238        let metrics = Arc::new(NoOpMetricsCollector);
11239
11240        let (row_ids, _scores) = index
11241            .bm25_search(tokens, params, Operator::And, prefilter, metrics, None)
11242            .await
11243            .unwrap();
11244
11245        assert_eq!(row_ids, vec![100]);
11246    }
11247
11248    /// Build a multi-partition inverted index in `store` with `num_partitions`
11249    /// partitions, each carrying a handful of tokens/docs.
11250    async fn build_multi_partition_index(
11251        store: &Arc<LanceIndexStore>,
11252        num_partitions: u64,
11253    ) -> (Arc<InvertedIndex>, Arc<LanceCache>) {
11254        for id in 0..num_partitions {
11255            let mut builder = InnerBuilder::new_with_format_version(
11256                id,
11257                false,
11258                TokenSetFormat::default(),
11259                InvertedListFormatVersion::V1,
11260            );
11261            // A few distinct tokens per partition so each posting file has real
11262            // content to read and materialize during prewarm.
11263            for t in 0..4u32 {
11264                builder.tokens.add(format!("tok_{id}_{t}"));
11265                let mut posting = PostingListBuilder::new_with_posting_tail_codec(
11266                    false,
11267                    PostingTailCodec::Fixed32,
11268                );
11269                let base = id * 1000 + t as u64 * 10;
11270                for d in 0..5u32 {
11271                    posting.add(d, PositionRecorder::Count(1));
11272                    builder.docs.append(base + d as u64, 4);
11273                }
11274                builder.posting_lists.push(posting);
11275            }
11276            builder.write(store.as_ref()).await.unwrap();
11277        }
11278
11279        let partition_ids: Vec<u64> = (0..num_partitions).collect();
11280        let metadata = std::collections::HashMap::from_iter(vec![
11281            (
11282                "partitions".to_owned(),
11283                serde_json::to_string(&partition_ids).unwrap(),
11284            ),
11285            (
11286                "params".to_owned(),
11287                serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
11288            ),
11289            (
11290                TOKEN_SET_FORMAT_KEY.to_owned(),
11291                TokenSetFormat::default().to_string(),
11292            ),
11293        ]);
11294        let mut writer = store
11295            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
11296            .await
11297            .unwrap();
11298        writer.finish_with_metadata(metadata).await.unwrap();
11299
11300        // Keep the cache alive and return it: the partition readers hold only a
11301        // WeakLanceCache, so the prewarmed entries vanish if this Arc is dropped.
11302        let cache = Arc::new(LanceCache::with_capacity(1 << 20));
11303        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
11304            .await
11305            .unwrap();
11306        (index, cache)
11307    }
11308
11309    /// The prewarm cost estimate must come from cheap object metadata (the
11310    /// posting file length) without reading the posting data, and must be
11311    /// monotonic in the partition's content.
11312    #[tokio::test]
11313    async fn test_posting_data_size_bytes_uses_file_length() {
11314        let tmpdir = TempObjDir::default();
11315        let store = Arc::new(LanceIndexStore::new(
11316            ObjectStore::local().into(),
11317            tmpdir.clone(),
11318            Arc::new(LanceCache::no_cache()),
11319        ));
11320        let (index, _cache) = build_multi_partition_index(&store, 3).await;
11321        for part in &index.partitions {
11322            // File length is reported by object metadata at open time; it must be
11323            // non-trivial for a partition that actually holds postings.
11324            let est = part.inverted_list.posting_data_size_bytes();
11325            assert!(
11326                est > 0,
11327                "expected a non-zero posting-data size estimate, got {est}"
11328            );
11329        }
11330    }
11331
11332    /// Each partition must read through the shared scheduler at a distinct base
11333    /// priority. Tied priorities (every partition at 0) break the scheduler's
11334    /// backpressure deadlock-break — which admits the lowest-priority in-flight
11335    /// request — because there is no unique lowest request to advance, so a
11336    /// concurrent multi-partition read (e.g. prewarm) can wedge. Distinct
11337    /// per-partition priorities keep the in-flight set totally ordered.
11338    #[tokio::test]
11339    async fn test_partitions_load_with_distinct_priorities() {
11340        let tmpdir = TempObjDir::default();
11341        let store = Arc::new(LanceIndexStore::new(
11342            ObjectStore::local().into(),
11343            tmpdir.clone(),
11344            Arc::new(LanceCache::no_cache()),
11345        ));
11346        let (index, _cache) = build_multi_partition_index(&store, 5).await;
11347
11348        let mut priorities: Vec<u64> = index
11349            .partitions
11350            .iter()
11351            .map(|part| {
11352                part.store
11353                    .as_any()
11354                    .downcast_ref::<LanceIndexStore>()
11355                    .expect("partition store should be a LanceIndexStore")
11356                    .io_priority()
11357            })
11358            .collect();
11359
11360        // Distinct and dense (0..N): every partition reads at its own priority,
11361        // so the shared scheduler sees a total order across all partitions. The
11362        // partitions may finish loading in any order, so sort before comparing —
11363        // what matters is that the priorities form a contiguous, collision-free
11364        // set, not which partition ended up at which slot.
11365        priorities.sort_unstable();
11366        assert_eq!(
11367            priorities,
11368            (0..index.partitions.len() as u64).collect::<Vec<_>>()
11369        );
11370    }
11371
11372    #[tokio::test]
11373    async fn test_update_preserves_v2_format_version() -> Result<()> {
11374        let src_dir = TempObjDir::default();
11375        let dest_dir = TempObjDir::default();
11376        let src_store = Arc::new(LanceIndexStore::new(
11377            ObjectStore::local().into(),
11378            src_dir.clone(),
11379            Arc::new(LanceCache::no_cache()),
11380        ));
11381        let dest_store = Arc::new(LanceIndexStore::new(
11382            ObjectStore::local().into(),
11383            dest_dir.clone(),
11384            Arc::new(LanceCache::no_cache()),
11385        ));
11386
11387        let format_version = InvertedListFormatVersion::V2;
11388        let posting_tail_codec = format_version.posting_tail_codec();
11389        let mut partition = InnerBuilder::new_with_format_version(
11390            0,
11391            false,
11392            TokenSetFormat::default(),
11393            format_version,
11394        );
11395        partition.tokens.add("hello".to_owned());
11396        let mut posting_list =
11397            PostingListBuilder::new_with_posting_tail_codec(false, posting_tail_codec);
11398        posting_list.add(0, PositionRecorder::Count(1));
11399        partition.posting_lists.push(posting_list);
11400        partition.docs.append(100, 1);
11401        partition.write(src_store.as_ref()).await?;
11402
11403        let metadata = HashMap::from([
11404            (
11405                "partitions".to_owned(),
11406                serde_json::to_string(&vec![0_u64]).unwrap(),
11407            ),
11408            (
11409                "params".to_owned(),
11410                serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
11411            ),
11412            (
11413                TOKEN_SET_FORMAT_KEY.to_owned(),
11414                TokenSetFormat::default().to_string(),
11415            ),
11416            (
11417                POSTING_TAIL_CODEC_KEY.to_owned(),
11418                posting_tail_codec.as_str().to_owned(),
11419            ),
11420        ]);
11421        let mut writer = src_store
11422            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
11423            .await
11424            .unwrap();
11425        writer.finish_with_metadata(metadata).await.unwrap();
11426
11427        let index = InvertedIndex::load(src_store, None, &LanceCache::no_cache()).await?;
11428        assert_eq!(index.format_version(), format_version);
11429        assert_eq!(index.index_version(), INVERTED_INDEX_VERSION_V2);
11430
11431        let schema = Arc::new(Schema::new(vec![
11432            Field::new("doc", DataType::Utf8, true),
11433            Field::new(ROW_ID, DataType::UInt64, false),
11434        ]));
11435        let docs = Arc::new(StringArray::from(vec![Some("hello again")]));
11436        let row_ids = Arc::new(UInt64Array::from(vec![101u64]));
11437        let batch = RecordBatch::try_new(schema.clone(), vec![docs, row_ids])?;
11438        let stream = RecordBatchStreamAdapter::new(schema, stream::iter(vec![Ok(batch)]));
11439        let created = index
11440            .update(Box::pin(stream), dest_store.as_ref(), None)
11441            .await?;
11442
11443        assert_eq!(created.index_version, INVERTED_INDEX_VERSION_V2);
11444
11445        let updated = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?;
11446        assert_eq!(updated.format_version(), format_version);
11447        assert_eq!(updated.index_version(), INVERTED_INDEX_VERSION_V2);
11448        assert_eq!(updated.partitions.len(), 2);
11449        for partition in &updated.partitions {
11450            assert_eq!(
11451                partition.inverted_list.posting_tail_codec(),
11452                posting_tail_codec
11453            );
11454        }
11455
11456        Ok(())
11457    }
11458
11459    #[tokio::test]
11460    async fn test_block_size_256_writes_v3_metadata_and_index_version() -> Result<()> {
11461        let src_dir = TempObjDir::default();
11462        let dest_dir = TempObjDir::default();
11463        let src_store = Arc::new(LanceIndexStore::new(
11464            ObjectStore::local().into(),
11465            src_dir.clone(),
11466            Arc::new(LanceCache::no_cache()),
11467        ));
11468        let dest_store = Arc::new(LanceIndexStore::new(
11469            ObjectStore::local().into(),
11470            dest_dir.clone(),
11471            Arc::new(LanceCache::no_cache()),
11472        ));
11473
11474        let params = InvertedIndexParams::default().block_size(256)?;
11475        let format_version = params.resolved_format_version();
11476        assert_eq!(format_version, InvertedListFormatVersion::V3);
11477
11478        let mut partition = InnerBuilder::new_with_format_version_and_block_size(
11479            0,
11480            false,
11481            TokenSetFormat::default(),
11482            format_version,
11483            params.posting_block_size(),
11484        );
11485        partition.tokens.add("hello".to_owned());
11486        let mut posting_list = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
11487            false,
11488            format_version.posting_tail_codec(),
11489            params.posting_block_size(),
11490        );
11491        posting_list.add(0, PositionRecorder::Count(1));
11492        partition.posting_lists.push(posting_list);
11493        partition.docs.append(100, 1);
11494        partition.write(src_store.as_ref()).await?;
11495
11496        write_test_metadata(&src_store, vec![0], params).await;
11497
11498        let index = InvertedIndex::load(src_store, None, &LanceCache::no_cache()).await?;
11499        assert_eq!(index.format_version(), InvertedListFormatVersion::V3);
11500        assert_eq!(index.index_version(), INVERTED_INDEX_VERSION_V3);
11501
11502        let created = index
11503            .update(empty_doc_stream(), dest_store.as_ref(), None)
11504            .await?;
11505        assert_eq!(created.index_version, INVERTED_INDEX_VERSION_V3);
11506
11507        let updated = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?;
11508        assert_eq!(updated.format_version(), InvertedListFormatVersion::V3);
11509        assert_eq!(updated.index_version(), INVERTED_INDEX_VERSION_V3);
11510
11511        Ok(())
11512    }
11513
11514    #[tokio::test]
11515    async fn test_merge_segments_preserves_arrow_token_set_format() -> Result<()> {
11516        let src_dir = TempObjDir::default();
11517        let dest_dir = TempObjDir::default();
11518        let src_store = Arc::new(LanceIndexStore::new(
11519            ObjectStore::local().into(),
11520            src_dir.clone(),
11521            Arc::new(LanceCache::no_cache()),
11522        ));
11523        let dest_store = Arc::new(LanceIndexStore::new(
11524            ObjectStore::local().into(),
11525            dest_dir.clone(),
11526            Arc::new(LanceCache::no_cache()),
11527        ));
11528
11529        let index = write_single_partition_index(
11530            src_store,
11531            InvertedIndexParams::default().format_version(InvertedListFormatVersion::V2),
11532            TokenSetFormat::Arrow,
11533            "hello",
11534            100,
11535        )
11536        .await?;
11537        assert_eq!(index.index_version(), 0);
11538        let created = InvertedIndex::merge_segments(
11539            &[index],
11540            empty_doc_stream(),
11541            dest_store.as_ref(),
11542            None,
11543            crate::progress::noop_progress(),
11544        )
11545        .await?;
11546
11547        assert_eq!(created.index_version, 0);
11548        let merged = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?;
11549        assert_eq!(merged.index_version(), 0);
11550        assert_eq!(merged.token_set_format, TokenSetFormat::Arrow);
11551
11552        let tokens = Arc::new(Tokens::new(vec!["hello".to_string()], DocType::Text));
11553        let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
11554        let prefilter = Arc::new(NoFilter);
11555        let metrics = Arc::new(NoOpMetricsCollector);
11556        let (row_ids, _) = merged
11557            .bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
11558            .await?;
11559        assert_eq!(row_ids, vec![100]);
11560
11561        Ok(())
11562    }
11563
11564    #[rstest::rstest]
11565    #[case::v1(InvertedListFormatVersion::V1, LEGACY_BLOCK_SIZE)]
11566    #[case::v2(InvertedListFormatVersion::V2, LEGACY_BLOCK_SIZE)]
11567    #[case::v3_128(InvertedListFormatVersion::V3, LEGACY_BLOCK_SIZE)]
11568    #[case::v3_256(InvertedListFormatVersion::V3, 256)]
11569    #[tokio::test]
11570    async fn test_merge_segments_preserves_format_version(
11571        #[case] format_version: InvertedListFormatVersion,
11572        #[case] block_size: usize,
11573    ) -> Result<()> {
11574        let src_dir = TempObjDir::default();
11575        let dest_dir = TempObjDir::default();
11576        let src_store = Arc::new(LanceIndexStore::new(
11577            ObjectStore::local().into(),
11578            src_dir.clone(),
11579            Arc::new(LanceCache::no_cache()),
11580        ));
11581        let dest_store = Arc::new(LanceIndexStore::new(
11582            ObjectStore::local().into(),
11583            dest_dir.clone(),
11584            Arc::new(LanceCache::no_cache()),
11585        ));
11586        let params = InvertedIndexParams::default()
11587            .block_size(block_size)?
11588            .format_version(format_version);
11589
11590        let index =
11591            write_single_partition_index(src_store, params, TokenSetFormat::Fst, "hello", 100)
11592                .await?;
11593        assert_eq!(index.format_version(), format_version);
11594
11595        let created = InvertedIndex::merge_segments(
11596            &[index],
11597            empty_doc_stream(),
11598            dest_store.as_ref(),
11599            None,
11600            crate::progress::noop_progress(),
11601        )
11602        .await?;
11603        assert_eq!(created.index_version, format_version.index_version());
11604
11605        let merged = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?;
11606        assert_eq!(merged.format_version(), format_version);
11607        assert_eq!(merged.index_version(), format_version.index_version());
11608
11609        Ok(())
11610    }
11611
11612    #[tokio::test]
11613    async fn test_merge_segments_uses_memory_limit_for_old_partitions() -> Result<()> {
11614        let src_dir_1 = TempObjDir::default();
11615        let src_dir_2 = TempObjDir::default();
11616        let dest_dir = TempObjDir::default();
11617        let src_store_1 = Arc::new(LanceIndexStore::new(
11618            ObjectStore::local().into(),
11619            src_dir_1.clone(),
11620            Arc::new(LanceCache::no_cache()),
11621        ));
11622        let src_store_2 = Arc::new(LanceIndexStore::new(
11623            ObjectStore::local().into(),
11624            src_dir_2.clone(),
11625            Arc::new(LanceCache::no_cache()),
11626        ));
11627        let dest_store = Arc::new(LanceIndexStore::new(
11628            ObjectStore::local().into(),
11629            dest_dir.clone(),
11630            Arc::new(LanceCache::no_cache()),
11631        ));
11632
11633        let params = InvertedIndexParams::default().memory_limit_mb(0);
11634        let first = write_single_partition_index(
11635            src_store_1,
11636            params.clone(),
11637            TokenSetFormat::default(),
11638            "alpha",
11639            100,
11640        )
11641        .await?;
11642        let second = write_single_partition_index(
11643            src_store_2,
11644            params,
11645            TokenSetFormat::default(),
11646            "beta",
11647            200,
11648        )
11649        .await?;
11650
11651        let mut builder =
11652            InvertedIndexBuilder::new(InvertedIndexParams::default().memory_limit_mb(0))
11653                .with_token_set_format(TokenSetFormat::default());
11654        builder
11655            .update_from_segments(
11656                empty_doc_stream(),
11657                dest_store.as_ref(),
11658                &[first, second],
11659                None,
11660            )
11661            .await?;
11662
11663        let merged = InvertedIndex::load(dest_store, None, &LanceCache::no_cache()).await?;
11664        assert_eq!(merged.partitions.len(), 2);
11665        let mut partition_ids = merged
11666            .partitions
11667            .iter()
11668            .map(|partition| partition.id())
11669            .collect::<Vec<_>>();
11670        partition_ids.sort_unstable();
11671        assert_eq!(partition_ids, vec![0, 1]);
11672
11673        Ok(())
11674    }
11675
11676    #[tokio::test]
11677    async fn test_modern_index_without_deleted_col_has_empty_bitmap() {
11678        // An index created before the deleted_fragments feature was added
11679        // will have a metadata file with num_rows=0 (no record batch data).
11680        // The load path should gracefully handle this with an empty bitmap.
11681        let tmpdir = TempObjDir::default();
11682        let store = Arc::new(LanceIndexStore::new(
11683            ObjectStore::local().into(),
11684            tmpdir.clone(),
11685            Arc::new(LanceCache::no_cache()),
11686        ));
11687
11688        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
11689        builder.tokens.add("test".to_owned());
11690        builder.posting_lists.push(PostingListBuilder::new(false));
11691        builder.posting_lists[0].add(0, PositionRecorder::Count(1));
11692        builder.docs.append(100, 1);
11693        builder.write(store.as_ref()).await.unwrap();
11694
11695        // Write a metadata file WITHOUT the deleted_fragments column
11696        // (simulates an older index version)
11697        let metadata = std::collections::HashMap::from_iter(vec![
11698            (
11699                "partitions".to_owned(),
11700                serde_json::to_string(&vec![0u64]).unwrap(),
11701            ),
11702            (
11703                "params".to_owned(),
11704                serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
11705            ),
11706            (
11707                TOKEN_SET_FORMAT_KEY.to_owned(),
11708                TokenSetFormat::default().to_string(),
11709            ),
11710        ]);
11711        let mut writer = store
11712            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
11713            .await
11714            .unwrap();
11715        writer.finish_with_metadata(metadata).await.unwrap();
11716
11717        let index = InvertedIndex::load(store, None, &LanceCache::no_cache())
11718            .await
11719            .unwrap();
11720        assert!(
11721            index.deleted_fragments().is_empty(),
11722            "index without deleted_fragments column should have empty bitmap"
11723        );
11724    }
11725
11726    #[tokio::test]
11727    async fn flat_bm25_search_stream_with_metrics_records_elapsed_compute() {
11728        use crate::scalar::inverted::tokenizer::document_tokenizer::TextTokenizer;
11729        use arrow_array::{StringArray, UInt64Array};
11730        use lance_tokenizer::{SimpleTokenizer, TextAnalyzer};
11731
11732        // Tiny stream of one batch containing the query term in two rows.
11733        let schema = Arc::new(Schema::new(vec![
11734            ROW_ID_FIELD.clone(),
11735            Field::new("text", DataType::Utf8, false),
11736        ]));
11737        let batch = RecordBatch::try_new(
11738            schema.clone(),
11739            vec![
11740                Arc::new(UInt64Array::from(vec![0u64, 1, 2, 3])),
11741                Arc::new(StringArray::from(vec![
11742                    "the quick brown fox",
11743                    "lazy dog sleeps",
11744                    "the brown fox jumps over",
11745                    "completely unrelated text",
11746                ])),
11747            ],
11748        )
11749        .unwrap();
11750
11751        let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
11752            schema.clone(),
11753            stream::iter(vec![Ok(batch)]),
11754        ));
11755
11756        let tokenizer: Box<dyn LanceTokenizer> = Box::new(TextTokenizer::new(
11757            TextAnalyzer::builder(SimpleTokenizer::default()).build(),
11758        ));
11759
11760        let elapsed_compute = Time::default();
11761        let result_stream = flat_bm25_search_stream_with_metrics(
11762            input,
11763            "text".to_string(),
11764            "fox".to_string(),
11765            tokenizer,
11766            None,
11767            100,
11768            Some(elapsed_compute.clone()),
11769        )
11770        .await
11771        .unwrap();
11772
11773        let batches: Vec<_> = result_stream.try_collect().await.unwrap();
11774        assert!(!batches.is_empty(), "expected at least one scored batch");
11775
11776        // Both phase 1 (tokenize_and_count's spawn_cpu) and phase 2 (sync
11777        // scoring) call `add_duration` on the metric; verify the handle
11778        // was actually populated.
11779        assert!(
11780            elapsed_compute.value() > 0,
11781            "elapsed_compute should have been populated; got 0"
11782        );
11783    }
11784
11785    #[tokio::test]
11786    async fn flat_bm25_skips_zero_token_documents_from_corpus_stats() {
11787        let schema = Arc::new(Schema::new(vec![
11788            ROW_ID_FIELD.clone(),
11789            Field::new("text", DataType::Utf8, true),
11790        ]));
11791        let batch = RecordBatch::try_new(
11792            schema,
11793            vec![
11794                Arc::new(UInt64Array::from(vec![0_u64, 1, 2, 3, 4, 5])) as ArrayRef,
11795                Arc::new(StringArray::from(vec![
11796                    Some(""),
11797                    Some("   "),
11798                    Some("the"),
11799                    Some("overlength"),
11800                    None,
11801                    Some("hello"),
11802                ])) as ArrayRef,
11803            ],
11804        )
11805        .unwrap();
11806        let params = InvertedIndexParams::new("whitespace".to_string(), Language::English)
11807            .remove_stop_words(true)
11808            .stem(false)
11809            .max_token_length(Some(6));
11810        let query_tokens = Arc::new(Tokens::new(vec!["hello".to_string()], DocType::Text));
11811
11812        let counted_input = tokenize_and_count(
11813            stream::iter(vec![Ok(batch)]),
11814            params.build().unwrap(),
11815            query_tokens.clone(),
11816            1,
11817            None,
11818        )
11819        .await
11820        .unwrap();
11821
11822        assert_eq!(counted_input.num_rows(), 1);
11823        assert_eq!(
11824            counted_input[ROW_ID].as_primitive::<UInt64Type>().values(),
11825            &[5]
11826        );
11827        let scorer = initialize_scorer(None, query_tokens.as_ref(), &counted_input);
11828        let expected_scorer = MemBM25Scorer::new(1, 1, HashMap::from([("hello".to_string(), 1)]));
11829        assert_eq!(scorer.total_tokens, 1);
11830        assert_eq!(scorer.num_docs(), 1);
11831        assert_eq!(scorer.num_docs_containing_token("hello"), 1);
11832        assert_eq!(scorer.avg_doc_length(), expected_scorer.avg_doc_length());
11833        assert_eq!(
11834            scorer.query_weight("hello"),
11835            expected_scorer.query_weight("hello")
11836        );
11837    }
11838
11839    #[tokio::test]
11840    async fn flat_bm25_search_uses_full_document_length_for_normalization() {
11841        let schema = Arc::new(Schema::new(vec![
11842            ROW_ID_FIELD.clone(),
11843            Field::new("text", DataType::Utf8, false),
11844        ]));
11845        let batch = RecordBatch::try_new(
11846            schema.clone(),
11847            vec![
11848                Arc::new(UInt64Array::from(vec![0u64, 1])),
11849                Arc::new(StringArray::from(vec![
11850                    "alpha",
11851                    "alpha filler filler filler filler filler filler filler filler filler",
11852                ])),
11853            ],
11854        )
11855        .unwrap();
11856
11857        let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
11858            schema.clone(),
11859            stream::iter(vec![Ok(batch)]),
11860        ));
11861        let tokenizer: Box<dyn LanceTokenizer> = Box::new(TextTokenizer::new(
11862            TextAnalyzer::builder(SimpleTokenizer::default()).build(),
11863        ));
11864
11865        let result_stream = flat_bm25_search_stream_with_metrics(
11866            input,
11867            "text".to_string(),
11868            "alpha".to_string(),
11869            tokenizer,
11870            None,
11871            100,
11872            None,
11873        )
11874        .await
11875        .unwrap();
11876        let batches: Vec<_> = result_stream.try_collect().await.unwrap();
11877        let scored = arrow::compute::concat_batches(&FTS_SCHEMA, &batches).unwrap();
11878        let row_ids = scored[ROW_ID].as_primitive::<UInt64Type>();
11879        let scores = scored[SCORE_COL].as_primitive::<Float32Type>();
11880
11881        assert_eq!(row_ids.values(), &[0, 1]);
11882        assert!(
11883            scores.value(0) > scores.value(1),
11884            "same term frequency should score shorter document higher; short={}, long={}",
11885            scores.value(0),
11886            scores.value(1)
11887        );
11888    }
11889
11890    #[tokio::test]
11891    async fn flat_bm25_search_treats_string_lists_as_row_documents() {
11892        let mut docs_builder =
11893            GenericListBuilder::<i32, _>::new(GenericStringBuilder::<i32>::new());
11894        docs_builder.values().append_value("alpha");
11895        docs_builder.values().append_value("alpha beta");
11896        docs_builder.append(true);
11897        docs_builder.values().append_value("beta");
11898        docs_builder.append(true);
11899        docs_builder.append(true);
11900        docs_builder.values().append_null();
11901        docs_builder.append(true);
11902        docs_builder.append(false);
11903
11904        let docs = Arc::new(docs_builder.finish()) as ArrayRef;
11905        let schema = Arc::new(Schema::new(vec![
11906            ROW_ID_FIELD.clone(),
11907            Field::new("text", docs.data_type().clone(), true),
11908        ]));
11909        let batch = RecordBatch::try_new(
11910            schema.clone(),
11911            vec![
11912                Arc::new(UInt64Array::from(vec![0u64, 1, 2, 3, 4])) as ArrayRef,
11913                docs,
11914            ],
11915        )
11916        .unwrap();
11917
11918        let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
11919            schema.clone(),
11920            stream::iter(vec![Ok(batch)]),
11921        ));
11922        let tokenizer: Box<dyn LanceTokenizer> = Box::new(TextTokenizer::new(
11923            TextAnalyzer::builder(SimpleTokenizer::default()).build(),
11924        ));
11925
11926        let result_stream = flat_bm25_search_stream_with_metrics(
11927            input,
11928            "text".to_string(),
11929            "alpha".to_string(),
11930            tokenizer,
11931            None,
11932            100,
11933            None,
11934        )
11935        .await
11936        .unwrap();
11937        let batches: Vec<_> = result_stream.try_collect().await.unwrap();
11938        let scored = arrow::compute::concat_batches(&FTS_SCHEMA, &batches).unwrap();
11939        let row_ids = scored[ROW_ID].as_primitive::<UInt64Type>();
11940
11941        assert_eq!(row_ids.values(), &[0]);
11942    }
11943
11944    #[tokio::test]
11945    async fn flat_bm25_search_code_and_uses_position_groups() {
11946        let schema = Arc::new(Schema::new(vec![
11947            ROW_ID_FIELD.clone(),
11948            Field::new("code", DataType::Utf8, false),
11949        ]));
11950        let batch = RecordBatch::try_new(
11951            schema.clone(),
11952            vec![
11953                Arc::new(UInt64Array::from(vec![0u64, 1, 2, 3])),
11954                Arc::new(StringArray::from(vec![
11955                    "get user name",
11956                    "getUserName",
11957                    "get user",
11958                    "username",
11959                ])),
11960            ],
11961        )
11962        .unwrap();
11963
11964        let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
11965            schema.clone(),
11966            stream::iter(vec![Ok(batch)]),
11967        ));
11968        let tokenizer = InvertedIndexParams::code()
11969            .split_identifiers(true)
11970            .build()
11971            .unwrap();
11972
11973        let result_stream = flat_bm25_search_stream_with_metrics_and_operator(
11974            input,
11975            "code".to_string(),
11976            "getUserName".to_string(),
11977            tokenizer,
11978            None,
11979            100,
11980            Operator::And,
11981            None,
11982        )
11983        .await
11984        .unwrap();
11985
11986        let batches: Vec<_> = result_stream.try_collect().await.unwrap();
11987        let scored = arrow::compute::concat_batches(&FTS_SCHEMA, &batches).unwrap();
11988        let mut row_ids = scored[ROW_ID]
11989            .as_primitive::<UInt64Type>()
11990            .values()
11991            .to_vec();
11992        row_ids.sort_unstable();
11993
11994        assert_eq!(row_ids, vec![0, 1]);
11995    }
11996
11997    #[tokio::test]
11998    async fn flat_bm25_search_code_and_counts_repeated_subwords() {
11999        let schema = Arc::new(Schema::new(vec![
12000            ROW_ID_FIELD.clone(),
12001            Field::new("code", DataType::Utf8, false),
12002        ]));
12003        let batch = RecordBatch::try_new(
12004            schema.clone(),
12005            vec![
12006                Arc::new(UInt64Array::from(vec![0u64, 1])),
12007                Arc::new(StringArray::from(vec![
12008                    "pub fn edge_flat_generic_return<T>() -> Result<T, EdgeFlatError> where T: TryFrom<String> { todo!() }",
12009                    "pub fn edge_flat_generic_return<T>() -> Result<T> { todo!() }",
12010                ])),
12011            ],
12012        )
12013        .unwrap();
12014
12015        let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
12016            schema.clone(),
12017            stream::iter(vec![Ok(batch)]),
12018        ));
12019        let tokenizer = InvertedIndexParams::code().build().unwrap();
12020
12021        let result_stream = flat_bm25_search_stream_with_metrics_and_operator(
12022            input,
12023            "code".to_string(),
12024            "edge_flat_generic_return TryFrom EdgeFlatError Result".to_string(),
12025            tokenizer,
12026            None,
12027            100,
12028            Operator::And,
12029            None,
12030        )
12031        .await
12032        .unwrap();
12033
12034        let batches: Vec<_> = result_stream.try_collect().await.unwrap();
12035        let scored = arrow::compute::concat_batches(&FTS_SCHEMA, &batches).unwrap();
12036        let row_ids = scored[ROW_ID].as_primitive::<UInt64Type>().values();
12037
12038        assert_eq!(row_ids, &[0]);
12039    }
12040
12041    fn posting_entries(posting: &PostingList) -> Vec<(u64, u32)> {
12042        posting.iter().map(|(doc, freq, _)| (doc, freq)).collect()
12043    }
12044
12045    /// Runtime synthetic grouping must return correct posting lists for every
12046    /// token, including across synthetic group boundaries.
12047    #[tokio::test]
12048    async fn test_posting_list_synthetic_grouping_reads_group_boundaries() {
12049        let tmpdir = TempObjDir::default();
12050        let store = Arc::new(LanceIndexStore::new(
12051            ObjectStore::local().into(),
12052            tmpdir.clone(),
12053            Arc::new(LanceCache::no_cache()),
12054        ));
12055
12056        let num_tokens = runtime_posting_group_tokens() as u32 + 4;
12057        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12058        for t in 0..num_tokens {
12059            builder.tokens.add(format!("t{t}"));
12060            let mut pl = PostingListBuilder::new(false);
12061            pl.add(t, PositionRecorder::Count(1));
12062            builder.posting_lists.push(pl);
12063            builder.docs.append(1000 + t as u64, 1);
12064        }
12065        builder.write(store.as_ref()).await.unwrap();
12066
12067        let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
12068        let cache = LanceCache::no_cache();
12069        let posting_reader = PostingListReader::try_new(reader, &cache).await.unwrap();
12070        assert!(
12071            matches!(
12072                &posting_reader.grouping,
12073                PostingGrouping::SyntheticFixed { .. }
12074            ),
12075            "v2 reader must synthesize runtime posting groups",
12076        );
12077
12078        let metrics = NoOpMetricsCollector;
12079        for token in 0..num_tokens {
12080            let posting = posting_reader
12081                .posting_list(token, false, &metrics)
12082                .await
12083                .unwrap();
12084            assert_eq!(
12085                posting_entries(&posting),
12086                vec![(token as u64, 1)],
12087                "synthetic grouping mismatch for token {token}",
12088            );
12089            assert_eq!(posting.len(), 1, "length mismatch for token {token}");
12090        }
12091    }
12092
12093    /// Prewarm must populate exactly the `PostingListGroupKey`s the read path
12094    /// looks up — in particular the final group, whose `end` both paths derive
12095    /// from `self.len()`. If those derivations drifted (e.g. one used
12096    /// `num_rows()` and the other the loaded posting count), the last group's
12097    /// warm entry would be missing and prewarm silently wasted (issue #7040).
12098    #[tokio::test]
12099    async fn test_prewarm_group_keys_match_read_path() {
12100        let tmpdir = TempObjDir::default();
12101        let store = Arc::new(LanceIndexStore::new(
12102            ObjectStore::local().into(),
12103            tmpdir.clone(),
12104            Arc::new(LanceCache::no_cache()),
12105        ));
12106
12107        let num_tokens = runtime_posting_group_tokens() as u32 + 4;
12108        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12109        for t in 0..num_tokens {
12110            builder.tokens.add(format!("t{t}"));
12111            let mut pl = PostingListBuilder::new(false);
12112            pl.add(t, PositionRecorder::Count(1));
12113            builder.posting_lists.push(pl);
12114            builder.docs.append(1000 + t as u64, 1);
12115        }
12116        builder.write(store.as_ref()).await.unwrap();
12117
12118        let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
12119        // A real (strong) cache must outlive the reader's weak handle so the
12120        // prewarmed entries are still resolvable below.
12121        let cache = LanceCache::with_capacity(1 << 20);
12122        let posting_reader = PostingListReader::try_new(reader, &cache).await.unwrap();
12123        assert!(
12124            matches!(
12125                &posting_reader.grouping,
12126                PostingGrouping::SyntheticFixed { .. }
12127            ),
12128            "v2 reader should use runtime synthetic groups",
12129        );
12130
12131        posting_reader
12132            .prewarm_posting_lists(false, 2)
12133            .await
12134            .unwrap();
12135
12136        for token in 0..num_tokens {
12137            let (start, end) = posting_reader.group_range_for_token(token).unwrap();
12138            assert!(
12139                posting_reader
12140                    .index_cache
12141                    .get_with_key(&posting_list_group_cache_key(
12142                        start,
12143                        end,
12144                        posting_reader.has_impacts,
12145                    ))
12146                    .await
12147                    .is_some(),
12148                "prewarm did not populate group [{start}, {end}) that the read \
12149                 path requests for token {token}",
12150            );
12151        }
12152
12153        let (_, last_end) = posting_reader
12154            .group_range_for_token(num_tokens - 1)
12155            .unwrap();
12156        assert_eq!(
12157            last_end, num_tokens,
12158            "the last group must end at the posting count ({num_tokens})",
12159        );
12160    }
12161
12162    /// An empty partition has no synthetic groups because there are no token
12163    /// rows to cache.
12164    #[tokio::test]
12165    async fn test_empty_partition_has_no_synthetic_groups() {
12166        let tmpdir = TempObjDir::default();
12167        let store = Arc::new(LanceIndexStore::new(
12168            ObjectStore::local().into(),
12169            tmpdir.clone(),
12170            Arc::new(LanceCache::no_cache()),
12171        ));
12172
12173        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12174        builder.write(store.as_ref()).await.unwrap();
12175
12176        let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
12177        let posting_reader = PostingListReader::try_new(reader, &LanceCache::no_cache())
12178            .await
12179            .unwrap();
12180        assert!(
12181            matches!(&posting_reader.grouping, PostingGrouping::None),
12182            "reader for an empty partition must not create cache groups",
12183        );
12184        assert!(posting_reader.is_empty());
12185    }
12186
12187    /// A large posting list can share a runtime synthetic group with neighbors;
12188    /// grouping is token-count based and should still read every member intact.
12189    #[tokio::test]
12190    async fn test_large_posting_reads_inside_synthetic_group() {
12191        let tmpdir = TempObjDir::default();
12192        let store = Arc::new(LanceIndexStore::new(
12193            ObjectStore::local().into(),
12194            tmpdir.clone(),
12195            Arc::new(LanceCache::no_cache()),
12196        ));
12197
12198        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12199        let big_docs = (BLOCK_SIZE * 3 + 5) as u32;
12200        builder.tokens.add("big".to_owned());
12201        let mut big = PostingListBuilder::new(false);
12202        for d in 0..big_docs {
12203            big.add(d, PositionRecorder::Count(1));
12204        }
12205        builder.posting_lists.push(big);
12206        for t in 1..5u32 {
12207            builder.tokens.add(format!("t{t}"));
12208            let mut pl = PostingListBuilder::new(false);
12209            pl.add(0, PositionRecorder::Count(1));
12210            builder.posting_lists.push(pl);
12211        }
12212        for d in 0..big_docs as u64 {
12213            builder.docs.append(1000 + d, 1);
12214        }
12215        builder.write(store.as_ref()).await.unwrap();
12216
12217        let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
12218        let posting_reader = PostingListReader::try_new(reader, &LanceCache::no_cache())
12219            .await
12220            .unwrap();
12221        let expected_end = runtime_posting_group_tokens().min(5) as u32;
12222
12223        assert_eq!(
12224            posting_reader.group_range_for_token(0),
12225            Some((0, expected_end)),
12226            "runtime synthetic grouping should group by token count, not posting bytes",
12227        );
12228        let big = posting_reader
12229            .posting_list(0, false, &NoOpMetricsCollector)
12230            .await
12231            .unwrap();
12232        assert_eq!(big.len(), big_docs as usize);
12233        // A trailing tiny term (in the next, multi-token group) still reads back.
12234        let tiny = posting_reader
12235            .posting_list(2, false, &NoOpMetricsCollector)
12236            .await
12237            .unwrap();
12238        assert_eq!(tiny.len(), 1);
12239    }
12240
12241    /// Non-empty v2 indexes should prewarm synthetic `PostingListGroupKey`
12242    /// entries, matching what the read path then looks up without persisted
12243    /// grouping metadata.
12244    #[tokio::test]
12245    async fn test_prewarm_synthetic_grouping_populates_group_entries() {
12246        let tmpdir = TempObjDir::default();
12247        let store = Arc::new(LanceIndexStore::new(
12248            ObjectStore::local().into(),
12249            tmpdir.clone(),
12250            Arc::new(LanceCache::no_cache()),
12251        ));
12252
12253        let num_tokens = 3u32;
12254        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12255        for t in 0..num_tokens {
12256            builder.tokens.add(format!("t{t}"));
12257            let mut pl = PostingListBuilder::new(false);
12258            pl.add(t, PositionRecorder::Count(1));
12259            builder.posting_lists.push(pl);
12260            builder.docs.append(1000 + t as u64, 1);
12261        }
12262        builder.write(store.as_ref()).await.unwrap();
12263
12264        let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
12265        let cache = LanceCache::with_capacity(1 << 20);
12266        let posting_reader = PostingListReader::try_new(reader, &cache).await.unwrap();
12267        assert!(matches!(
12268            &posting_reader.grouping,
12269            PostingGrouping::SyntheticFixed { .. }
12270        ));
12271
12272        posting_reader
12273            .prewarm_posting_lists(false, 2)
12274            .await
12275            .unwrap();
12276
12277        for token_id in 0..num_tokens {
12278            let (start, end) = posting_reader.group_range_for_token(token_id).unwrap();
12279            let group = posting_reader
12280                .index_cache
12281                .get_with_key(&posting_list_group_cache_key(
12282                    start,
12283                    end,
12284                    posting_reader.has_impacts,
12285                ))
12286                .await
12287                .unwrap_or_else(|| {
12288                    panic!(
12289                        "synthetic prewarm should populate group [{start}, {end}) for token {token_id}"
12290                    )
12291                });
12292            assert!(
12293                group.is_packed(),
12294                "no-position synthetic prewarm should insert a packed group"
12295            );
12296            assert!(
12297                posting_reader
12298                    .index_cache
12299                    .get_with_key(&posting_list_cache_key(
12300                        token_id,
12301                        posting_reader.has_impacts,
12302                    ))
12303                    .await
12304                    .is_none(),
12305                "synthetic prewarm should not populate per-token entry {token_id}",
12306            );
12307        }
12308    }
12309
12310    /// End-to-end BM25 search over a grouped multi-group index must return the
12311    /// correct documents, and a warm-cache query must match the cold-cache
12312    /// result exactly (issue #7040).
12313    #[tokio::test]
12314    async fn test_grouped_bm25_search_correct_and_cache_stable() {
12315        let tmpdir = TempObjDir::default();
12316        let store = Arc::new(LanceIndexStore::new(
12317            ObjectStore::local().into(),
12318            tmpdir.clone(),
12319            Arc::new(LanceCache::no_cache()),
12320        ));
12321
12322        // Rare tokens (one doc each) plus one common token in every doc. The
12323        // token count exceeds the runtime group size so scoring must index
12324        // into the right synthetic group slot.
12325        let num_rare = runtime_posting_group_tokens() as u32 + 2;
12326        let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
12327        for t in 0..num_rare {
12328            builder.tokens.add(format!("t{t}"));
12329            builder.posting_lists.push(PostingListBuilder::new(false));
12330        }
12331        let common_id = builder.tokens.add("common".to_owned());
12332        builder.posting_lists.push(PostingListBuilder::new(false));
12333        for d in 0..num_rare {
12334            builder.posting_lists[d as usize].add(d, PositionRecorder::Count(1));
12335            builder.posting_lists[common_id as usize].add(d, PositionRecorder::Count(1));
12336            builder.docs.append(1000 + d as u64, 2);
12337        }
12338        builder.write(store.as_ref()).await.unwrap();
12339
12340        let metadata = HashMap::from([
12341            (
12342                "partitions".to_owned(),
12343                serde_json::to_string(&vec![0u64]).unwrap(),
12344            ),
12345            (
12346                "params".to_owned(),
12347                serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
12348            ),
12349            (
12350                TOKEN_SET_FORMAT_KEY.to_owned(),
12351                TokenSetFormat::default().to_string(),
12352            ),
12353        ]);
12354        let mut writer = store
12355            .new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
12356            .await
12357            .unwrap();
12358        writer.finish_with_metadata(metadata).await.unwrap();
12359
12360        let cache = Arc::new(LanceCache::with_capacity(1 << 20));
12361        let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
12362            .await
12363            .unwrap();
12364
12365        // A rare token in the middle of a group must resolve to its one doc.
12366        let query = |term: &str| {
12367            let index = index.clone();
12368            let term = term.to_string();
12369            async move {
12370                index
12371                    .bm25_search(
12372                        Arc::new(Tokens::new(vec![term], DocType::Text)),
12373                        Arc::new(FtsSearchParams::new().with_limit(Some(num_rare as usize))),
12374                        Operator::Or,
12375                        Arc::new(NoFilter),
12376                        Arc::new(NoOpMetricsCollector),
12377                        None,
12378                    )
12379                    .await
12380                    .unwrap()
12381            }
12382        };
12383
12384        let rare_query_id = num_rare / 2;
12385        let (rare_rows, _) = query(&format!("t{rare_query_id}")).await;
12386        assert_eq!(
12387            rare_rows,
12388            vec![1000 + rare_query_id as u64],
12389            "rare token must map to its single doc",
12390        );
12391
12392        // Cold vs warm cache must agree for the common (large) token.
12393        let (cold_rows, cold_scores) = query("common").await;
12394        let (warm_rows, warm_scores) = query("common").await;
12395        assert_eq!(cold_rows.len(), num_rare as usize);
12396        assert_eq!(cold_rows, warm_rows, "warm-cache rows must match cold");
12397        assert_eq!(
12398            cold_scores, warm_scores,
12399            "warm-cache scores must match cold"
12400        );
12401    }
12402
12403    #[tokio::test]
12404    async fn flat_bm25_search_stop_word_query_over_unindexed_rows_returns_empty() {
12405        let schema = Arc::new(Schema::new(vec![
12406            ROW_ID_FIELD.clone(),
12407            Field::new("text", DataType::Utf8, false),
12408        ]));
12409        let batch = RecordBatch::try_new(
12410            schema.clone(),
12411            vec![
12412                Arc::new(UInt64Array::from(vec![0u64, 1, 2])),
12413                Arc::new(StringArray::from(vec![
12414                    "the quick brown fox",
12415                    "a lazy dog",
12416                    "for the win",
12417                ])),
12418            ],
12419        )
12420        .unwrap();
12421
12422        let input: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
12423            schema.clone(),
12424            stream::iter(vec![Ok(batch)]),
12425        ));
12426
12427        // Analyzer with an English stop-word filter, so the query "the"
12428        // tokenizes to zero terms -- exactly the production trigger.
12429        let tokenizer: Box<dyn LanceTokenizer> = Box::new(TextTokenizer::new(
12430            TextAnalyzer::builder(SimpleTokenizer::default())
12431                .filter(StopWordFilter::new(Language::English).unwrap())
12432                .build(),
12433        ));
12434
12435        let result_stream = flat_bm25_search_stream_with_metrics(
12436            input,
12437            "text".to_string(),
12438            "the".to_string(),
12439            tokenizer,
12440            None,
12441            100,
12442            None,
12443        )
12444        .await
12445        .unwrap();
12446
12447        let batches: Vec<_> = result_stream.try_collect().await.unwrap();
12448        let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
12449        assert_eq!(
12450            total_rows, 0,
12451            "a stop-word-only query has no searchable terms and must match nothing"
12452        );
12453    }
12454}