Skip to main content

hermes_core/structures/postings/
partitioned_ef.rs

1//! Partitioned Elias-Fano (PEF) encoding
2//!
3//! Based on Ottaviano & Venturini (2014) "Partitioned Elias-Fano Indexes"
4//!
5//! Key improvements over standard Elias-Fano:
6//! - **Optimal partitioning**: Divides sequence into chunks with (1+ε)-optimal compression
7//! - **Better compression**: 30-40% smaller than standard EF on typical data
8//! - **Fast random access**: O(1) access within partitions
9//! - **Efficient NextGEQ**: Uses partition endpoints for fast seeking
10//!
11//! The algorithm finds optimal partition boundaries that minimize total encoding size.
12//! Each partition is encoded with its own Elias-Fano instance using local parameters.
13
14use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
15use std::io::{self, Read, Write};
16
17/// Minimum partition size (smaller partitions have too much overhead)
18const MIN_PARTITION_SIZE: usize = 64;
19
20/// Maximum partition size (larger partitions lose locality benefits)
21const MAX_PARTITION_SIZE: usize = 512;
22
23/// A single partition in the PEF structure
24#[derive(Debug, Clone)]
25pub struct EFPartition {
26    /// Lower bits array
27    lower_bits: Vec<u64>,
28    /// Upper bits array (unary encoded)
29    upper_bits: Vec<u64>,
30    /// Number of elements in this partition
31    len: u32,
32    /// First value in partition (absolute)
33    first_value: u32,
34    /// Last value in partition (absolute)
35    last_value: u32,
36    /// Number of lower bits per element
37    lower_bit_width: u8,
38}
39
40impl EFPartition {
41    /// Create a partition from a sorted slice
42    /// Values are stored relative to first_value for better compression
43    pub fn from_sorted_slice(values: &[u32]) -> Self {
44        if values.is_empty() {
45            return Self {
46                lower_bits: Vec::new(),
47                upper_bits: Vec::new(),
48                len: 0,
49                first_value: 0,
50                last_value: 0,
51                lower_bit_width: 0,
52            };
53        }
54
55        let first_value = values[0];
56        let last_value = *values.last().unwrap();
57        let n = values.len() as u64;
58
59        // Local universe: encode values relative to first_value
60        let local_universe = last_value - first_value + 1;
61
62        // Calculate lower bit width using local universe
63        let lower_bit_width = if n <= 1 {
64            0
65        } else {
66            let ratio = (local_universe as u64).max(1) / n.max(1);
67            if ratio <= 1 {
68                0
69            } else {
70                (64 - ratio.leading_zeros() - 1) as u8
71            }
72        };
73
74        // Allocate arrays
75        let lower_bits_total = (n as usize) * (lower_bit_width as usize);
76        let lower_words = lower_bits_total.div_ceil(64);
77        let mut lower_bits = vec![0u64; lower_words];
78
79        let max_relative = local_universe.saturating_sub(1) as u64;
80        let upper_bound = n + (max_relative >> lower_bit_width) + 1;
81        let upper_words = (upper_bound as usize).div_ceil(64);
82        let mut upper_bits = vec![0u64; upper_words];
83
84        let lower_mask = if lower_bit_width == 0 {
85            0
86        } else {
87            (1u64 << lower_bit_width) - 1
88        };
89
90        // Encode each value relative to first_value
91        for (i, &val) in values.iter().enumerate() {
92            let relative_val = (val - first_value) as u64;
93
94            // Store lower bits
95            if lower_bit_width > 0 {
96                let lower = relative_val & lower_mask;
97                let bit_pos = i * (lower_bit_width as usize);
98                let word_idx = bit_pos / 64;
99                let bit_offset = bit_pos % 64;
100
101                lower_bits[word_idx] |= lower << bit_offset;
102                if bit_offset + (lower_bit_width as usize) > 64 && word_idx + 1 < lower_bits.len() {
103                    lower_bits[word_idx + 1] |= lower >> (64 - bit_offset);
104                }
105            }
106
107            // Store upper bits
108            let upper = relative_val >> lower_bit_width;
109            let upper_pos = (i as u64) + upper;
110            let word_idx = (upper_pos / 64) as usize;
111            let bit_offset = upper_pos % 64;
112            if word_idx < upper_bits.len() {
113                upper_bits[word_idx] |= 1u64 << bit_offset;
114            }
115        }
116
117        Self {
118            lower_bits,
119            upper_bits,
120            len: values.len() as u32,
121            first_value,
122            last_value,
123            lower_bit_width,
124        }
125    }
126
127    /// Get element at position i (0-indexed)
128    #[inline]
129    pub fn get(&self, i: u32) -> Option<u32> {
130        if i >= self.len {
131            return None;
132        }
133
134        let i = i as usize;
135
136        // Get lower bits
137        let lower = if self.lower_bit_width == 0 {
138            0u64
139        } else {
140            let bit_pos = i * (self.lower_bit_width as usize);
141            let word_idx = bit_pos / 64;
142            let bit_offset = bit_pos % 64;
143            let lower_mask = (1u64 << self.lower_bit_width) - 1;
144
145            let mut val = (self.lower_bits[word_idx] >> bit_offset) & lower_mask;
146            if bit_offset + (self.lower_bit_width as usize) > 64
147                && word_idx + 1 < self.lower_bits.len()
148            {
149                val |= (self.lower_bits[word_idx + 1] << (64 - bit_offset)) & lower_mask;
150            }
151            val
152        };
153
154        // Get upper bits via select1(i) - i
155        let select_pos = self.select1(i as u32)?;
156        let upper = (select_pos as u64) - (i as u64);
157
158        // Reconstruct absolute value
159        let relative_val = (upper << self.lower_bit_width) | lower;
160        Some(self.first_value + relative_val as u32)
161    }
162
163    /// Find position of i-th set bit (optimized with NEON on aarch64)
164    fn select1(&self, i: u32) -> Option<u32> {
165        if i >= self.len {
166            return None;
167        }
168
169        let mut remaining = i + 1;
170        let mut pos = 0u32;
171
172        // Process 4 words at a time on aarch64 using NEON popcount
173        #[cfg(target_arch = "aarch64")]
174        {
175            use std::arch::aarch64::*;
176
177            let (chunks, remainder) = self.upper_bits.as_chunks::<4>();
178
179            for chunk in chunks {
180                // Load 4 u64 words (32 bytes)
181                let words: [u64; 4] = [chunk[0], chunk[1], chunk[2], chunk[3]];
182
183                // Count bits in each word using NEON
184                // vcntq_u8 counts bits per byte, then sum horizontally
185                unsafe {
186                    let bytes = std::mem::transmute::<[u64; 4], [u8; 32]>(words);
187
188                    // Process first 16 bytes (words 0-1)
189                    let v0 = vld1q_u8(bytes.as_ptr());
190                    let cnt0 = vcntq_u8(v0);
191                    let sum0 = vaddlvq_u8(cnt0) as u32;
192
193                    // Process next 16 bytes (words 2-3)
194                    let v1 = vld1q_u8(bytes.as_ptr().add(16));
195                    let cnt1 = vcntq_u8(v1);
196                    let sum1 = vaddlvq_u8(cnt1) as u32;
197
198                    let total_popcount = sum0 + sum1;
199
200                    if total_popcount >= remaining {
201                        // Found the chunk, now find exact word
202                        for &word in chunk {
203                            let popcount = word.count_ones();
204                            if popcount >= remaining {
205                                let mut w = word;
206                                for _ in 0..remaining - 1 {
207                                    w &= w - 1;
208                                }
209                                return Some(pos + w.trailing_zeros());
210                            }
211                            remaining -= popcount;
212                            pos += 64;
213                        }
214                    }
215                    remaining -= total_popcount;
216                    pos += 256; // 4 words * 64 bits
217                }
218            }
219
220            // Handle remaining words
221            for &word in remainder {
222                let popcount = word.count_ones();
223                if popcount >= remaining {
224                    let mut w = word;
225                    for _ in 0..remaining - 1 {
226                        w &= w - 1;
227                    }
228                    return Some(pos + w.trailing_zeros());
229                }
230                remaining -= popcount;
231                pos += 64;
232            }
233        }
234
235        // Process 4 words at a time on x86_64 using POPCNT instruction
236        #[cfg(target_arch = "x86_64")]
237        {
238            #[cfg(target_feature = "popcnt")]
239            {
240                use std::arch::x86_64::*;
241
242                let chunks = self.upper_bits.chunks_exact(4);
243                let remainder = chunks.remainder();
244
245                for chunk in chunks {
246                    // Use hardware POPCNT for each word
247                    unsafe {
248                        let p0 = _popcnt64(chunk[0] as i64) as u32;
249                        let p1 = _popcnt64(chunk[1] as i64) as u32;
250                        let p2 = _popcnt64(chunk[2] as i64) as u32;
251                        let p3 = _popcnt64(chunk[3] as i64) as u32;
252
253                        let total_popcount = p0 + p1 + p2 + p3;
254
255                        if total_popcount >= remaining {
256                            // Found the chunk, now find exact word
257                            for &word in chunk {
258                                let popcount = word.count_ones();
259                                if popcount >= remaining {
260                                    let mut w = word;
261                                    for _ in 0..remaining - 1 {
262                                        w &= w - 1;
263                                    }
264                                    return Some(pos + w.trailing_zeros());
265                                }
266                                remaining -= popcount;
267                                pos += 64;
268                            }
269                        }
270                        remaining -= total_popcount;
271                        pos += 256; // 4 words * 64 bits
272                    }
273                }
274
275                // Handle remaining words
276                for &word in remainder {
277                    let popcount = word.count_ones();
278                    if popcount >= remaining {
279                        let mut w = word;
280                        for _ in 0..remaining - 1 {
281                            w &= w - 1;
282                        }
283                        return Some(pos + w.trailing_zeros());
284                    }
285                    remaining -= popcount;
286                    pos += 64;
287                }
288            }
289
290            // Scalar fallback when POPCNT not available
291            #[cfg(not(target_feature = "popcnt"))]
292            {
293                for &word in &self.upper_bits {
294                    let popcount = word.count_ones();
295                    if popcount >= remaining {
296                        let mut w = word;
297                        for _ in 0..remaining - 1 {
298                            w &= w - 1;
299                        }
300                        return Some(pos + w.trailing_zeros());
301                    }
302                    remaining -= popcount;
303                    pos += 64;
304                }
305            }
306        }
307
308        // Scalar fallback for other architectures
309        #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))]
310        {
311            for &word in &self.upper_bits {
312                let popcount = word.count_ones();
313                if popcount >= remaining {
314                    let mut w = word;
315                    for _ in 0..remaining - 1 {
316                        w &= w - 1;
317                    }
318                    return Some(pos + w.trailing_zeros());
319                }
320                remaining -= popcount;
321                pos += 64;
322            }
323        }
324
325        None
326    }
327
328    /// Find first element >= target within this partition
329    /// Returns (local_position, value) or None
330    pub fn next_geq(&self, target: u32) -> Option<(u32, u32)> {
331        if self.len == 0 || target > self.last_value {
332            return None;
333        }
334
335        if target <= self.first_value {
336            return Some((0, self.first_value));
337        }
338
339        // Binary search for target
340        let mut lo = 0u32;
341        let mut hi = self.len;
342
343        while lo < hi {
344            let mid = lo + (hi - lo) / 2;
345            if let Some(val) = self.get(mid) {
346                if val < target {
347                    lo = mid + 1;
348                } else {
349                    hi = mid;
350                }
351            } else {
352                break;
353            }
354        }
355
356        if lo < self.len {
357            self.get(lo).map(|v| (lo, v))
358        } else {
359            None
360        }
361    }
362
363    /// Serialize partition
364    pub fn serialize<W: Write>(&self, writer: &mut W) -> io::Result<()> {
365        writer.write_u32::<LittleEndian>(self.len)?;
366        writer.write_u32::<LittleEndian>(self.first_value)?;
367        writer.write_u32::<LittleEndian>(self.last_value)?;
368        // local_universe removed - computed as last_value - first_value + 1
369        writer.write_u8(self.lower_bit_width)?;
370
371        writer.write_u32::<LittleEndian>(self.lower_bits.len() as u32)?;
372        for &word in &self.lower_bits {
373            writer.write_u64::<LittleEndian>(word)?;
374        }
375
376        writer.write_u32::<LittleEndian>(self.upper_bits.len() as u32)?;
377        for &word in &self.upper_bits {
378            writer.write_u64::<LittleEndian>(word)?;
379        }
380
381        Ok(())
382    }
383
384    /// Deserialize partition
385    pub fn deserialize<R: Read>(reader: &mut R) -> io::Result<Self> {
386        let len = reader.read_u32::<LittleEndian>()?;
387        let first_value = reader.read_u32::<LittleEndian>()?;
388        let last_value = reader.read_u32::<LittleEndian>()?;
389        // local_universe removed - computed as last_value - first_value + 1
390        let lower_bit_width = reader.read_u8()?;
391
392        let lower_len = reader.read_u32::<LittleEndian>()? as usize;
393        let mut lower_bits = Vec::with_capacity(lower_len);
394        for _ in 0..lower_len {
395            lower_bits.push(reader.read_u64::<LittleEndian>()?);
396        }
397
398        let upper_len = reader.read_u32::<LittleEndian>()? as usize;
399        let mut upper_bits = Vec::with_capacity(upper_len);
400        for _ in 0..upper_len {
401            upper_bits.push(reader.read_u64::<LittleEndian>()?);
402        }
403
404        Ok(Self {
405            lower_bits,
406            upper_bits,
407            len,
408            first_value,
409            last_value,
410            lower_bit_width,
411        })
412    }
413
414    /// Size in bytes (13 bytes header + arrays)
415    pub fn size_bytes(&self) -> usize {
416        13 + self.lower_bits.len() * 8 + self.upper_bits.len() * 8
417    }
418}
419
420/// Estimate encoding cost for a partition
421fn estimate_partition_cost(values: &[u32]) -> usize {
422    if values.is_empty() {
423        return 0;
424    }
425
426    let n = values.len();
427    let first = values[0];
428    let last = *values.last().unwrap();
429    let local_universe = (last - first + 1) as usize;
430
431    // Lower bits: n * log2(universe/n)
432    let lower_bits = if n <= 1 || local_universe <= n {
433        0
434    } else {
435        let ratio = local_universe / n;
436        let l = (usize::BITS - ratio.leading_zeros()) as usize;
437        n * l
438    };
439
440    // Upper bits: approximately 2n bits
441    let upper_bits = 2 * n;
442
443    // Overhead: partition header (13 bytes after removing local_universe)
444    let overhead = 13 * 8; // 13 bytes header
445
446    (lower_bits + upper_bits + overhead).div_ceil(8)
447}
448
449/// Find optimal partition boundaries using dynamic programming
450/// Returns partition endpoints (exclusive)
451fn find_optimal_partitions(values: &[u32]) -> Vec<usize> {
452    let n = values.len();
453    if n <= MIN_PARTITION_SIZE {
454        return vec![n];
455    }
456
457    // dp[i] = minimum cost to encode values[0..i]
458    // parent[i] = start of last partition ending at i
459    let mut dp = vec![usize::MAX; n + 1];
460    let mut parent = vec![0usize; n + 1];
461    dp[0] = 0;
462
463    for i in MIN_PARTITION_SIZE..=n {
464        // Try all valid partition sizes ending at i
465        let min_start = i.saturating_sub(MAX_PARTITION_SIZE);
466        let max_start = i.saturating_sub(MIN_PARTITION_SIZE);
467
468        for start in min_start..=max_start {
469            if dp[start] == usize::MAX {
470                continue;
471            }
472
473            let partition_cost = estimate_partition_cost(&values[start..i]);
474            let total_cost = dp[start].saturating_add(partition_cost);
475
476            if total_cost < dp[i] {
477                dp[i] = total_cost;
478                parent[i] = start;
479            }
480        }
481    }
482
483    // Handle case where n is not reachable (too small for partitioning)
484    if dp[n] == usize::MAX {
485        return vec![n];
486    }
487
488    // Reconstruct partition boundaries
489    let mut boundaries = Vec::new();
490    let mut pos = n;
491    while pos > 0 {
492        boundaries.push(pos);
493        pos = parent[pos];
494    }
495    boundaries.reverse();
496
497    boundaries
498}
499
500/// Partitioned Elias-Fano encoded sequence
501#[derive(Debug, Clone)]
502pub struct PartitionedEliasFano {
503    /// Individual partitions
504    partitions: Vec<EFPartition>,
505    /// Total number of elements
506    len: u32,
507    /// Cumulative element counts for each partition (for position lookup)
508    cumulative_counts: Vec<u32>,
509}
510
511impl PartitionedEliasFano {
512    /// Create from sorted slice with optimal partitioning
513    pub fn from_sorted_slice(values: &[u32]) -> Self {
514        if values.is_empty() {
515            return Self {
516                partitions: Vec::new(),
517                len: 0,
518                cumulative_counts: Vec::new(),
519            };
520        }
521
522        let boundaries = find_optimal_partitions(values);
523        let mut partitions = Vec::with_capacity(boundaries.len());
524        let mut cumulative_counts = Vec::with_capacity(boundaries.len());
525
526        let mut start = 0;
527        let mut cumulative = 0u32;
528
529        for &end in &boundaries {
530            let partition = EFPartition::from_sorted_slice(&values[start..end]);
531            cumulative += partition.len;
532            cumulative_counts.push(cumulative);
533            partitions.push(partition);
534            start = end;
535        }
536
537        Self {
538            partitions,
539            len: values.len() as u32,
540            cumulative_counts,
541        }
542    }
543
544    /// Number of elements
545    #[inline]
546    pub fn len(&self) -> u32 {
547        self.len
548    }
549
550    /// Check if empty
551    #[inline]
552    pub fn is_empty(&self) -> bool {
553        self.len == 0
554    }
555
556    /// Number of partitions
557    pub fn num_partitions(&self) -> usize {
558        self.partitions.len()
559    }
560
561    /// Get element at global position
562    pub fn get(&self, pos: u32) -> Option<u32> {
563        if pos >= self.len {
564            return None;
565        }
566
567        // Find partition containing this position
568        let partition_idx = self
569            .cumulative_counts
570            .binary_search(&(pos + 1))
571            .unwrap_or_else(|x| x);
572
573        if partition_idx >= self.partitions.len() {
574            return None;
575        }
576
577        let local_pos = if partition_idx == 0 {
578            pos
579        } else {
580            pos - self.cumulative_counts[partition_idx - 1]
581        };
582
583        self.partitions[partition_idx].get(local_pos)
584    }
585
586    /// Find first element >= target
587    /// Returns (global_position, value) or None
588    pub fn next_geq(&self, target: u32) -> Option<(u32, u32)> {
589        if self.partitions.is_empty() {
590            return None;
591        }
592
593        // Binary search for partition containing target
594        let partition_idx = self
595            .partitions
596            .binary_search_by(|p| {
597                if p.last_value < target {
598                    std::cmp::Ordering::Less
599                } else if p.first_value > target {
600                    std::cmp::Ordering::Greater
601                } else {
602                    std::cmp::Ordering::Equal
603                }
604            })
605            .unwrap_or_else(|x| x);
606
607        // Search from this partition onwards
608        for (i, partition) in self.partitions[partition_idx..].iter().enumerate() {
609            let actual_idx = partition_idx + i;
610
611            if let Some((local_pos, val)) = partition.next_geq(target) {
612                let global_pos = if actual_idx == 0 {
613                    local_pos
614                } else {
615                    self.cumulative_counts[actual_idx - 1] + local_pos
616                };
617                return Some((global_pos, val));
618            }
619        }
620
621        None
622    }
623
624    /// Serialize
625    pub fn serialize<W: Write>(&self, writer: &mut W) -> io::Result<()> {
626        writer.write_u32::<LittleEndian>(self.len)?;
627        writer.write_u32::<LittleEndian>(self.partitions.len() as u32)?;
628
629        for &count in &self.cumulative_counts {
630            writer.write_u32::<LittleEndian>(count)?;
631        }
632
633        for partition in &self.partitions {
634            partition.serialize(writer)?;
635        }
636
637        Ok(())
638    }
639
640    /// Deserialize
641    pub fn deserialize<R: Read>(reader: &mut R) -> io::Result<Self> {
642        let len = reader.read_u32::<LittleEndian>()?;
643        let num_partitions = reader.read_u32::<LittleEndian>()? as usize;
644
645        let mut cumulative_counts = Vec::with_capacity(num_partitions);
646        for _ in 0..num_partitions {
647            cumulative_counts.push(reader.read_u32::<LittleEndian>()?);
648        }
649
650        let mut partitions = Vec::with_capacity(num_partitions);
651        for _ in 0..num_partitions {
652            partitions.push(EFPartition::deserialize(reader)?);
653        }
654
655        Ok(Self {
656            partitions,
657            len,
658            cumulative_counts,
659        })
660    }
661
662    /// Total size in bytes
663    pub fn size_bytes(&self) -> usize {
664        let mut size = 8 + self.cumulative_counts.len() * 4;
665        for p in &self.partitions {
666            size += p.size_bytes();
667        }
668        size
669    }
670
671    /// Create iterator
672    pub fn iter(&self) -> PartitionedEFIterator<'_> {
673        PartitionedEFIterator {
674            pef: self,
675            partition_idx: 0,
676            local_pos: 0,
677        }
678    }
679}
680
681/// Iterator over Partitioned Elias-Fano
682pub struct PartitionedEFIterator<'a> {
683    pef: &'a PartitionedEliasFano,
684    partition_idx: usize,
685    local_pos: u32,
686}
687
688impl<'a> Iterator for PartitionedEFIterator<'a> {
689    type Item = u32;
690
691    fn next(&mut self) -> Option<Self::Item> {
692        if self.partition_idx >= self.pef.partitions.len() {
693            return None;
694        }
695
696        let partition = &self.pef.partitions[self.partition_idx];
697        if let Some(val) = partition.get(self.local_pos) {
698            self.local_pos += 1;
699            if self.local_pos >= partition.len {
700                self.partition_idx += 1;
701                self.local_pos = 0;
702            }
703            Some(val)
704        } else {
705            None
706        }
707    }
708
709    fn size_hint(&self) -> (usize, Option<usize>) {
710        let current_global = if self.partition_idx == 0 {
711            self.local_pos
712        } else if self.partition_idx < self.pef.cumulative_counts.len() {
713            self.pef.cumulative_counts[self.partition_idx - 1] + self.local_pos
714        } else {
715            self.pef.len
716        };
717        let remaining = (self.pef.len - current_global) as usize;
718        (remaining, Some(remaining))
719    }
720}
721
722/// Block metadata for block-max pruning
723#[derive(Debug, Clone)]
724pub struct PEFBlockInfo {
725    /// First document ID in block
726    pub first_doc_id: u32,
727    /// Last document ID in block
728    pub last_doc_id: u32,
729    /// Maximum term frequency in block
730    pub max_tf: u32,
731    /// Maximum BM25 score upper bound
732    pub max_block_score: f32,
733    /// Starting partition index
734    pub partition_idx: u16,
735    /// Starting position within partition
736    pub local_start: u32,
737    /// Number of documents in block
738    pub num_docs: u16,
739}
740
741impl PEFBlockInfo {
742    pub fn serialize<W: Write>(&self, writer: &mut W) -> io::Result<()> {
743        writer.write_u32::<LittleEndian>(self.first_doc_id)?;
744        writer.write_u32::<LittleEndian>(self.last_doc_id)?;
745        writer.write_u32::<LittleEndian>(self.max_tf)?;
746        writer.write_f32::<LittleEndian>(self.max_block_score)?;
747        writer.write_u16::<LittleEndian>(self.partition_idx)?;
748        writer.write_u32::<LittleEndian>(self.local_start)?;
749        writer.write_u16::<LittleEndian>(self.num_docs)?;
750        Ok(())
751    }
752
753    pub fn deserialize<R: Read>(reader: &mut R) -> io::Result<Self> {
754        Ok(Self {
755            first_doc_id: reader.read_u32::<LittleEndian>()?,
756            last_doc_id: reader.read_u32::<LittleEndian>()?,
757            max_tf: reader.read_u32::<LittleEndian>()?,
758            max_block_score: reader.read_f32::<LittleEndian>()?,
759            partition_idx: reader.read_u16::<LittleEndian>()?,
760            local_start: reader.read_u32::<LittleEndian>()?,
761            num_docs: reader.read_u16::<LittleEndian>()?,
762        })
763    }
764}
765
766/// Block size for BlockMax (matches other formats)
767pub const PEF_BLOCK_SIZE: usize = 128;
768
769/// Partitioned Elias-Fano posting list with term frequencies and BlockMax
770#[derive(Debug, Clone)]
771pub struct PartitionedEFPostingList {
772    /// Document IDs (Partitioned Elias-Fano encoded)
773    pub doc_ids: PartitionedEliasFano,
774    /// Term frequencies (packed)
775    pub term_freqs: Vec<u8>,
776    /// Bits per term frequency
777    pub tf_bits: u8,
778    /// Maximum term frequency
779    pub max_tf: u32,
780    /// Block metadata for block-max pruning
781    pub blocks: Vec<PEFBlockInfo>,
782    /// Global maximum score
783    pub max_score: f32,
784}
785
786impl PartitionedEFPostingList {
787    /// Create from postings
788    pub fn from_postings(doc_ids: &[u32], term_freqs: &[u32]) -> Self {
789        Self::from_postings_with_idf(doc_ids, term_freqs, 1.0)
790    }
791
792    /// Create from postings with IDF for block-max scores
793    pub fn from_postings_with_idf(doc_ids: &[u32], term_freqs: &[u32], idf: f32) -> Self {
794        assert_eq!(doc_ids.len(), term_freqs.len());
795
796        if doc_ids.is_empty() {
797            return Self {
798                doc_ids: PartitionedEliasFano::from_sorted_slice(&[]),
799                term_freqs: Vec::new(),
800                tf_bits: 0,
801                max_tf: 0,
802                blocks: Vec::new(),
803                max_score: 0.0,
804            };
805        }
806
807        let pef_doc_ids = PartitionedEliasFano::from_sorted_slice(doc_ids);
808
809        // Find max TF
810        let max_tf = *term_freqs.iter().max().unwrap();
811        let tf_bits = if max_tf == 0 {
812            0
813        } else {
814            (32 - max_tf.leading_zeros()) as u8
815        };
816
817        // Pack term frequencies
818        let total_bits = doc_ids.len() * (tf_bits as usize);
819        let total_bytes = total_bits.div_ceil(8);
820        let mut packed_tfs = vec![0u8; total_bytes];
821
822        if tf_bits > 0 {
823            for (i, &tf) in term_freqs.iter().enumerate() {
824                let bit_pos = i * (tf_bits as usize);
825                let byte_idx = bit_pos / 8;
826                let bit_offset = bit_pos % 8;
827
828                let val = tf.saturating_sub(1);
829
830                packed_tfs[byte_idx] |= (val as u8) << bit_offset;
831                if bit_offset + (tf_bits as usize) > 8 && byte_idx + 1 < packed_tfs.len() {
832                    packed_tfs[byte_idx + 1] |= (val >> (8 - bit_offset)) as u8;
833                }
834                if bit_offset + (tf_bits as usize) > 16 && byte_idx + 2 < packed_tfs.len() {
835                    packed_tfs[byte_idx + 2] |= (val >> (16 - bit_offset)) as u8;
836                }
837            }
838        }
839
840        // Build block metadata
841        let mut blocks = Vec::new();
842        let mut max_score = 0.0f32;
843        let mut i = 0;
844
845        while i < doc_ids.len() {
846            let block_end = (i + PEF_BLOCK_SIZE).min(doc_ids.len());
847            let block_doc_ids = &doc_ids[i..block_end];
848            let block_tfs = &term_freqs[i..block_end];
849
850            let block_max_tf = *block_tfs.iter().max().unwrap_or(&1);
851            let block_score = crate::query::bm25_upper_bound(block_max_tf as f32, idf);
852            max_score = max_score.max(block_score);
853
854            // Find partition info for this block start
855            let (partition_idx, local_start) =
856                Self::find_partition_position(&pef_doc_ids, i as u32);
857
858            blocks.push(PEFBlockInfo {
859                first_doc_id: block_doc_ids[0],
860                last_doc_id: *block_doc_ids.last().unwrap(),
861                max_tf: block_max_tf,
862                max_block_score: block_score,
863                partition_idx: partition_idx as u16,
864                local_start,
865                num_docs: (block_end - i) as u16,
866            });
867
868            i = block_end;
869        }
870
871        Self {
872            doc_ids: pef_doc_ids,
873            term_freqs: packed_tfs,
874            tf_bits,
875            max_tf,
876            blocks,
877            max_score,
878        }
879    }
880
881    fn find_partition_position(pef: &PartitionedEliasFano, global_pos: u32) -> (usize, u32) {
882        let partition_idx = pef
883            .cumulative_counts
884            .binary_search(&(global_pos + 1))
885            .unwrap_or_else(|x| x);
886
887        let local_pos = if partition_idx == 0 {
888            global_pos
889        } else {
890            global_pos - pef.cumulative_counts[partition_idx - 1]
891        };
892
893        (partition_idx, local_pos)
894    }
895
896    /// Get term frequency at position
897    pub fn get_tf(&self, pos: u32) -> u32 {
898        if self.tf_bits == 0 || pos >= self.doc_ids.len() {
899            return 1;
900        }
901
902        let bit_pos = (pos as usize) * (self.tf_bits as usize);
903        let byte_idx = bit_pos / 8;
904        let bit_offset = bit_pos % 8;
905        let mask = (1u32 << self.tf_bits) - 1;
906
907        let mut val = (self.term_freqs[byte_idx] >> bit_offset) as u32;
908        if bit_offset + (self.tf_bits as usize) > 8 && byte_idx + 1 < self.term_freqs.len() {
909            val |= (self.term_freqs[byte_idx + 1] as u32) << (8 - bit_offset);
910        }
911        if bit_offset + (self.tf_bits as usize) > 16 && byte_idx + 2 < self.term_freqs.len() {
912            val |= (self.term_freqs[byte_idx + 2] as u32) << (16 - bit_offset);
913        }
914
915        (val & mask) + 1
916    }
917
918    /// Number of documents
919    pub fn len(&self) -> u32 {
920        self.doc_ids.len()
921    }
922
923    /// Check if empty
924    pub fn is_empty(&self) -> bool {
925        self.doc_ids.is_empty()
926    }
927
928    /// Serialize
929    pub fn serialize<W: Write>(&self, writer: &mut W) -> io::Result<()> {
930        self.doc_ids.serialize(writer)?;
931        writer.write_u8(self.tf_bits)?;
932        writer.write_u32::<LittleEndian>(self.max_tf)?;
933        writer.write_f32::<LittleEndian>(self.max_score)?;
934        writer.write_u32::<LittleEndian>(self.term_freqs.len() as u32)?;
935        writer.write_all(&self.term_freqs)?;
936
937        writer.write_u32::<LittleEndian>(self.blocks.len() as u32)?;
938        for block in &self.blocks {
939            block.serialize(writer)?;
940        }
941
942        Ok(())
943    }
944
945    /// Deserialize
946    pub fn deserialize<R: Read>(reader: &mut R) -> io::Result<Self> {
947        let doc_ids = PartitionedEliasFano::deserialize(reader)?;
948        let tf_bits = reader.read_u8()?;
949        let max_tf = reader.read_u32::<LittleEndian>()?;
950        let max_score = reader.read_f32::<LittleEndian>()?;
951        let tf_len = reader.read_u32::<LittleEndian>()? as usize;
952        let mut term_freqs = vec![0u8; tf_len];
953        reader.read_exact(&mut term_freqs)?;
954
955        let num_blocks = reader.read_u32::<LittleEndian>()? as usize;
956        let mut blocks = Vec::with_capacity(num_blocks);
957        for _ in 0..num_blocks {
958            blocks.push(PEFBlockInfo::deserialize(reader)?);
959        }
960
961        Ok(Self {
962            doc_ids,
963            term_freqs,
964            tf_bits,
965            max_tf,
966            blocks,
967            max_score,
968        })
969    }
970
971    /// Create iterator
972    pub fn iterator(&self) -> PartitionedEFPostingIterator<'_> {
973        PartitionedEFPostingIterator {
974            list: self,
975            pos: 0,
976            current_block: 0,
977        }
978    }
979
980    /// Get compression ratio compared to standard EF
981    pub fn compression_info(&self) -> (usize, usize) {
982        let pef_size = self.doc_ids.size_bytes();
983        // Estimate standard EF size
984        let n = self.len() as usize;
985        let max_val = if let Some(last_block) = self.blocks.last() {
986            last_block.last_doc_id
987        } else {
988            0
989        };
990        let ef_size = if n > 0 {
991            let l = if n <= 1 {
992                0
993            } else {
994                let ratio = (max_val as usize + 1) / n;
995                if ratio <= 1 {
996                    0
997                } else {
998                    (usize::BITS - ratio.leading_zeros()) as usize
999                }
1000            };
1001            (n * l + 2 * n).div_ceil(8) + 16
1002        } else {
1003            0
1004        };
1005        (pef_size, ef_size)
1006    }
1007}
1008
1009/// Iterator over Partitioned EF posting list
1010pub struct PartitionedEFPostingIterator<'a> {
1011    list: &'a PartitionedEFPostingList,
1012    pos: u32,
1013    current_block: usize,
1014}
1015
1016impl<'a> PartitionedEFPostingIterator<'a> {
1017    /// Current document ID
1018    pub fn doc(&self) -> u32 {
1019        self.list.doc_ids.get(self.pos).unwrap_or(u32::MAX)
1020    }
1021
1022    /// Current term frequency
1023    pub fn term_freq(&self) -> u32 {
1024        self.list.get_tf(self.pos)
1025    }
1026
1027    /// Advance to next document
1028    pub fn advance(&mut self) -> u32 {
1029        self.pos += 1;
1030        if !self.list.blocks.is_empty() {
1031            let new_block = (self.pos as usize) / PEF_BLOCK_SIZE;
1032            if new_block < self.list.blocks.len() {
1033                self.current_block = new_block;
1034            }
1035        }
1036        self.doc()
1037    }
1038
1039    /// Seek to first doc >= target
1040    pub fn seek(&mut self, target: u32) -> u32 {
1041        // Use block skip list
1042        if !self.list.blocks.is_empty() {
1043            let block_idx = self.list.blocks[self.current_block..].binary_search_by(|b| {
1044                if b.last_doc_id < target {
1045                    std::cmp::Ordering::Less
1046                } else if b.first_doc_id > target {
1047                    std::cmp::Ordering::Greater
1048                } else {
1049                    std::cmp::Ordering::Equal
1050                }
1051            });
1052
1053            let target_block = match block_idx {
1054                Ok(idx) => self.current_block + idx,
1055                Err(idx) => {
1056                    let abs_idx = self.current_block + idx;
1057                    if abs_idx >= self.list.blocks.len() {
1058                        self.pos = self.list.len();
1059                        return u32::MAX;
1060                    }
1061                    abs_idx
1062                }
1063            };
1064
1065            if target_block > self.current_block {
1066                self.current_block = target_block;
1067                self.pos = (target_block * PEF_BLOCK_SIZE) as u32;
1068            }
1069        }
1070
1071        // Use PEF's next_geq
1072        if let Some((pos, val)) = self.list.doc_ids.next_geq(target)
1073            && pos >= self.pos
1074        {
1075            self.pos = pos;
1076            if !self.list.blocks.is_empty() {
1077                self.current_block = (pos as usize) / PEF_BLOCK_SIZE;
1078            }
1079            return val;
1080        }
1081
1082        // Linear scan fallback
1083        while self.pos < self.list.len() {
1084            let doc = self.doc();
1085            if doc >= target {
1086                return doc;
1087            }
1088            self.pos += 1;
1089        }
1090
1091        u32::MAX
1092    }
1093
1094    /// Check if exhausted
1095    pub fn is_exhausted(&self) -> bool {
1096        self.pos >= self.list.len()
1097    }
1098
1099    /// Current block's max score
1100    pub fn current_block_max_score(&self) -> f32 {
1101        if self.is_exhausted() || self.list.blocks.is_empty() {
1102            return 0.0;
1103        }
1104        if self.current_block < self.list.blocks.len() {
1105            self.list.blocks[self.current_block].max_block_score
1106        } else {
1107            0.0
1108        }
1109    }
1110
1111    /// Current block's max TF
1112    pub fn current_block_max_tf(&self) -> u32 {
1113        if self.is_exhausted() || self.list.blocks.is_empty() {
1114            return 0;
1115        }
1116        if self.current_block < self.list.blocks.len() {
1117            self.list.blocks[self.current_block].max_tf
1118        } else {
1119            0
1120        }
1121    }
1122
1123    /// Max score for remaining blocks
1124    pub fn max_remaining_score(&self) -> f32 {
1125        if self.is_exhausted() || self.list.blocks.is_empty() {
1126            return 0.0;
1127        }
1128        self.list.blocks[self.current_block..]
1129            .iter()
1130            .map(|b| b.max_block_score)
1131            .fold(0.0f32, |a, b| a.max(b))
1132    }
1133
1134    /// Skip to next block containing doc >= target (for block-max pruning)
1135    /// Returns (first_doc_in_block, block_max_score) or None if exhausted
1136    pub fn skip_to_block_with_doc(&mut self, target: u32) -> Option<(u32, f32)> {
1137        if self.list.blocks.is_empty() {
1138            return None;
1139        }
1140
1141        while self.current_block < self.list.blocks.len() {
1142            let block = &self.list.blocks[self.current_block];
1143            if block.last_doc_id >= target {
1144                self.pos = (self.current_block * PEF_BLOCK_SIZE) as u32;
1145                return Some((block.first_doc_id, block.max_block_score));
1146            }
1147            self.current_block += 1;
1148        }
1149
1150        self.pos = self.list.len();
1151        None
1152    }
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157    use super::*;
1158
1159    #[test]
1160    fn test_ef_partition_basic() {
1161        let values = vec![10, 20, 30, 40, 50];
1162        let partition = EFPartition::from_sorted_slice(&values);
1163
1164        assert_eq!(partition.len, 5);
1165        assert_eq!(partition.first_value, 10);
1166        assert_eq!(partition.last_value, 50);
1167
1168        for (i, &expected) in values.iter().enumerate() {
1169            assert_eq!(partition.get(i as u32), Some(expected));
1170        }
1171    }
1172
1173    #[test]
1174    fn test_ef_partition_next_geq() {
1175        let values = vec![10, 20, 30, 100, 200, 300];
1176        let partition = EFPartition::from_sorted_slice(&values);
1177
1178        assert_eq!(partition.next_geq(5), Some((0, 10)));
1179        assert_eq!(partition.next_geq(10), Some((0, 10)));
1180        assert_eq!(partition.next_geq(15), Some((1, 20)));
1181        assert_eq!(partition.next_geq(100), Some((3, 100)));
1182        assert_eq!(partition.next_geq(301), None);
1183    }
1184
1185    #[test]
1186    fn test_partitioned_ef_basic() {
1187        let values: Vec<u32> = (0..500).map(|i| i * 2).collect();
1188        let pef = PartitionedEliasFano::from_sorted_slice(&values);
1189
1190        assert_eq!(pef.len(), 500);
1191        assert!(pef.num_partitions() >= 1);
1192
1193        for (i, &expected) in values.iter().enumerate() {
1194            assert_eq!(pef.get(i as u32), Some(expected), "Mismatch at {}", i);
1195        }
1196    }
1197
1198    #[test]
1199    fn test_partitioned_ef_next_geq() {
1200        let values: Vec<u32> = (0..1000).map(|i| i * 3).collect();
1201        let pef = PartitionedEliasFano::from_sorted_slice(&values);
1202
1203        assert_eq!(pef.next_geq(0), Some((0, 0)));
1204        assert_eq!(pef.next_geq(100), Some((34, 102))); // 100/3 = 33.33, next is 34*3=102
1205        assert_eq!(pef.next_geq(1500), Some((500, 1500)));
1206        assert_eq!(pef.next_geq(3000), None);
1207    }
1208
1209    #[test]
1210    fn test_partitioned_ef_serialization() {
1211        let values: Vec<u32> = (0..500).map(|i| i * 5).collect();
1212        let pef = PartitionedEliasFano::from_sorted_slice(&values);
1213
1214        let mut buffer = Vec::new();
1215        pef.serialize(&mut buffer).unwrap();
1216
1217        let restored = PartitionedEliasFano::deserialize(&mut &buffer[..]).unwrap();
1218
1219        assert_eq!(restored.len(), pef.len());
1220        assert_eq!(restored.num_partitions(), pef.num_partitions());
1221
1222        for i in 0..pef.len() {
1223            assert_eq!(restored.get(i), pef.get(i));
1224        }
1225    }
1226
1227    #[test]
1228    fn test_partitioned_ef_posting_list() {
1229        let doc_ids: Vec<u32> = (0..300).map(|i| i * 2).collect();
1230        let term_freqs: Vec<u32> = (0..300).map(|i| (i % 10) + 1).collect();
1231
1232        let list = PartitionedEFPostingList::from_postings(&doc_ids, &term_freqs);
1233
1234        assert_eq!(list.len(), 300);
1235
1236        let mut iter = list.iterator();
1237        for (i, (&expected_doc, &expected_tf)) in doc_ids.iter().zip(term_freqs.iter()).enumerate()
1238        {
1239            assert_eq!(iter.doc(), expected_doc, "Doc mismatch at {}", i);
1240            assert_eq!(iter.term_freq(), expected_tf, "TF mismatch at {}", i);
1241            iter.advance();
1242        }
1243    }
1244
1245    #[test]
1246    fn test_partitioned_ef_seek() {
1247        let doc_ids: Vec<u32> = (0..500).map(|i| i * 3).collect();
1248        let term_freqs: Vec<u32> = vec![1; 500];
1249
1250        let list = PartitionedEFPostingList::from_postings(&doc_ids, &term_freqs);
1251        let mut iter = list.iterator();
1252
1253        assert_eq!(iter.seek(100), 102); // 100/3 = 33.33, next is 34*3=102
1254        assert_eq!(iter.seek(300), 300);
1255        assert_eq!(iter.seek(1500), u32::MAX);
1256    }
1257
1258    #[test]
1259    fn test_compression_improvement() {
1260        // Test that PEF achieves better compression than standard EF
1261        // on data with varying density
1262        let values: Vec<u32> = (0..10000)
1263            .map(|i| {
1264                if i < 5000 {
1265                    i * 2 // Dense region
1266                } else {
1267                    10000 + (i - 5000) * 100 // Sparse region
1268                }
1269            })
1270            .collect();
1271
1272        let pef = PartitionedEliasFano::from_sorted_slice(&values);
1273
1274        // PEF should use multiple partitions for this mixed-density data
1275        assert!(
1276            pef.num_partitions() > 1,
1277            "Expected multiple partitions, got {}",
1278            pef.num_partitions()
1279        );
1280
1281        // Verify correctness
1282        for (i, &expected) in values.iter().enumerate() {
1283            assert_eq!(pef.get(i as u32), Some(expected));
1284        }
1285    }
1286
1287    #[test]
1288    fn test_partitioned_ef_block_max() {
1289        // Create a large posting list that spans multiple blocks
1290        let doc_ids: Vec<u32> = (0..500).map(|i| i * 2).collect();
1291        // Vary term frequencies so different blocks have different max_tf
1292        let term_freqs: Vec<u32> = (0..500)
1293            .map(|i| {
1294                if i < 128 {
1295                    1 // Block 0: max_tf = 1
1296                } else if i < 256 {
1297                    5 // Block 1: max_tf = 5
1298                } else if i < 384 {
1299                    10 // Block 2: max_tf = 10
1300                } else {
1301                    3 // Block 3: max_tf = 3
1302                }
1303            })
1304            .collect();
1305
1306        let list = PartitionedEFPostingList::from_postings_with_idf(&doc_ids, &term_freqs, 2.0);
1307
1308        // Should have 4 blocks (500 docs / 128 per block)
1309        assert_eq!(list.blocks.len(), 4);
1310        assert_eq!(list.blocks[0].max_tf, 1);
1311        assert_eq!(list.blocks[1].max_tf, 5);
1312        assert_eq!(list.blocks[2].max_tf, 10);
1313        assert_eq!(list.blocks[3].max_tf, 3);
1314
1315        // Block 2 should have highest score (max_tf = 10)
1316        assert!(list.blocks[2].max_block_score > list.blocks[0].max_block_score);
1317        assert!(list.blocks[2].max_block_score > list.blocks[1].max_block_score);
1318        assert!(list.blocks[2].max_block_score > list.blocks[3].max_block_score);
1319
1320        // Global max_score should equal block 2's score
1321        assert_eq!(list.max_score, list.blocks[2].max_block_score);
1322
1323        // Test iterator block-max methods
1324        let mut iter = list.iterator();
1325        assert_eq!(iter.current_block_max_tf(), 1); // Block 0
1326
1327        // Seek to block 1
1328        iter.seek(256); // first doc in block 1
1329        assert_eq!(iter.current_block_max_tf(), 5);
1330
1331        // Seek to block 2
1332        iter.seek(512); // first doc in block 2
1333        assert_eq!(iter.current_block_max_tf(), 10);
1334
1335        // Test skip_to_block_with_doc
1336        let mut iter2 = list.iterator();
1337        let result = iter2.skip_to_block_with_doc(300);
1338        assert!(result.is_some());
1339        let (first_doc, score) = result.unwrap();
1340        assert!(first_doc <= 300);
1341        assert!(score > 0.0);
1342
1343        // Test max_remaining_score
1344        let mut iter3 = list.iterator();
1345        let max_score = iter3.max_remaining_score();
1346        assert_eq!(max_score, list.max_score);
1347
1348        // After seeking past block 2, max_remaining should be lower
1349        iter3.seek(768); // Block 3
1350        let remaining = iter3.max_remaining_score();
1351        assert!(remaining < max_score);
1352    }
1353}