Skip to main content

lance_index/scalar/inverted/
cache_codec.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Cache codec impls for FTS index entries.
5//!
6//! Serializes [`PostingList`] and [`Positions`] cache values for persistent
7//! cache backends, behind the stabilized envelope written by
8//! [`CacheCodec`](lance_core::cache::CacheCodec).
9//!
10//! Every variant uses a protobuf header (see `protos-cache/cache.proto`, with the
11//! tail/position codecs and position-storage kind as proto enums) followed by
12//! 64-byte-aligned Arrow IPC sections and, where applicable, raw blobs:
13//!
14//! - the compressed posting list: an IPC section for `blocks`, then the
15//!   position sections (legacy IPC, or shared block-offsets IPC + a raw blob of
16//!   the [`SharedPositionStream`] byte buffer, which has its own portable
17//!   encoding), then an optional impact IPC section;
18//! - the plain posting list: an IPC section of `(row_ids, frequencies)`, then
19//!   an optional legacy position IPC section;
20//! - a packed posting-list group: one IPC section containing the original
21//!   `List<LargeBinary>` posting rows and optional impact rows; prewarmed groups
22//!   omit score/length metadata and inject it from the posting reader into
23//!   query-local views;
24//! - the standalone [`Positions`] codec: the position sections alone.
25//!
26//! All sections read back zero-copy via [`lance_arrow::ipc`]. This is the FTS
27//! counterpart of `partition_serde.rs` for vector indices.
28
29use std::sync::Arc;
30
31use arrow_array::cast::AsArray;
32use arrow_array::types::{Float32Type, UInt32Type, UInt64Type};
33use arrow_array::{
34    Array, Float32Array, LargeBinaryArray, ListArray, RecordBatch, UInt32Array, UInt64Array,
35};
36use arrow_schema::{DataType, Field, Schema};
37use lance_core::cache::{CacheCodecImpl, CacheEntryReader, CacheEntryWriter};
38use lance_core::{Error, Result};
39
40use crate::cache_pb::{
41    CompressedPostingHeader, PlainPostingHeader, PositionStorage as PbPositionStorage,
42    PositionStreamCodec as PbPositionStreamCodec, PositionsHeader, PostingListGroupHeader,
43    PostingTailCodec as PbPostingTailCodec,
44};
45
46use super::impact::ImpactSkipData;
47use super::index::{
48    CompressedPositionStorage, CompressedPostingList, PlainPostingList, PositionStreamCodec,
49    Positions, PostingList, PostingListGroup, PostingListGroupStorage, PostingTailCodec,
50    SharedPositionStream,
51};
52use super::tokenizer::{LEGACY_BLOCK_SIZE, validate_block_size};
53
54// ---------------------------------------------------------------------------
55// Tags
56// ---------------------------------------------------------------------------
57
58const POSTING_VARIANT_PLAIN: u8 = 0;
59const POSTING_VARIANT_COMPRESSED: u8 = 1;
60const GROUP_VARIANT_MATERIALIZED: u8 = 0;
61const GROUP_VARIANT_PACKED: u8 = 1;
62
63// ---------------------------------------------------------------------------
64// Codec enum mappings
65// ---------------------------------------------------------------------------
66
67// Posting lists carry their discriminants as protobuf enums in the header;
68// these map to/from the in-memory Rust enums.
69
70fn posting_tail_codec_to_proto(c: PostingTailCodec) -> PbPostingTailCodec {
71    match c {
72        PostingTailCodec::Fixed32 => PbPostingTailCodec::Fixed32,
73        PostingTailCodec::VarintDelta => PbPostingTailCodec::VarintDelta,
74    }
75}
76
77fn proto_to_posting_tail_codec(c: PbPostingTailCodec) -> PostingTailCodec {
78    match c {
79        PbPostingTailCodec::Fixed32 => PostingTailCodec::Fixed32,
80        PbPostingTailCodec::VarintDelta => PostingTailCodec::VarintDelta,
81    }
82}
83
84fn posting_tail_codec_to_tag(c: PostingTailCodec) -> u8 {
85    match c {
86        PostingTailCodec::Fixed32 => 0,
87        PostingTailCodec::VarintDelta => 1,
88    }
89}
90
91fn posting_tail_codec_from_tag(tag: u8) -> Result<PostingTailCodec> {
92    match tag {
93        0 => Ok(PostingTailCodec::Fixed32),
94        1 => Ok(PostingTailCodec::VarintDelta),
95        other => Err(Error::io(format!(
96            "unknown packed posting tail codec: {other}"
97        ))),
98    }
99}
100
101fn position_stream_codec_to_proto(c: PositionStreamCodec) -> PbPositionStreamCodec {
102    match c {
103        PositionStreamCodec::VarintDocDelta => PbPositionStreamCodec::VarintDocDelta,
104        PositionStreamCodec::PackedDelta => PbPositionStreamCodec::PackedDelta,
105    }
106}
107
108fn proto_to_position_stream_codec(c: PbPositionStreamCodec) -> PositionStreamCodec {
109    match c {
110        PbPositionStreamCodec::VarintDocDelta => PositionStreamCodec::VarintDocDelta,
111        PbPositionStreamCodec::PackedDelta => PositionStreamCodec::PackedDelta,
112    }
113}
114
115// ---------------------------------------------------------------------------
116// Position storage sections (shared by PostingList variants and Positions)
117// ---------------------------------------------------------------------------
118
119const POSITION_LIST_COLUMN: &str = "position_list";
120const BLOCK_OFFSETS_COLUMN: &str = "block_offsets";
121const ROW_IDS_COLUMN: &str = "row_ids";
122const FREQUENCIES_COLUMN: &str = "frequencies";
123const BLOCKS_COLUMN: &str = "blocks";
124const IMPACTS_COLUMN: &str = "impacts";
125
126fn legacy_positions_batch(list: &ListArray) -> Result<RecordBatch> {
127    let schema = Arc::new(Schema::new(vec![Field::new(
128        POSITION_LIST_COLUMN,
129        list.data_type().clone(),
130        list.is_nullable(),
131    )]));
132    Ok(RecordBatch::try_new(schema, vec![Arc::new(list.clone())])?)
133}
134
135fn read_legacy_positions(r: &mut CacheEntryReader<'_>) -> Result<ListArray> {
136    let batch = r.read_ipc()?;
137    Ok(batch
138        .column_by_name(POSITION_LIST_COLUMN)
139        .ok_or_else(|| Error::io("legacy position column is missing".to_string()))?
140        .as_any()
141        .downcast_ref::<ListArray>()
142        .ok_or_else(|| Error::io("legacy position column is not a ListArray".to_string()))?
143        .clone())
144}
145
146/// Write the position sections (the bytes after the header) for `storage`. The
147/// caller's header proto carries the storage kind and shared-stream codec.
148fn write_position_sections(
149    w: &mut CacheEntryWriter<'_>,
150    storage: &CompressedPositionStorage,
151) -> Result<()> {
152    match storage {
153        CompressedPositionStorage::LegacyPerDoc(list) => {
154            w.write_ipc(&legacy_positions_batch(list)?)?;
155        }
156        CompressedPositionStorage::SharedStream(stream) => {
157            let offsets = UInt32Array::from(stream.block_offsets().to_vec());
158            let schema = Arc::new(Schema::new(vec![Field::new(
159                BLOCK_OFFSETS_COLUMN,
160                DataType::UInt32,
161                false,
162            )]));
163            let batch = RecordBatch::try_new(schema, vec![Arc::new(offsets)])?;
164            w.write_ipc(&batch)?;
165            w.write_raw(stream.bytes())?;
166        }
167    }
168    Ok(())
169}
170
171/// Read the position sections for the given `storage` kind and (for shared
172/// streams) `stream_codec`. Returns `None` only when `storage` is
173/// [`PbPositionStorage::None`].
174fn read_position_sections(
175    r: &mut CacheEntryReader<'_>,
176    storage: PbPositionStorage,
177    stream_codec: PositionStreamCodec,
178) -> Result<Option<CompressedPositionStorage>> {
179    match storage {
180        PbPositionStorage::None => Ok(None),
181        PbPositionStorage::Legacy => Ok(Some(CompressedPositionStorage::LegacyPerDoc(
182            read_legacy_positions(r)?,
183        ))),
184        PbPositionStorage::Shared => {
185            let batch = r.read_ipc()?;
186            let block_offsets = batch
187                .column_by_name(BLOCK_OFFSETS_COLUMN)
188                .ok_or_else(|| Error::io("block_offsets column is missing".to_string()))?
189                .as_primitive_opt::<UInt32Type>()
190                .ok_or_else(|| Error::io("block_offsets column is not UInt32".to_string()))?
191                .values()
192                .to_vec();
193            // Zero copy: read_raw returns a Bytes slice backed by the same
194            // allocation as the input, and SharedPositionStream stores its byte
195            // buffer as Bytes -- no copy on read.
196            let bytes = r.read_raw()?;
197            Ok(Some(CompressedPositionStorage::SharedStream(
198                SharedPositionStream::new(stream_codec, block_offsets, bytes),
199            )))
200        }
201    }
202}
203
204// ---------------------------------------------------------------------------
205// PostingList codec
206// ---------------------------------------------------------------------------
207
208impl CacheCodecImpl for PostingList {
209    const TYPE_ID: &'static str = "lance.fts.PostingList";
210    // Version 3 adds the optional impact IPC section. Main already used v2 for
211    // configurable posting block sizes, so impact data needs a distinct
212    // version to keep older readers from accepting a body with an extra
213    // section they cannot consume.
214    const CURRENT_VERSION: u32 = 3;
215
216    fn serialize(&self, w: &mut CacheEntryWriter<'_>) -> Result<()> {
217        match self {
218            Self::Plain(plain) => {
219                w.write_u8(POSTING_VARIANT_PLAIN)?;
220                serialize_plain(w, plain)
221            }
222            Self::Compressed(compressed) => {
223                w.write_u8(POSTING_VARIANT_COMPRESSED)?;
224                serialize_compressed(w, compressed)
225            }
226        }
227    }
228
229    fn deserialize(r: &mut CacheEntryReader<'_>) -> Result<Self> {
230        match r.version() {
231            1 | 2 | Self::CURRENT_VERSION => deserialize_posting_list_body(r),
232            other => Err(Error::io(format!(
233                "unsupported PostingList cache version: {other}"
234            ))),
235        }
236    }
237}
238
239fn deserialize_posting_list_body(r: &mut CacheEntryReader<'_>) -> Result<PostingList> {
240    let variant = r.read_u8()?;
241    match variant {
242        POSTING_VARIANT_PLAIN => Ok(PostingList::Plain(deserialize_plain(r)?)),
243        POSTING_VARIANT_COMPRESSED => Ok(PostingList::Compressed(deserialize_compressed(r)?)),
244        other => Err(Error::io(format!("unknown PostingList variant: {other}"))),
245    }
246}
247
248fn serialize_plain(w: &mut CacheEntryWriter<'_>, plain: &PlainPostingList) -> Result<()> {
249    // Plain postings carry only per-doc legacy positions (or none).
250    let position_storage = if plain.positions.is_some() {
251        PbPositionStorage::Legacy
252    } else {
253        PbPositionStorage::None
254    };
255    let header = PlainPostingHeader {
256        max_score: plain.max_score,
257        position_storage: position_storage as i32,
258    };
259    w.write_header(&header)?;
260
261    let row_ids = UInt64Array::new(plain.row_ids.clone(), None);
262    let frequencies = Float32Array::new(plain.frequencies.clone(), None);
263    let schema = Arc::new(Schema::new(vec![
264        Field::new(ROW_IDS_COLUMN, DataType::UInt64, false),
265        Field::new(FREQUENCIES_COLUMN, DataType::Float32, false),
266    ]));
267    let batch = RecordBatch::try_new(schema, vec![Arc::new(row_ids), Arc::new(frequencies)])?;
268    w.write_ipc(&batch)?;
269
270    if let Some(list) = &plain.positions {
271        w.write_ipc(&legacy_positions_batch(list)?)?;
272    }
273    Ok(())
274}
275
276fn deserialize_plain(r: &mut CacheEntryReader<'_>) -> Result<PlainPostingList> {
277    let header: PlainPostingHeader = r.read_header()?;
278
279    let batch = r.read_ipc()?;
280    let row_ids = batch
281        .column_by_name(ROW_IDS_COLUMN)
282        .ok_or_else(|| Error::io("row_ids column is missing".to_string()))?
283        .as_primitive_opt::<UInt64Type>()
284        .ok_or_else(|| Error::io("row_ids column is not UInt64".to_string()))?
285        .values()
286        .clone();
287    let frequencies = batch
288        .column_by_name(FREQUENCIES_COLUMN)
289        .ok_or_else(|| Error::io("frequencies column is missing".to_string()))?
290        .as_primitive_opt::<Float32Type>()
291        .ok_or_else(|| Error::io("frequencies column is not Float32".to_string()))?
292        .values()
293        .clone();
294
295    let positions = match header.position_storage() {
296        PbPositionStorage::None => None,
297        PbPositionStorage::Legacy => Some(read_legacy_positions(r)?),
298        PbPositionStorage::Shared => {
299            return Err(Error::io(
300                "Plain posting list cannot have a shared position stream".to_string(),
301            ));
302        }
303    };
304
305    Ok(PlainPostingList::new(
306        row_ids,
307        frequencies,
308        header.max_score,
309        positions,
310    ))
311}
312
313/// The compressed posting list is serialized with a protobuf header followed
314/// by 64-byte-aligned Arrow IPC sections (for the `blocks`, and for shared
315/// position block-offsets) and a raw blob (for the shared position byte
316/// stream, which already has its own portable encoding).
317fn serialize_compressed(
318    w: &mut CacheEntryWriter<'_>,
319    posting: &CompressedPostingList,
320) -> Result<()> {
321    let (position_storage, position_stream_codec) = match &posting.positions {
322        None => (PbPositionStorage::None, PbPositionStreamCodec::default()),
323        Some(CompressedPositionStorage::LegacyPerDoc(_)) => {
324            (PbPositionStorage::Legacy, PbPositionStreamCodec::default())
325        }
326        Some(CompressedPositionStorage::SharedStream(stream)) => (
327            PbPositionStorage::Shared,
328            position_stream_codec_to_proto(stream.codec()),
329        ),
330    };
331
332    let header = CompressedPostingHeader {
333        max_score: posting.max_score,
334        length: posting.length,
335        posting_tail_codec: posting_tail_codec_to_proto(posting.posting_tail_codec) as i32,
336        position_storage: position_storage as i32,
337        position_stream_codec: position_stream_codec as i32,
338        block_size: posting.block_size as u32,
339        has_impacts: posting.impacts.is_some(),
340    };
341    w.write_header(&header)?;
342
343    let schema = Arc::new(Schema::new(vec![Field::new(
344        BLOCKS_COLUMN,
345        DataType::LargeBinary,
346        false,
347    )]));
348    let batch = RecordBatch::try_new(schema, vec![Arc::new(posting.blocks.clone())])?;
349    w.write_ipc(&batch)?;
350
351    if let Some(storage) = &posting.positions {
352        write_position_sections(w, storage)?;
353    }
354    if let Some(impacts) = &posting.impacts {
355        let schema = Arc::new(Schema::new(vec![Field::new(
356            IMPACTS_COLUMN,
357            DataType::LargeBinary,
358            false,
359        )]));
360        let batch = RecordBatch::try_new(schema, vec![Arc::new(impacts.entries().clone())])?;
361        w.write_ipc(&batch)?;
362    }
363    Ok(())
364}
365
366fn deserialize_compressed(r: &mut CacheEntryReader<'_>) -> Result<CompressedPostingList> {
367    let header: CompressedPostingHeader = r.read_header()?;
368    let posting_tail_codec = proto_to_posting_tail_codec(header.posting_tail_codec());
369
370    let batch = r.read_ipc()?;
371    let blocks = batch
372        .column_by_name(BLOCKS_COLUMN)
373        .ok_or_else(|| Error::io("blocks column is missing".to_string()))?
374        .as_any()
375        .downcast_ref::<LargeBinaryArray>()
376        .ok_or_else(|| Error::io("blocks column is not a LargeBinaryArray".to_string()))?
377        .clone();
378
379    let stream_codec = proto_to_position_stream_codec(header.position_stream_codec());
380    let positions = read_position_sections(r, header.position_storage(), stream_codec)?;
381    let block_size = if header.block_size == 0 {
382        LEGACY_BLOCK_SIZE
383    } else {
384        validate_block_size(header.block_size as usize)?
385    };
386    let impacts = if r.version() >= 3 && header.has_impacts {
387        let batch = r.read_ipc()?;
388        let entries = batch
389            .column_by_name(IMPACTS_COLUMN)
390            .ok_or_else(|| Error::io("impacts column is missing".to_string()))?
391            .as_any()
392            .downcast_ref::<LargeBinaryArray>()
393            .ok_or_else(|| Error::io("impacts column is not a LargeBinaryArray".to_string()))?
394            .clone();
395        Some(ImpactSkipData::new(entries, blocks.len())?)
396    } else {
397        None
398    };
399
400    Ok(CompressedPostingList::new(
401        blocks,
402        header.max_score,
403        header.length,
404        posting_tail_codec,
405        block_size,
406        positions,
407        impacts,
408    ))
409}
410
411// ---------------------------------------------------------------------------
412// PostingListGroup codec
413// ---------------------------------------------------------------------------
414
415/// Version 2 distinguishes packed groups from the materialized fallback. A
416/// packed group writes one IPC batch; materialized groups retain the v1 inline
417/// member framing used by legacy and position-bearing prewarm paths. Version 3
418/// marks packed groups whose IPC schema can carry configurable posting block
419/// sizes while retaining the version-2 body framing. Version 4 adds optional
420/// impact data, either as an inline section for materialized members or as a
421/// column in the packed IPC batch.
422impl CacheCodecImpl for PostingListGroup {
423    const TYPE_ID: &'static str = "lance.fts.PostingListGroup";
424    const CURRENT_VERSION: u32 = 4;
425
426    fn serialize(&self, w: &mut CacheEntryWriter<'_>) -> Result<()> {
427        let count = u32::try_from(self.len())
428            .map_err(|_| Error::io("posting list group too large to serialize".to_string()))?;
429        match &self.storage {
430            PostingListGroupStorage::Materialized(posting_lists) => {
431                w.write_u8(GROUP_VARIANT_MATERIALIZED)?;
432                w.write_header(&PostingListGroupHeader { count })?;
433                for posting in posting_lists {
434                    posting.serialize(w)?;
435                }
436            }
437            PostingListGroupStorage::Packed(group) => {
438                w.write_u8(GROUP_VARIANT_PACKED)?;
439                w.write_header(&PostingListGroupHeader { count })?;
440                w.write_u8(posting_tail_codec_to_tag(group.posting_tail_codec))?;
441                w.write_ipc(&group.batch)?;
442            }
443        }
444        Ok(())
445    }
446
447    fn deserialize(r: &mut CacheEntryReader<'_>) -> Result<Self> {
448        match r.version() {
449            1 => return deserialize_materialized_group(r),
450            2 | 3 | Self::CURRENT_VERSION => {}
451            other => {
452                return Err(Error::io(format!(
453                    "unsupported PostingListGroup cache version: {other}"
454                )));
455            }
456        }
457
458        let variant = r.read_u8()?;
459        match variant {
460            GROUP_VARIANT_MATERIALIZED => deserialize_materialized_group(r),
461            GROUP_VARIANT_PACKED => {
462                let header: PostingListGroupHeader = r.read_header()?;
463                let posting_tail_codec = posting_tail_codec_from_tag(r.read_u8()?)?;
464                let batch = r.read_ipc()?;
465                if batch.num_rows() != header.count as usize {
466                    return Err(Error::io(format!(
467                        "packed posting group row count {} does not match header count {}",
468                        batch.num_rows(),
469                        header.count
470                    )));
471                }
472                Self::new_packed(batch, posting_tail_codec)
473            }
474            other => Err(Error::io(format!(
475                "unknown PostingListGroup variant: {other}"
476            ))),
477        }
478    }
479}
480
481fn deserialize_materialized_group(r: &mut CacheEntryReader<'_>) -> Result<PostingListGroup> {
482    let header: PostingListGroupHeader = r.read_header()?;
483    let mut posting_lists = Vec::with_capacity(header.count as usize);
484    for _ in 0..header.count {
485        posting_lists.push(deserialize_posting_list_body(r)?);
486    }
487    Ok(PostingListGroup::new(posting_lists))
488}
489
490// ---------------------------------------------------------------------------
491// Positions codec
492// ---------------------------------------------------------------------------
493
494impl CacheCodecImpl for Positions {
495    const TYPE_ID: &'static str = "lance.fts.Positions";
496    const CURRENT_VERSION: u32 = 1;
497
498    fn serialize(&self, w: &mut CacheEntryWriter<'_>) -> Result<()> {
499        let (position_storage, position_stream_codec) = match &self.0 {
500            CompressedPositionStorage::LegacyPerDoc(_) => {
501                (PbPositionStorage::Legacy, PbPositionStreamCodec::default())
502            }
503            CompressedPositionStorage::SharedStream(stream) => (
504                PbPositionStorage::Shared,
505                position_stream_codec_to_proto(stream.codec()),
506            ),
507        };
508        let header = PositionsHeader {
509            position_storage: position_storage as i32,
510            position_stream_codec: position_stream_codec as i32,
511        };
512        w.write_header(&header)?;
513        write_position_sections(w, &self.0)
514    }
515
516    fn deserialize(r: &mut CacheEntryReader<'_>) -> Result<Self> {
517        let header: PositionsHeader = r.read_header()?;
518        let stream_codec = proto_to_position_stream_codec(header.position_stream_codec());
519        read_position_sections(r, header.position_storage(), stream_codec)?
520            .map(Self)
521            .ok_or_else(|| {
522                Error::io("Positions cache entry cannot encode the None variant".to_string())
523            })
524    }
525}
526
527// ---------------------------------------------------------------------------
528// Tests
529// ---------------------------------------------------------------------------
530
531#[cfg(test)]
532mod tests {
533    use std::collections::HashMap;
534    use std::sync::Arc;
535
536    use arrow::buffer::ScalarBuffer;
537    use arrow_array::builder::{Int32Builder, LargeBinaryBuilder, ListBuilder};
538    use arrow_array::{Array, LargeBinaryArray, RecordBatch};
539    use arrow_schema::{Field, Schema};
540    use bytes::Bytes;
541    use lance_core::Result;
542    use lance_core::cache::{CacheCodecImpl, CacheEntryReader, CacheEntryWriter};
543
544    use crate::cache_pb::{CompressedPostingHeader, PostingTailCodec as PbPostingTailCodec};
545
546    use super::super::impact::{ImpactSkipData, ImpactSkipDataBuilder};
547    use super::super::index::{
548        CompressedPositionStorage, CompressedPostingList, IMPACT_COL, POSTING_BLOCK_SIZE_KEY,
549        POSTING_COL, PlainPostingList, PositionStreamCodec, Positions, PostingList,
550        PostingListGroup, PostingTailCodec, SharedPositionStream,
551    };
552    use super::super::tokenizer::LEGACY_BLOCK_SIZE;
553
554    fn legacy_positions(rows: &[&[i32]]) -> arrow_array::ListArray {
555        let mut builder = ListBuilder::new(Int32Builder::new());
556        for row in rows {
557            for v in *row {
558                builder.values().append_value(*v);
559            }
560            builder.append(true);
561        }
562        builder.finish()
563    }
564
565    fn packed_batch(postings: &[Vec<Vec<u8>>], block_size: Option<usize>) -> RecordBatch {
566        let mut builder = ListBuilder::new(LargeBinaryBuilder::new());
567        for posting in postings {
568            for block in posting {
569                builder.values().append_value(block);
570            }
571            builder.append(true);
572        }
573        let postings = builder.finish();
574        let fields = vec![Field::new(POSTING_COL, postings.data_type().clone(), false)];
575        let schema = Arc::new(match block_size {
576            Some(block_size) => Schema::new_with_metadata(
577                fields,
578                HashMap::from([(POSTING_BLOCK_SIZE_KEY.to_owned(), block_size.to_string())]),
579            ),
580            None => Schema::new(fields),
581        });
582        RecordBatch::try_new(schema, vec![Arc::new(postings)]).unwrap()
583    }
584
585    fn packed_group(
586        postings: &[Vec<Vec<u8>>],
587        posting_tail_codec: PostingTailCodec,
588        block_size: Option<usize>,
589    ) -> PostingListGroup {
590        PostingListGroup::new_packed(packed_batch(postings, block_size), posting_tail_codec)
591            .unwrap()
592    }
593
594    fn packed_group_with_impacts(
595        postings: &[Vec<Vec<u8>>],
596        impacts: &[ImpactSkipData],
597        posting_tail_codec: PostingTailCodec,
598        block_size: usize,
599    ) -> PostingListGroup {
600        assert_eq!(postings.len(), impacts.len());
601        let posting_batch = packed_batch(postings, Some(block_size));
602        let mut impacts_builder = ListBuilder::new(LargeBinaryBuilder::new());
603        for impacts in impacts {
604            for entry_idx in 0..impacts.entries().len() {
605                impacts_builder
606                    .values()
607                    .append_value(impacts.entries().value(entry_idx));
608            }
609            impacts_builder.append(true);
610        }
611        let impacts = impacts_builder.finish();
612        let fields = vec![
613            Field::new(
614                POSTING_COL,
615                posting_batch.column(0).data_type().clone(),
616                false,
617            ),
618            Field::new(IMPACT_COL, impacts.data_type().clone(), false),
619        ];
620        let schema = Arc::new(Schema::new_with_metadata(
621            fields,
622            posting_batch.schema_ref().metadata().clone(),
623        ));
624        let batch = RecordBatch::try_new(
625            schema,
626            vec![posting_batch.column(0).clone(), Arc::new(impacts)],
627        )
628        .unwrap();
629        PostingListGroup::new_packed(batch, posting_tail_codec).unwrap()
630    }
631
632    fn assert_plain_eq(a: &PlainPostingList, b: &PlainPostingList) {
633        assert_eq!(a.row_ids.as_ref(), b.row_ids.as_ref());
634        assert_eq!(a.frequencies.as_ref(), b.frequencies.as_ref());
635        assert_eq!(a.max_score, b.max_score);
636        match (&a.positions, &b.positions) {
637            (None, None) => {}
638            (Some(x), Some(y)) => assert_eq!(x, y),
639            _ => panic!("positions mismatch"),
640        }
641    }
642
643    fn assert_position_storage_eq(a: &CompressedPositionStorage, b: &CompressedPositionStorage) {
644        match (a, b) {
645            (
646                CompressedPositionStorage::LegacyPerDoc(x),
647                CompressedPositionStorage::LegacyPerDoc(y),
648            ) => assert_eq!(x, y),
649            (
650                CompressedPositionStorage::SharedStream(x),
651                CompressedPositionStorage::SharedStream(y),
652            ) => {
653                assert_eq!(x.codec(), y.codec());
654                assert_eq!(x.block_offsets(), y.block_offsets());
655                assert_eq!(x.bytes(), y.bytes());
656            }
657            _ => panic!("position storage variant mismatch"),
658        }
659    }
660
661    fn impact_skip_data(level0_len: usize, block_size: usize) -> ImpactSkipData {
662        let mut builder = ImpactSkipDataBuilder::with_capacity(level0_len, block_size);
663        for block_idx in 0..level0_len {
664            let doc_base = block_idx as u32 * 10;
665            builder
666                .append_block(&[
667                    (doc_base + 1, block_idx as u32 + 1, 10),
668                    (doc_base + 9, block_idx as u32 + 2, 8),
669                ])
670                .unwrap();
671        }
672        builder.finish().unwrap()
673    }
674
675    /// Serialize a codec body (no envelope) into a standalone buffer.
676    fn body_bytes<T: CacheCodecImpl>(entry: &T) -> Bytes {
677        let mut buf = Vec::new();
678        let mut w = CacheEntryWriter::new(&mut buf);
679        entry.serialize(&mut w).unwrap();
680        Bytes::from(buf)
681    }
682
683    /// Deserialize a codec body (no envelope) at the current build's version.
684    fn from_body<T: CacheCodecImpl>(data: &Bytes) -> Result<T> {
685        let mut r = CacheEntryReader::new(data, 0, T::CURRENT_VERSION);
686        T::deserialize(&mut r)
687    }
688
689    fn from_body_version<T: CacheCodecImpl>(data: &Bytes, version: u32) -> Result<T> {
690        let mut r = CacheEntryReader::new(data, 0, version);
691        T::deserialize(&mut r)
692    }
693
694    fn compressed_body_with_ipc_sections(
695        blocks: &RecordBatch,
696        impacts: Option<&RecordBatch>,
697    ) -> Bytes {
698        let mut buf = Vec::new();
699        let mut w = CacheEntryWriter::new(&mut buf);
700        w.write_u8(super::POSTING_VARIANT_COMPRESSED).unwrap();
701        w.write_header(&CompressedPostingHeader {
702            max_score: 1.0,
703            length: 1,
704            posting_tail_codec: PbPostingTailCodec::VarintDelta as i32,
705            block_size: 256,
706            has_impacts: impacts.is_some(),
707            ..Default::default()
708        })
709        .unwrap();
710        w.write_ipc(blocks).unwrap();
711        if let Some(impacts) = impacts {
712            w.write_ipc(impacts).unwrap();
713        }
714        Bytes::from(buf)
715    }
716
717    fn roundtrip_posting_list(entry: &PostingList) -> PostingList {
718        from_body::<PostingList>(&body_bytes(entry)).unwrap()
719    }
720
721    fn roundtrip_positions(entry: &Positions) -> Positions {
722        from_body::<Positions>(&body_bytes(entry)).unwrap()
723    }
724
725    fn assert_slice_points_into_bytes(slice: &[u8], bytes: &Bytes) {
726        let slice_start = slice.as_ptr() as usize;
727        let slice_end = slice_start + slice.len();
728        let bytes_start = bytes.as_ptr() as usize;
729        let bytes_end = bytes_start + bytes.len();
730        assert!(
731            slice_start >= bytes_start && slice_end <= bytes_end,
732            "slice [{slice_start:#x}, {slice_end:#x}) should point into bytes \
733             [{bytes_start:#x}, {bytes_end:#x})",
734        );
735    }
736
737    #[test]
738    fn plain_posting_list_no_positions_roundtrip() {
739        let plain = PlainPostingList::new(
740            ScalarBuffer::from(vec![10u64, 20, 30]),
741            ScalarBuffer::from(vec![0.5f32, 1.0, 1.5]),
742            Some(2.0),
743            None,
744        );
745        let entry = PostingList::Plain(plain.clone());
746        match roundtrip_posting_list(&entry) {
747            PostingList::Plain(restored) => assert_plain_eq(&plain, &restored),
748            PostingList::Compressed(_) => panic!("expected Plain variant"),
749        }
750    }
751
752    #[test]
753    fn plain_posting_list_with_positions_roundtrip() {
754        let plain = PlainPostingList::new(
755            ScalarBuffer::from(vec![1u64, 2]),
756            ScalarBuffer::from(vec![1.0f32, 1.0]),
757            None,
758            Some(legacy_positions(&[&[3, 7], &[1, 4, 9]])),
759        );
760        let entry = PostingList::Plain(plain.clone());
761        match roundtrip_posting_list(&entry) {
762            PostingList::Plain(restored) => assert_plain_eq(&plain, &restored),
763            PostingList::Compressed(_) => panic!("expected Plain variant"),
764        }
765    }
766
767    #[test]
768    fn compressed_posting_list_no_positions_roundtrip() {
769        // Two synthetic block payloads -- content is opaque to the codec.
770        let blocks = LargeBinaryArray::from_opt_vec(vec![
771            Some(&[1u8, 2, 3, 4, 5][..]),
772            Some(&[6, 7, 8, 9, 10][..]),
773        ]);
774        let posting = CompressedPostingList::new(
775            blocks,
776            3.5,
777            42,
778            PostingTailCodec::VarintDelta,
779            256,
780            None,
781            None,
782        );
783        let entry = PostingList::Compressed(posting.clone());
784        match roundtrip_posting_list(&entry) {
785            PostingList::Compressed(restored) => {
786                assert_eq!(restored.max_score, posting.max_score);
787                assert_eq!(restored.length, posting.length);
788                assert_eq!(restored.posting_tail_codec, posting.posting_tail_codec);
789                assert_eq!(restored.block_size, posting.block_size);
790                assert_eq!(restored.blocks, posting.blocks);
791                assert!(restored.positions.is_none());
792            }
793            PostingList::Plain(_) => panic!("expected Compressed variant"),
794        }
795    }
796
797    #[test]
798    fn compressed_posting_list_impacts_roundtrip() {
799        let blocks = LargeBinaryArray::from_opt_vec(vec![
800            Some(&[1u8, 2, 3, 4, 5][..]),
801            Some(&[6, 7, 8, 9, 10][..]),
802        ]);
803        let impacts = impact_skip_data(blocks.len(), 256);
804        let posting = CompressedPostingList::new(
805            blocks,
806            3.5,
807            42,
808            PostingTailCodec::VarintDelta,
809            256,
810            None,
811            Some(impacts.clone()),
812        );
813        let entry = PostingList::Compressed(posting);
814        match roundtrip_posting_list(&entry) {
815            PostingList::Compressed(restored) => {
816                let restored = restored.impacts.expect("impacts should roundtrip");
817                assert_eq!(restored.level0_len(), impacts.level0_len());
818                assert_eq!(restored.level1_len(), impacts.level1_len());
819                assert_eq!(restored.entries(), impacts.entries());
820            }
821            PostingList::Plain(_) => panic!("expected Compressed variant"),
822        }
823    }
824
825    #[test]
826    fn compressed_posting_list_missing_ipc_columns_returns_error() {
827        let empty = RecordBatch::new_empty(Arc::new(Schema::empty()));
828        assert!(
829            from_body::<PostingList>(&compressed_body_with_ipc_sections(&empty, None)).is_err()
830        );
831
832        let blocks = LargeBinaryArray::from_opt_vec(vec![Some(&[1_u8, 2, 3][..])]);
833        let schema = Arc::new(Schema::new(vec![Field::new(
834            super::BLOCKS_COLUMN,
835            blocks.data_type().clone(),
836            false,
837        )]));
838        let blocks = RecordBatch::try_new(schema, vec![Arc::new(blocks)]).unwrap();
839        assert!(
840            from_body::<PostingList>(&compressed_body_with_ipc_sections(&blocks, Some(&empty)))
841                .is_err()
842        );
843    }
844
845    #[test]
846    fn compressed_posting_list_v1_cache_without_impacts_decodes() {
847        let posting = CompressedPostingList::new(
848            LargeBinaryArray::from_opt_vec(vec![Some(&[1u8, 2, 3][..])]),
849            1.25,
850            5,
851            PostingTailCodec::Fixed32,
852            crate::scalar::inverted::LEGACY_BLOCK_SIZE,
853            None,
854            None,
855        );
856        let data = body_bytes(&PostingList::Compressed(posting));
857        let restored = from_body_version::<PostingList>(&data, 1).unwrap();
858        let PostingList::Compressed(restored) = restored else {
859            panic!("expected Compressed variant");
860        };
861        assert!(restored.impacts.is_none());
862    }
863
864    #[test]
865    fn compressed_posting_list_legacy_positions_roundtrip() {
866        let blocks = LargeBinaryArray::from_opt_vec(vec![Some(&[1u8, 2, 3][..])]);
867        let posting = CompressedPostingList::new(
868            blocks,
869            1.25,
870            5,
871            PostingTailCodec::Fixed32,
872            crate::scalar::inverted::LEGACY_BLOCK_SIZE,
873            Some(CompressedPositionStorage::LegacyPerDoc(legacy_positions(
874                &[&[0, 4, 8]],
875            ))),
876            None,
877        );
878        let entry = PostingList::Compressed(posting.clone());
879        match roundtrip_posting_list(&entry) {
880            PostingList::Compressed(restored) => {
881                assert_eq!(restored.posting_tail_codec, posting.posting_tail_codec);
882                assert_position_storage_eq(
883                    restored.positions.as_ref().unwrap(),
884                    posting.positions.as_ref().unwrap(),
885                );
886            }
887            PostingList::Plain(_) => panic!("expected Compressed variant"),
888        }
889    }
890
891    #[test]
892    fn compressed_posting_list_shared_stream_roundtrip() {
893        for codec in [
894            PositionStreamCodec::VarintDocDelta,
895            PositionStreamCodec::PackedDelta,
896        ] {
897            let blocks = LargeBinaryArray::from_opt_vec(vec![Some(&[9u8; 16][..])]);
898            let stream = SharedPositionStream::new(
899                codec,
900                vec![0u32, 4, 11],
901                Bytes::from((0u8..32).collect::<Vec<_>>()),
902            );
903            let posting = CompressedPostingList::new(
904                blocks,
905                7.0,
906                3,
907                PostingTailCodec::VarintDelta,
908                256,
909                Some(CompressedPositionStorage::SharedStream(stream)),
910                None,
911            );
912            let entry = PostingList::Compressed(posting.clone());
913            match roundtrip_posting_list(&entry) {
914                PostingList::Compressed(restored) => {
915                    assert_position_storage_eq(
916                        restored.positions.as_ref().unwrap(),
917                        posting.positions.as_ref().unwrap(),
918                    );
919                }
920                PostingList::Plain(_) => panic!("expected Compressed variant"),
921            }
922        }
923    }
924
925    #[test]
926    fn shared_stream_deserialize_borrows_from_input_bytes() {
927        let blocks = LargeBinaryArray::from_opt_vec(vec![Some(&[9u8; 16][..])]);
928        let expected_stream = SharedPositionStream::new(
929            PositionStreamCodec::PackedDelta,
930            vec![0u32, 4, 11],
931            Bytes::from((0u8..32).collect::<Vec<_>>()),
932        );
933        let posting = CompressedPostingList::new(
934            blocks,
935            7.0,
936            3,
937            PostingTailCodec::VarintDelta,
938            256,
939            Some(CompressedPositionStorage::SharedStream(
940                expected_stream.clone(),
941            )),
942            None,
943        );
944        let serialized = body_bytes(&PostingList::Compressed(posting));
945
946        let restored = from_body::<PostingList>(&serialized).unwrap();
947        let PostingList::Compressed(restored) = restored else {
948            panic!("expected Compressed variant");
949        };
950        let Some(CompressedPositionStorage::SharedStream(stream)) = restored.positions else {
951            panic!("expected shared-stream positions");
952        };
953
954        assert_eq!(stream.codec(), expected_stream.codec());
955        assert_eq!(stream.block_offsets(), expected_stream.block_offsets());
956        assert_eq!(stream.bytes(), expected_stream.bytes());
957        assert_slice_points_into_bytes(stream.bytes(), &serialized);
958    }
959
960    #[test]
961    fn posting_list_group_roundtrip() {
962        // Mix of plain and compressed members, including an empty group.
963        let plain = PostingList::Plain(PlainPostingList::new(
964            ScalarBuffer::from(vec![1u64, 2, 3]),
965            ScalarBuffer::from(vec![1.0f32, 2.0, 3.0]),
966            Some(4.0),
967            None,
968        ));
969        let compressed = PostingList::Compressed(CompressedPostingList::new(
970            LargeBinaryArray::from_opt_vec(vec![Some(&[1u8, 2, 3][..])]),
971            2.5,
972            7,
973            PostingTailCodec::VarintDelta,
974            256,
975            None,
976            None,
977        ));
978
979        for members in [
980            Vec::new(),
981            vec![plain.clone()],
982            vec![plain.clone(), compressed, plain],
983        ] {
984            let group = PostingListGroup::new(members.clone());
985            let restored = from_body::<PostingListGroup>(&body_bytes(&group)).unwrap();
986            assert!(!restored.is_packed());
987            assert_eq!(restored.len(), members.len());
988            for (index, a) in members.iter().enumerate() {
989                let b = restored.posting_list(index, None, None).unwrap().unwrap();
990                match (a, &b) {
991                    (PostingList::Plain(x), PostingList::Plain(y)) => assert_plain_eq(x, y),
992                    (PostingList::Compressed(x), PostingList::Compressed(y)) => {
993                        assert_eq!(x.blocks, y.blocks);
994                        assert_eq!(x.length, y.length);
995                        assert_eq!(x.max_score, y.max_score);
996                    }
997                    _ => panic!("variant mismatch in group roundtrip"),
998                }
999            }
1000        }
1001    }
1002
1003    #[test]
1004    fn packed_posting_list_group_roundtrip_and_v1_fallback() {
1005        let group = packed_group(
1006            &[vec![vec![1, 2, 3], vec![4, 5]], vec![vec![7; 16 * 1024]]],
1007            PostingTailCodec::VarintDelta,
1008            Some(256),
1009        );
1010        let restored = from_body::<PostingListGroup>(&body_bytes(&group)).unwrap();
1011        assert!(restored.is_packed());
1012        assert_eq!(restored.len(), 2);
1013        for slot in 0..2 {
1014            let max_score = [1.5, 3.25][slot];
1015            let length = [3, 4096][slot];
1016            let expected = group
1017                .posting_list(slot, Some(max_score), Some(length))
1018                .unwrap()
1019                .unwrap();
1020            let actual = restored
1021                .posting_list(slot, Some(max_score), Some(length))
1022                .unwrap()
1023                .unwrap();
1024            let (PostingList::Compressed(expected), PostingList::Compressed(actual)) =
1025                (expected, actual)
1026            else {
1027                panic!("expected compressed packed posting views");
1028            };
1029            assert_eq!(actual.blocks, expected.blocks);
1030            assert_eq!(actual.max_score, expected.max_score);
1031            assert_eq!(actual.length, expected.length);
1032            assert_eq!(actual.posting_tail_codec, expected.posting_tail_codec);
1033            assert_eq!(actual.block_size, 256);
1034        }
1035
1036        let legacy_packed =
1037            packed_group(&[vec![vec![9, 8, 7]]], PostingTailCodec::VarintDelta, None);
1038        let restored = from_body::<PostingListGroup>(&body_bytes(&legacy_packed)).unwrap();
1039        let PostingList::Compressed(posting) = restored
1040            .posting_list(0, Some(2.0), Some(3))
1041            .unwrap()
1042            .unwrap()
1043        else {
1044            panic!("expected compressed legacy packed posting");
1045        };
1046        assert_eq!(posting.block_size, LEGACY_BLOCK_SIZE);
1047
1048        let legacy_member = PostingList::Compressed(CompressedPostingList::new(
1049            LargeBinaryArray::from_opt_vec(vec![Some(&[9u8, 8, 7][..])]),
1050            2.0,
1051            3,
1052            PostingTailCodec::VarintDelta,
1053            LEGACY_BLOCK_SIZE,
1054            None,
1055            None,
1056        ));
1057        let mut legacy_body = Vec::new();
1058        let mut writer = CacheEntryWriter::new(&mut legacy_body);
1059        writer
1060            .write_header(&crate::cache_pb::PostingListGroupHeader { count: 1 })
1061            .unwrap();
1062        legacy_member.serialize(&mut writer).unwrap();
1063        let legacy_body = Bytes::from(legacy_body);
1064        let mut reader = CacheEntryReader::new(&legacy_body, 0, 1);
1065        let restored = PostingListGroup::deserialize(&mut reader).unwrap();
1066        assert!(!restored.is_packed());
1067        assert_eq!(restored.len(), 1);
1068    }
1069
1070    #[test]
1071    fn posting_list_group_impacted_compressed_members_roundtrip() {
1072        let first = CompressedPostingList::new(
1073            LargeBinaryArray::from_opt_vec(vec![Some(&[1u8, 2, 3][..]), Some(&[4u8, 5, 6][..])]),
1074            3.0,
1075            256,
1076            PostingTailCodec::VarintDelta,
1077            LEGACY_BLOCK_SIZE,
1078            None,
1079            Some(impact_skip_data(2, LEGACY_BLOCK_SIZE)),
1080        );
1081        let second = CompressedPostingList::new(
1082            LargeBinaryArray::from_opt_vec(vec![Some(&[7u8, 8, 9][..])]),
1083            5.0,
1084            128,
1085            PostingTailCodec::Fixed32,
1086            256,
1087            Some(CompressedPositionStorage::SharedStream(
1088                SharedPositionStream::new(
1089                    PositionStreamCodec::PackedDelta,
1090                    vec![0u32, 12],
1091                    Bytes::from(vec![0xABu8; 32]),
1092                ),
1093            )),
1094            Some(impact_skip_data(1, 256)),
1095        );
1096        let members = vec![
1097            PostingList::Compressed(first.clone()),
1098            PostingList::Compressed(second.clone()),
1099        ];
1100        let group = PostingListGroup::new(members);
1101        let restored = from_body::<PostingListGroup>(&body_bytes(&group)).unwrap();
1102        assert!(!restored.is_packed());
1103        assert_eq!(restored.len(), 2);
1104
1105        let expected = [&first, &second];
1106        for (slot, expected) in expected.iter().enumerate() {
1107            let restored = restored.posting_list(slot, None, None).unwrap().unwrap();
1108            let PostingList::Compressed(restored) = restored else {
1109                panic!("expected compressed member");
1110            };
1111            assert_eq!(restored.blocks, expected.blocks);
1112            assert_eq!(restored.length, expected.length);
1113            assert_eq!(restored.max_score, expected.max_score);
1114            assert_eq!(restored.posting_tail_codec, expected.posting_tail_codec);
1115            assert_eq!(restored.block_size, expected.block_size);
1116            assert_eq!(
1117                restored.impacts.as_ref().unwrap().entries(),
1118                expected.impacts.as_ref().unwrap().entries()
1119            );
1120            match (&expected.positions, &restored.positions) {
1121                (Some(expected), Some(restored)) => {
1122                    assert_position_storage_eq(expected, restored);
1123                }
1124                (None, None) => {}
1125                _ => panic!("position storage mismatch"),
1126            }
1127        }
1128    }
1129
1130    #[test]
1131    fn packed_posting_list_group_impacts_roundtrip() {
1132        let postings = vec![vec![vec![1, 2, 3], vec![4, 5, 6]], vec![vec![7, 8, 9]]];
1133        let expected_impacts = vec![impact_skip_data(2, 256), impact_skip_data(1, 256)];
1134        let group = packed_group_with_impacts(
1135            &postings,
1136            &expected_impacts,
1137            PostingTailCodec::VarintDelta,
1138            256,
1139        );
1140
1141        let restored = from_body::<PostingListGroup>(&body_bytes(&group)).unwrap();
1142        assert!(restored.is_packed());
1143        assert_eq!(restored.len(), expected_impacts.len());
1144        for (slot, expected) in expected_impacts.iter().enumerate() {
1145            let posting = restored
1146                .posting_list(slot, Some(3.0), Some(256))
1147                .unwrap()
1148                .unwrap();
1149            let PostingList::Compressed(posting) = posting else {
1150                panic!("expected compressed packed posting");
1151            };
1152            let actual = posting.impacts.as_ref().expect("impacts should roundtrip");
1153            assert_eq!(actual.entries(), expected.entries());
1154            assert_eq!(actual.level0_len(), expected.level0_len());
1155            assert_eq!(
1156                actual.level1_doc_up_to(0),
1157                expected.level1_doc_up_to(0),
1158                "impact entries should remain decodable with the packed block size",
1159            );
1160            assert!(actual.level1_doc_up_to(0).is_some());
1161        }
1162    }
1163
1164    #[test]
1165    fn positions_legacy_roundtrip() {
1166        let positions = Positions(CompressedPositionStorage::LegacyPerDoc(legacy_positions(
1167            &[&[1, 2, 3], &[], &[10]],
1168        )));
1169        let restored = roundtrip_positions(&positions);
1170        assert_position_storage_eq(&positions.0, &restored.0);
1171    }
1172
1173    #[test]
1174    fn positions_shared_stream_roundtrip() {
1175        let stream = SharedPositionStream::new(
1176            PositionStreamCodec::PackedDelta,
1177            vec![0u32, 8],
1178            Bytes::from(vec![0xAAu8; 24]),
1179        );
1180        let positions = Positions(CompressedPositionStorage::SharedStream(stream));
1181        let restored = roundtrip_positions(&positions);
1182        assert_position_storage_eq(&positions.0, &restored.0);
1183    }
1184
1185    #[test]
1186    fn truncated_data_errors() {
1187        let plain = PlainPostingList::new(
1188            ScalarBuffer::from(vec![1u64]),
1189            ScalarBuffer::from(vec![1.0f32]),
1190            None,
1191            None,
1192        );
1193        let entry = PostingList::Plain(plain);
1194        let mut buf = body_bytes(&entry).to_vec();
1195        buf.truncate(buf.len() / 2);
1196        assert!(from_body::<PostingList>(&Bytes::from(buf)).is_err());
1197    }
1198
1199    /// Tests covering the stabilized envelope + compressed proto format,
1200    /// exercised through the full type-erased [`CacheCodec`] (envelope + body).
1201    mod stable_format {
1202        use std::sync::Arc;
1203
1204        use arrow_array::Array;
1205        use arrow_schema::DataType;
1206        use lance_core::cache::{
1207            CacheCodec, CacheCodecImpl, CacheDecode, CacheEntryReader, CacheEntryWriter,
1208            CacheMissReason,
1209        };
1210        use lance_core::{Error, Result};
1211        use prost::Message;
1212
1213        use super::super::{
1214            BLOCKS_COLUMN, GROUP_VARIANT_PACKED, POSTING_VARIANT_COMPRESSED,
1215            posting_tail_codec_to_tag,
1216        };
1217        use super::*;
1218        use crate::cache_pb::{
1219            CompressedPostingHeader, PostingListGroupHeader, PostingTailCodec as PbPostingTailCodec,
1220        };
1221
1222        type ArcAny = Arc<dyn std::any::Any + Send + Sync>;
1223
1224        struct PostingListV2Codec(PostingList);
1225
1226        impl CacheCodecImpl for PostingListV2Codec {
1227            const TYPE_ID: &'static str = <PostingList as CacheCodecImpl>::TYPE_ID;
1228            const CURRENT_VERSION: u32 = 2;
1229
1230            fn serialize(&self, w: &mut CacheEntryWriter<'_>) -> Result<()> {
1231                self.0.serialize(w)
1232            }
1233
1234            fn deserialize(r: &mut CacheEntryReader<'_>) -> Result<Self> {
1235                PostingList::deserialize(r).map(Self)
1236            }
1237        }
1238
1239        struct PostingListGroupV3Codec(PostingListGroup);
1240
1241        impl CacheCodecImpl for PostingListGroupV3Codec {
1242            const TYPE_ID: &'static str = <PostingListGroup as CacheCodecImpl>::TYPE_ID;
1243            const CURRENT_VERSION: u32 = 3;
1244
1245            fn serialize(&self, w: &mut CacheEntryWriter<'_>) -> Result<()> {
1246                self.0.serialize(w)
1247            }
1248
1249            fn deserialize(r: &mut CacheEntryReader<'_>) -> Result<Self> {
1250                PostingListGroup::deserialize(r).map(Self)
1251            }
1252        }
1253
1254        struct LegacyCompressedPostingV1 {
1255            blocks: LargeBinaryArray,
1256        }
1257
1258        impl CacheCodecImpl for LegacyCompressedPostingV1 {
1259            const TYPE_ID: &'static str = <PostingList as CacheCodecImpl>::TYPE_ID;
1260            const CURRENT_VERSION: u32 = 1;
1261
1262            fn serialize(&self, w: &mut CacheEntryWriter<'_>) -> Result<()> {
1263                w.write_u8(POSTING_VARIANT_COMPRESSED)?;
1264                w.write_header(&CompressedPostingHeader {
1265                    max_score: 2.0,
1266                    length: 3,
1267                    posting_tail_codec: PbPostingTailCodec::VarintDelta as i32,
1268                    ..Default::default()
1269                })?;
1270                let schema = Arc::new(Schema::new(vec![Field::new(
1271                    BLOCKS_COLUMN,
1272                    DataType::LargeBinary,
1273                    false,
1274                )]));
1275                let batch = RecordBatch::try_new(schema, vec![Arc::new(self.blocks.clone())])?;
1276                w.write_ipc(&batch)
1277            }
1278
1279            fn deserialize(_r: &mut CacheEntryReader<'_>) -> Result<Self> {
1280                Err(Error::io(
1281                    "LegacyCompressedPostingV1 is a writer-only test codec".to_string(),
1282                ))
1283            }
1284        }
1285
1286        struct LegacyPackedGroupV2 {
1287            batch: RecordBatch,
1288            posting_tail_codec: PostingTailCodec,
1289        }
1290
1291        impl CacheCodecImpl for LegacyPackedGroupV2 {
1292            const TYPE_ID: &'static str = <PostingListGroup as CacheCodecImpl>::TYPE_ID;
1293            const CURRENT_VERSION: u32 = 2;
1294
1295            fn serialize(&self, w: &mut CacheEntryWriter<'_>) -> Result<()> {
1296                w.write_u8(GROUP_VARIANT_PACKED)?;
1297                let count = u32::try_from(self.batch.num_rows())
1298                    .map_err(|_| Error::io("legacy packed group is too large".to_string()))?;
1299                w.write_header(&PostingListGroupHeader { count })?;
1300                w.write_u8(posting_tail_codec_to_tag(self.posting_tail_codec))?;
1301                w.write_ipc(&self.batch)
1302            }
1303
1304            fn deserialize(_r: &mut CacheEntryReader<'_>) -> Result<Self> {
1305                Err(Error::io(
1306                    "LegacyPackedGroupV2 is a writer-only test codec".to_string(),
1307                ))
1308            }
1309        }
1310
1311        fn codec() -> CacheCodec {
1312            CacheCodec::from_impl::<PostingList>()
1313        }
1314
1315        fn serialize_typed_entry<T: CacheCodecImpl + 'static>(entry: T) -> Vec<u8> {
1316            let any: ArcAny = Arc::new(entry);
1317            let mut buf = Vec::new();
1318            CacheCodec::from_impl::<T>()
1319                .serialize(&any, &mut buf)
1320                .unwrap();
1321            buf
1322        }
1323
1324        /// Serialize an entry through the full codec (envelope + body).
1325        fn serialize_entry(entry: PostingList) -> Vec<u8> {
1326            serialize_typed_entry(entry)
1327        }
1328
1329        /// A `Bytes` whose base address is 64-byte aligned, modelling a backend
1330        /// that reads cache entries into an aligned buffer.
1331        fn aligned_bytes(payload: &[u8]) -> Bytes {
1332            const ALIGN: usize = 64;
1333            let mut v = vec![0u8; payload.len() + ALIGN];
1334            let pad = (ALIGN - (v.as_ptr() as usize % ALIGN)) % ALIGN;
1335            v[pad..pad + payload.len()].copy_from_slice(payload);
1336            Bytes::from(v).slice(pad..pad + payload.len())
1337        }
1338
1339        fn compressed_with_shared_positions() -> PostingList {
1340            let blocks =
1341                LargeBinaryArray::from_opt_vec(vec![Some(&[9u8; 48][..]), Some(&[1u8; 48])]);
1342            let stream = SharedPositionStream::new(
1343                PositionStreamCodec::PackedDelta,
1344                vec![0u32, 4, 11],
1345                Bytes::from((0u8..64).collect::<Vec<_>>()),
1346            );
1347            PostingList::Compressed(CompressedPostingList::new(
1348                blocks,
1349                7.0,
1350                3,
1351                PostingTailCodec::VarintDelta,
1352                256,
1353                Some(CompressedPositionStorage::SharedStream(stream)),
1354                None,
1355            ))
1356        }
1357
1358        /// The compressed `blocks` (an aligned IPC section) and the shared
1359        /// position blob (a raw section) must both be borrowed zero-copy from
1360        /// the input even though the envelope pushes them to a non-zero,
1361        /// non-aligned starting offset.
1362        #[test]
1363        fn compressed_sections_are_zero_copy_through_envelope() {
1364            let serialized = aligned_bytes(&serialize_entry(compressed_with_shared_positions()));
1365            let restored = codec().deserialize(&serialized).hit().unwrap();
1366            let restored = restored.downcast::<PostingList>().unwrap();
1367            let PostingList::Compressed(restored) = restored.as_ref() else {
1368                panic!("expected Compressed");
1369            };
1370
1371            let base = serialized.as_ptr() as usize;
1372            let end = base + serialized.len();
1373            let points_in = |ptr: usize| ptr >= base && ptr < end;
1374
1375            // blocks IPC section decoded in place (no realigning memcpy).
1376            for buf in restored.blocks.to_data().buffers() {
1377                assert!(
1378                    points_in(buf.as_ptr() as usize),
1379                    "blocks buffer was realigned out of the input — misaligned IPC section",
1380                );
1381            }
1382            // shared position raw blob borrowed in place.
1383            let Some(CompressedPositionStorage::SharedStream(stream)) = &restored.positions else {
1384                panic!("expected shared stream");
1385            };
1386            assert!(points_in(stream.bytes().as_ptr() as usize));
1387        }
1388
1389        /// A packed group's single IPC batch and all posting views decoded from
1390        /// it must borrow the cache entry's aligned input buffer.
1391        #[test]
1392        fn packed_group_sections_are_zero_copy_through_envelope() {
1393            let postings = vec![
1394                vec![vec![9; 48], vec![9; 48]],
1395                vec![vec![1; 48], vec![1; 48]],
1396            ];
1397            let impacts = vec![impact_skip_data(2, 256), impact_skip_data(2, 256)];
1398            let group =
1399                packed_group_with_impacts(&postings, &impacts, PostingTailCodec::VarintDelta, 256);
1400
1401            let group_codec = CacheCodec::from_impl::<PostingListGroup>();
1402            let any: ArcAny = Arc::new(group);
1403            let mut buf = Vec::new();
1404            group_codec.serialize(&any, &mut buf).unwrap();
1405            let serialized = aligned_bytes(&buf);
1406
1407            let restored = group_codec.deserialize(&serialized).hit().unwrap();
1408            let restored = restored.downcast::<PostingListGroup>().unwrap();
1409
1410            let base = serialized.as_ptr() as usize;
1411            let end = base + serialized.len();
1412            let points_in = |ptr: usize| ptr >= base && ptr < end;
1413
1414            assert!(restored.is_packed());
1415            assert_eq!(restored.len(), 2);
1416            for slot in 0..restored.len() {
1417                let member = restored
1418                    .posting_list(slot, Some(7.0), Some(3))
1419                    .unwrap()
1420                    .unwrap();
1421                let PostingList::Compressed(member) = member else {
1422                    panic!("expected Compressed member");
1423                };
1424                for buf in member.blocks.to_data().buffers() {
1425                    assert!(
1426                        points_in(buf.as_ptr() as usize),
1427                        "group member blocks buffer was realigned out of the input — \
1428                        misaligned IPC section",
1429                    );
1430                }
1431                let impacts = member
1432                    .impacts
1433                    .as_ref()
1434                    .expect("packed impacts should decode");
1435                for buf in impacts.entries().to_data().buffers() {
1436                    assert!(
1437                        points_in(buf.as_ptr() as usize),
1438                        "group member impact buffer was realigned out of the input",
1439                    );
1440                }
1441            }
1442        }
1443
1444        /// The plain posting's row-id/frequency IPC section must also decode
1445        /// zero-copy through the envelope + proto header.
1446        #[test]
1447        fn plain_sections_are_zero_copy_through_envelope() {
1448            let plain = PostingList::Plain(PlainPostingList::new(
1449                ScalarBuffer::from((0u64..64).collect::<Vec<_>>()),
1450                ScalarBuffer::from(vec![1.0f32; 64]),
1451                Some(2.0),
1452                None,
1453            ));
1454            let serialized = aligned_bytes(&serialize_entry(plain));
1455            let restored = codec().deserialize(&serialized).hit().unwrap();
1456            let restored = restored.downcast::<PostingList>().unwrap();
1457            let PostingList::Plain(restored) = restored.as_ref() else {
1458                panic!("expected Plain");
1459            };
1460
1461            let base = serialized.as_ptr() as usize;
1462            let end = base + serialized.len();
1463            // The row_ids ScalarBuffer must borrow from the input allocation.
1464            let ptr = restored.row_ids.as_ptr() as usize;
1465            assert!(
1466                ptr >= base && ptr < end,
1467                "row_ids buffer was realigned out of the input — misaligned IPC section",
1468            );
1469        }
1470
1471        /// Additive proto fields (lever #1) must not break decoding: an unknown
1472        /// field number appended to the header is ignored.
1473        #[test]
1474        fn header_proto_ignores_unknown_fields() {
1475            let header = CompressedPostingHeader {
1476                max_score: 1.5,
1477                length: 9,
1478                posting_tail_codec: PbPostingTailCodec::VarintDelta as i32,
1479                ..Default::default()
1480            };
1481            let mut bytes = header.encode_to_vec();
1482            // Append an unknown field #15, varint wire type (0), value 7.
1483            bytes.push(15 << 3);
1484            bytes.push(7);
1485            let decoded = CompressedPostingHeader::decode(bytes.as_slice()).unwrap();
1486            assert_eq!(decoded.length, 9);
1487            assert_eq!(decoded.max_score, 1.5);
1488        }
1489
1490        /// An entry written by a different codec (foreign TYPE_ID) misses.
1491        #[test]
1492        fn foreign_type_id_is_miss() {
1493            // A PostingListGroup entry carries a different TYPE_ID in its
1494            // envelope; reading it as a PostingList must miss, not misread it.
1495            let group = PostingListGroup::new(vec![]);
1496            let any: ArcAny = Arc::new(group);
1497            let mut buf = Vec::new();
1498            CacheCodec::from_impl::<PostingListGroup>()
1499                .serialize(&any, &mut buf)
1500                .unwrap();
1501            assert!(codec().deserialize(&Bytes::from(buf)).hit().is_none());
1502        }
1503
1504        /// An entry written by a newer build (higher type_version) misses.
1505        #[test]
1506        fn future_type_version_is_miss() {
1507            let mut buf = serialize_entry(compressed_with_shared_positions());
1508            // Patch the envelope's type_version (magic[4] + ver[1] + len[2] +
1509            // type_id[N]) to a value beyond what this build understands.
1510            let type_id_len = u16::from_le_bytes([buf[5], buf[6]]) as usize;
1511            let version_off = 4 + 1 + 2 + type_id_len;
1512            buf[version_off..version_off + 4].copy_from_slice(&u32::MAX.to_le_bytes());
1513            assert!(codec().deserialize(&Bytes::from(buf)).hit().is_none());
1514        }
1515
1516        #[test]
1517        fn old_codecs_reject_new_impact_envelopes_as_version_too_new() {
1518            let posting = Bytes::from(serialize_entry(compressed_with_shared_positions()));
1519            match CacheCodec::from_impl::<PostingListV2Codec>().deserialize(&posting) {
1520                CacheDecode::Miss(reason) => {
1521                    assert_eq!(reason, CacheMissReason::VersionTooNew)
1522                }
1523                CacheDecode::Hit(_) => panic!("v2 PostingList codec accepted a v3 envelope"),
1524            }
1525
1526            let group = packed_group(
1527                &[vec![vec![1, 2, 3]], vec![vec![4, 5, 6]]],
1528                PostingTailCodec::VarintDelta,
1529                Some(256),
1530            );
1531            let group = Bytes::from(serialize_typed_entry(group));
1532            match CacheCodec::from_impl::<PostingListGroupV3Codec>().deserialize(&group) {
1533                CacheDecode::Miss(reason) => {
1534                    assert_eq!(reason, CacheMissReason::VersionTooNew)
1535                }
1536                CacheDecode::Hit(_) => {
1537                    panic!("v3 PostingListGroup codec accepted a v4 envelope")
1538                }
1539            }
1540        }
1541
1542        #[test]
1543        fn current_codecs_read_previous_main_versions() {
1544            let previous_posting = PostingListV2Codec(compressed_with_shared_positions());
1545            let previous_posting = Bytes::from(serialize_typed_entry(previous_posting));
1546            let restored = codec().deserialize(&previous_posting).hit().unwrap();
1547            let restored = restored.downcast::<PostingList>().unwrap();
1548            let PostingList::Compressed(restored) = restored.as_ref() else {
1549                panic!("expected compressed posting");
1550            };
1551            assert_eq!(restored.block_size, 256);
1552            assert!(restored.impacts.is_none());
1553
1554            let previous_group = PostingListGroupV3Codec(packed_group(
1555                &[vec![vec![1, 2, 3]], vec![vec![4, 5, 6]]],
1556                PostingTailCodec::VarintDelta,
1557                Some(256),
1558            ));
1559            let previous_group = Bytes::from(serialize_typed_entry(previous_group));
1560            let restored = CacheCodec::from_impl::<PostingListGroup>()
1561                .deserialize(&previous_group)
1562                .hit()
1563                .unwrap()
1564                .downcast::<PostingListGroup>()
1565                .unwrap();
1566            assert!(restored.is_packed());
1567            assert_eq!(restored.len(), 2);
1568            let PostingList::Compressed(restored) = restored
1569                .posting_list(0, Some(2.0), Some(3))
1570                .unwrap()
1571                .unwrap()
1572            else {
1573                panic!("expected compressed packed posting");
1574            };
1575            assert_eq!(restored.block_size, 256);
1576            assert!(restored.impacts.is_none());
1577        }
1578
1579        #[test]
1580        fn current_codecs_read_legacy_payloads_without_block_size() {
1581            let legacy_posting = LegacyCompressedPostingV1 {
1582                blocks: LargeBinaryArray::from_opt_vec(vec![Some(&[9u8, 8, 7][..])]),
1583            };
1584            let legacy_posting = Bytes::from(serialize_typed_entry(legacy_posting));
1585            let restored = codec().deserialize(&legacy_posting).hit().unwrap();
1586            let restored = restored.downcast::<PostingList>().unwrap();
1587            let PostingList::Compressed(restored) = restored.as_ref() else {
1588                panic!("expected a compressed legacy posting");
1589            };
1590            assert_eq!(restored.block_size, LEGACY_BLOCK_SIZE);
1591
1592            let legacy_batch = packed_batch(&[vec![vec![1, 2, 3]], vec![vec![4, 5, 6]]], None);
1593            assert!(
1594                !legacy_batch
1595                    .schema_ref()
1596                    .metadata()
1597                    .contains_key(POSTING_BLOCK_SIZE_KEY)
1598            );
1599            let legacy_group = LegacyPackedGroupV2 {
1600                batch: legacy_batch,
1601                posting_tail_codec: PostingTailCodec::VarintDelta,
1602            };
1603            let legacy_group = Bytes::from(serialize_typed_entry(legacy_group));
1604            let restored = CacheCodec::from_impl::<PostingListGroup>()
1605                .deserialize(&legacy_group)
1606                .hit()
1607                .unwrap()
1608                .downcast::<PostingListGroup>()
1609                .unwrap();
1610            let PostingList::Compressed(restored) = restored
1611                .posting_list(0, Some(2.0), Some(3))
1612                .unwrap()
1613                .unwrap()
1614            else {
1615                panic!("expected a compressed legacy packed posting");
1616            };
1617            assert_eq!(restored.block_size, LEGACY_BLOCK_SIZE);
1618        }
1619
1620        /// A pre-stabilization blob (no magic) self-heals to a miss.
1621        #[test]
1622        fn pre_stabilization_blob_is_miss() {
1623            // Old format led with a u64 LE length prefix, never our magic.
1624            let mut blob = (30u64).to_le_bytes().to_vec();
1625            blob.extend_from_slice(&[0u8; 30]);
1626            assert!(codec().deserialize(&Bytes::from(blob)).hit().is_none());
1627        }
1628
1629        /// A structurally-valid envelope whose body leads with an out-of-range
1630        /// variant tag self-heals to a `BodyError` miss rather than panicking or
1631        /// misreading the remaining bytes.
1632        #[test]
1633        fn unknown_posting_variant_is_miss() {
1634            use lance_core::cache::{CacheDecode, CacheMissReason};
1635
1636            let mut buf = serialize_entry(compressed_with_shared_positions());
1637            // The variant tag is the first body byte, right after the envelope
1638            // (magic[4] + ver[1] + type_id_len[2] + type_id[N] + type_version[4]).
1639            let type_id_len = u16::from_le_bytes([buf[5], buf[6]]) as usize;
1640            let variant_off = 4 + 1 + 2 + type_id_len + 4;
1641            buf[variant_off] = 2; // neither PLAIN (0) nor COMPRESSED (1)
1642            match codec().deserialize(&Bytes::from(buf)) {
1643                CacheDecode::Miss(reason) => assert_eq!(reason, CacheMissReason::BodyError),
1644                CacheDecode::Hit(_) => panic!("expected a BodyError miss, got a hit"),
1645            }
1646        }
1647    }
1648}