Skip to main content

hermes_core/structures/postings/sparse/
config.rs

1//! Configuration types for sparse vector posting lists
2
3use serde::{Deserialize, Serialize};
4
5/// Sparse vector index format
6///
7/// Determines the on-disk layout and query execution strategy:
8/// - **MaxScore**: Per-dimension variable-size blocks (DAAT — document-at-a-time).
9///   Default, optimal for general sparse retrieval with block-max pruning.
10/// - **Bmp**: Fixed doc_id range blocks (BAAT — block-at-a-time).
11///   Based on Mallia, Suel & Tonellotto (SIGIR 2024). Divides the document
12///   space into fixed-size blocks and processes them in decreasing upper-bound
13///   order, enabling aggressive early termination.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
15pub enum SparseFormat {
16    /// Per-dimension variable-size blocks (existing format, DAAT MaxScore)
17    #[default]
18    MaxScore,
19    /// Fixed doc_id range blocks (BMP, BAAT block-at-a-time)
20    Bmp,
21}
22
23impl SparseFormat {
24    fn is_default(&self) -> bool {
25        *self == Self::MaxScore
26    }
27}
28
29/// Size of the index (term/dimension ID) in sparse vectors
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
31#[repr(u8)]
32pub enum IndexSize {
33    /// 16-bit index (0-65535), ideal for SPLADE vocabularies
34    U16 = 0,
35    /// 32-bit index (0-4B), for large vocabularies
36    #[default]
37    U32 = 1,
38}
39
40impl IndexSize {
41    /// Bytes per index
42    pub fn bytes(&self) -> usize {
43        match self {
44            IndexSize::U16 => 2,
45            IndexSize::U32 => 4,
46        }
47    }
48
49    /// Maximum value representable
50    pub fn max_value(&self) -> u32 {
51        match self {
52            IndexSize::U16 => u16::MAX as u32,
53            IndexSize::U32 => u32::MAX,
54        }
55    }
56
57    pub(crate) fn from_u8(v: u8) -> Option<Self> {
58        match v {
59            0 => Some(IndexSize::U16),
60            1 => Some(IndexSize::U32),
61            _ => None,
62        }
63    }
64}
65
66/// Quantization format for sparse vector weights
67///
68/// Research-validated compression/effectiveness trade-offs (Pati, 2025):
69/// - **UInt8**: 4x compression, ~1-2% nDCG@10 loss (RECOMMENDED for production)
70/// - **Float16**: 2x compression, <1% nDCG@10 loss
71/// - **Float32**: No compression, baseline effectiveness
72/// - **UInt4**: 8x compression, ~3-5% nDCG@10 loss (experimental)
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
74#[repr(u8)]
75pub enum WeightQuantization {
76    /// Full 32-bit float precision
77    #[default]
78    Float32 = 0,
79    /// 16-bit float (half precision) - 2x compression, <1% effectiveness loss
80    Float16 = 1,
81    /// 8-bit unsigned integer with scale factor - 4x compression, ~1-2% effectiveness loss (RECOMMENDED)
82    UInt8 = 2,
83    /// 4-bit unsigned integer with scale factor (packed, 2 per byte) - 8x compression, ~3-5% effectiveness loss
84    UInt4 = 3,
85}
86
87impl WeightQuantization {
88    /// Bytes per weight (approximate for UInt4)
89    pub fn bytes_per_weight(&self) -> f32 {
90        match self {
91            WeightQuantization::Float32 => 4.0,
92            WeightQuantization::Float16 => 2.0,
93            WeightQuantization::UInt8 => 1.0,
94            WeightQuantization::UInt4 => 0.5,
95        }
96    }
97
98    pub(crate) fn from_u8(v: u8) -> Option<Self> {
99        match v {
100            0 => Some(WeightQuantization::Float32),
101            1 => Some(WeightQuantization::Float16),
102            2 => Some(WeightQuantization::UInt8),
103            3 => Some(WeightQuantization::UInt4),
104            _ => None,
105        }
106    }
107}
108
109/// Query-time weighting strategy for sparse vector queries
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum QueryWeighting {
113    /// All terms get weight 1.0
114    #[default]
115    One,
116    /// Terms weighted by IDF (inverse document frequency) from global index statistics
117    /// Uses ln(N/df) where N = total docs, df = docs containing dimension
118    Idf,
119    /// Terms weighted by pre-computed IDF from model's idf.json file
120    /// Loaded from HuggingFace model repo. No fallback to global stats.
121    IdfFile,
122}
123
124/// Query-time configuration for sparse vectors
125///
126/// Quality-sensitive query optimization knobs. Weight filtering, dimension
127/// caps, fractional pruning, finite LSP gamma, and heap factors below 1.0 can
128/// all change the candidate set. They are disabled by default and should be
129/// tuned against representative Recall@K or relevance judgments.
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131pub struct SparseQueryConfig {
132    /// HuggingFace tokenizer path/name for query-time tokenization
133    /// Example: "Alibaba-NLP/gte-Qwen2-1.5B-instruct"
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub tokenizer: Option<String>,
136    /// Weighting strategy for tokenized query terms
137    #[serde(default)]
138    pub weighting: QueryWeighting,
139    /// Heap factor for approximate search (SEISMIC-style optimization)
140    /// A block is skipped if its max possible score < heap_factor * threshold
141    ///
142    /// - 1.0 = exact search (default)
143    /// - values below 1.0 = increasingly aggressive block pruning
144    #[serde(default = "default_heap_factor")]
145    pub heap_factor: f32,
146    /// Minimum weight for query dimensions (query-time pruning)
147    /// Dimensions with abs(weight) below this threshold are dropped before search.
148    /// Useful for filtering low-IDF tokens that add latency without improving relevance.
149    ///
150    /// - 0.0 = no filtering (default)
151    /// - positive values drop dimensions and require quality validation
152    #[serde(default)]
153    pub weight_threshold: f32,
154    /// Maximum number of query dimensions to process (query pruning)
155    /// Processes only the top-k dimensions by weight
156    ///
157    /// - None = process all dimensions (default, exact)
158    /// - Some(k) = process only the top-k dimensions by absolute weight
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub max_query_dims: Option<usize>,
161    /// Fraction of query dimensions to keep (0.0-1.0), same semantics as
162    /// indexing-time `pruning`: sort by abs(weight) descending and keep the
163    /// top fraction. BMP uses this subset for candidate generation and the
164    /// bounded full query for final scoring; MaxScore uses the subset for both.
165    /// None or 1.0 = no pruning.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub pruning: Option<f32>,
168    /// Minimum number of query dimensions before pruning and weight_threshold
169    /// filtering are applied. Protects short queries from losing most signal.
170    ///
171    /// Default: 4. Set to 0 to always apply pruning/filtering.
172    #[serde(default = "default_min_terms")]
173    pub min_query_dims: usize,
174    /// LSP/0 top-superblock guarantee γ. `None` selects the paper-derived
175    /// schedule from retrieval depth; `Some(0)` requests exhaustive traversal.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub lsp_gamma: Option<usize>,
178}
179
180fn default_heap_factor() -> f32 {
181    1.0
182}
183
184impl Default for SparseQueryConfig {
185    fn default() -> Self {
186        Self {
187            tokenizer: None,
188            weighting: QueryWeighting::One,
189            heap_factor: 1.0,
190            weight_threshold: 0.0,
191            max_query_dims: None,
192            pruning: None,
193            min_query_dims: 4,
194            lsp_gamma: None,
195        }
196    }
197}
198
199/// Configuration for sparse vector storage
200///
201/// Configuration knobs for learned sparse retrieval (SPLADE, uniCOIL, etc.).
202///
203/// Destructive posting-list and query-dimension pruning are opt-in. Their
204/// quality impact is corpus/model dependent and must be established with
205/// Recall@K or relevance judgments; a fixed retained fraction is not a safe
206/// production default.
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208pub struct SparseVectorConfig {
209    /// Index format: MaxScore (DAAT) or BMP (BAAT)
210    #[serde(default, skip_serializing_if = "SparseFormat::is_default")]
211    pub format: SparseFormat,
212    /// Size of dimension/term indices
213    pub index_size: IndexSize,
214    /// Quantization for weights (see WeightQuantization docs for trade-offs)
215    pub weight_quantization: WeightQuantization,
216    /// Minimum weight threshold - weights below this value are not indexed
217    ///
218    /// Positive values reduce posting count but are model/corpus dependent.
219    /// Benchmark retrieval quality before choosing a production threshold.
220    #[serde(default)]
221    pub weight_threshold: f32,
222    /// Document-side mass cropping: keep the top-|weight| entries covering
223    /// this fraction of a sparse vector's total |weight| mass; the excessive
224    /// tail is dropped at indexing time.
225    ///
226    /// SPLADE-style vectors can concentrate importance in a few head terms,
227    /// but the relevance carried by the tail is model/corpus dependent.
228    ///
229    /// - None or >= 1.0 = keep all entries (default)
230    /// - Applied after `weight_threshold`; vectors with <= `min_terms`
231    ///   entries are never cropped.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub doc_mass: Option<f32>,
234    /// Block size for posting lists (must be power of 2, default 128 for SIMD)
235    /// Larger blocks = better compression, smaller blocks = faster seeks.
236    /// Used by MaxScore format only.
237    #[serde(default = "default_block_size")]
238    pub block_size: usize,
239    /// BMP block size: number of consecutive doc_ids per block (must be power
240    /// of 2, max 256). Only used when format = Bmp. Uniform across every
241    /// segment of the field — set per field in SDL (`bmp_block_size: N`).
242    /// Smaller = better pruning granularity; larger means fewer locally
243    /// bit-packed maximum cells. Default 32 favors pruning granularity;
244    /// increase it only after representative tail-latency testing
245    /// (docs/bmp-grid-compression.md).
246    #[serde(default = "default_bmp_block_size")]
247    pub bmp_block_size: u32,
248    /// Bits per BMP block-grid cell: 4 (default) or 2. Two caps compressed D
249    /// payload groups at two bits; the exact space reduction depends on local
250    /// group widths. Measured pruning cost is small (+0.4-2.2% blocks scored;
251    /// the ceil-u4 superblock grid prunes first).
252    /// Grid bounds are ceil-quantized, so exact top-k results are unchanged
253    /// at any width. Uniform per field across all segments — set in SDL
254    /// (`bmp_grid_bits: 2`) at index creation.
255    #[serde(default = "default_bmp_grid_bits")]
256    pub bmp_grid_bits: u8,
257    /// Static pruning: fraction of postings to keep per inverted list (SEISMIC-style)
258    /// Lists are sorted by weight descending and truncated to top fraction.
259    ///
260    /// - None = keep all postings (default)
261    /// - Some(0.1) = keep only the top 10% of each dimension's postings
262    ///
263    /// A fraction is deliberately not enabled by the SPLADE presets. Per-list
264    /// frequency and score distributions vary widely, and keeping one posting
265    /// from a list of 4-10 entries can destroy candidate recall.
266    ///
267    /// Applied only during initial segment build, not during merge.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub pruning: Option<f32>,
270    /// Query-time configuration (tokenizer, weighting)
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub query_config: Option<SparseQueryConfig>,
273    /// Fixed vocabulary size (number of dimensions) for BMP format.
274    ///
275    /// When set, all BMP segments use the same grid dimensions (rows = dims),
276    /// enabling zero-copy block-copy merge. The grid is indexed by dim_id directly
277    /// (no dim_ids Section C needed).
278    ///
279    /// Required for BMP format. Typical values:
280    /// - SPLADE/BERT: 30522 or 105879 (WordPiece / Unigram vocabulary)
281    /// - uniCOIL: 30522
282    /// - Custom models: set to vocabulary size
283    ///
284    /// If None, the BMP builder derives dims from observed data.
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub dims: Option<u32>,
287    /// Fixed max weight scale for BMP format.
288    ///
289    /// When set, all BMP segments use the same quantization scale
290    /// (`max_weight_scale = max_weight`), eliminating rescaling during merge.
291    ///
292    /// For SPLADE models: 5.0 (covers typical weight range 0-5).
293    /// If None, the BMP builder derives scale from data.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub max_weight: Option<f32>,
296    /// Minimum number of postings in a dimension before pruning and
297    /// weight_threshold filtering are applied. Protects dimensions with
298    /// very few postings from losing most of their signal.
299    ///
300    /// Default: 4. Set to 0 to always apply pruning/filtering.
301    #[serde(default = "default_min_terms")]
302    pub min_terms: usize,
303}
304
305fn default_block_size() -> usize {
306    128
307}
308
309fn default_bmp_block_size() -> u32 {
310    SparseVectorConfig::DEFAULT_BMP_BLOCK_SIZE
311}
312
313fn default_bmp_grid_bits() -> u8 {
314    SparseVectorConfig::DEFAULT_BMP_GRID_BITS
315}
316
317fn default_min_terms() -> usize {
318    4
319}
320
321impl Default for SparseVectorConfig {
322    fn default() -> Self {
323        Self {
324            format: SparseFormat::MaxScore,
325            index_size: IndexSize::U32,
326            weight_quantization: WeightQuantization::Float32,
327            weight_threshold: 0.0,
328            doc_mass: None,
329            block_size: 128,
330            bmp_block_size: default_bmp_block_size(),
331            bmp_grid_bits: default_bmp_grid_bits(),
332            pruning: None,
333            query_config: None,
334
335            dims: None,
336            max_weight: None,
337            min_terms: 4,
338        }
339    }
340}
341
342impl SparseVectorConfig {
343    pub const DEFAULT_BMP_BLOCK_SIZE: u32 = 32;
344    pub const DEFAULT_BMP_GRID_BITS: u8 = 4;
345
346    /// Recall-preserving SPLADE storage preset
347    ///
348    /// Optimized for SPLADE, uniCOIL, and similar learned sparse retrieval models.
349    /// UInt8 impacts and a small weight threshold reduce storage. Destructive
350    /// per-list, query-dimension, and heap pruning remain disabled; enable them
351    /// only after a representative quality benchmark.
352    ///
353    /// Vocabulary: ~30K dimensions (fits in u16)
354    pub fn splade() -> Self {
355        Self {
356            format: SparseFormat::MaxScore,
357            index_size: IndexSize::U16,
358            weight_quantization: WeightQuantization::UInt8,
359            weight_threshold: 0.01, // Remove ~30-50% of low-weight postings
360            doc_mass: None,
361            block_size: 128,
362            bmp_block_size: default_bmp_block_size(),
363            bmp_grid_bits: default_bmp_grid_bits(),
364            pruning: None,
365            query_config: Some(SparseQueryConfig {
366                tokenizer: None,
367                weighting: QueryWeighting::One,
368                heap_factor: 1.0,
369                weight_threshold: 0.01,
370                max_query_dims: None,
371                pruning: None,
372                min_query_dims: 4,
373                lsp_gamma: None,
374            }),
375
376            dims: None,
377            max_weight: None,
378            min_terms: 4,
379        }
380    }
381
382    /// SPLADE-optimized config with BMP (Block-Max Pruning) format
383    ///
384    /// Same optimization settings as `splade()` but uses the BMP block-at-a-time
385    /// format (Mallia, Suel & Tonellotto, SIGIR 2024) instead of MaxScore.
386    /// BMP divides the document space into fixed-size blocks and processes them
387    /// in decreasing upper-bound order, enabling aggressive early termination.
388    pub fn splade_bmp() -> Self {
389        Self {
390            format: SparseFormat::Bmp,
391            index_size: IndexSize::U16,
392            weight_quantization: WeightQuantization::UInt8,
393            weight_threshold: 0.01,
394            doc_mass: None,
395            block_size: 128,
396            bmp_block_size: default_bmp_block_size(),
397            bmp_grid_bits: default_bmp_grid_bits(),
398            pruning: None,
399            query_config: Some(SparseQueryConfig {
400                tokenizer: None,
401                weighting: QueryWeighting::One,
402                heap_factor: 1.0,
403                weight_threshold: 0.01,
404                max_query_dims: None,
405                pruning: None,
406                min_query_dims: 4,
407                lsp_gamma: None,
408            }),
409
410            dims: Some(105879),
411            max_weight: Some(5.0),
412            min_terms: 4,
413        }
414    }
415
416    /// Compact config: Maximum compression (experimental)
417    ///
418    /// Uses aggressive UInt4 quantization for smallest possible index size.
419    /// Expected trade-offs:
420    /// - Index size: ~10-15% of Float32 baseline
421    /// - Effectiveness: ~3-5% nDCG@10 loss
422    ///
423    /// Recommended for: Memory-constrained environments, cache-heavy workloads
424    pub fn compact() -> Self {
425        Self {
426            format: SparseFormat::MaxScore,
427            index_size: IndexSize::U16,
428            weight_quantization: WeightQuantization::UInt4,
429            weight_threshold: 0.02, // Slightly higher threshold for UInt4
430            doc_mass: None,
431            block_size: 128,
432            bmp_block_size: default_bmp_block_size(),
433            bmp_grid_bits: default_bmp_grid_bits(),
434            pruning: Some(0.15), // Keep top 15% per dimension
435            query_config: Some(SparseQueryConfig {
436                tokenizer: None,
437                weighting: QueryWeighting::One,
438                heap_factor: 0.7,         // More aggressive approximate search
439                weight_threshold: 0.02,   // Drop low-IDF query tokens
440                max_query_dims: Some(15), // Fewer query dimensions
441                pruning: Some(0.15),      // Keep top 15% of query dims
442                min_query_dims: 4,
443                lsp_gamma: None,
444            }),
445
446            dims: None,
447            max_weight: None,
448            min_terms: 4,
449        }
450    }
451
452    /// Full precision config: No compression, baseline effectiveness
453    ///
454    /// Use for: Research baselines, when effectiveness is critical
455    pub fn full_precision() -> Self {
456        Self {
457            format: SparseFormat::MaxScore,
458            index_size: IndexSize::U32,
459            weight_quantization: WeightQuantization::Float32,
460            weight_threshold: 0.0,
461            doc_mass: None,
462            block_size: 128,
463            bmp_block_size: default_bmp_block_size(),
464            bmp_grid_bits: default_bmp_grid_bits(),
465            pruning: None,
466            query_config: None,
467
468            dims: None,
469            max_weight: None,
470            min_terms: 4,
471        }
472    }
473
474    /// Conservative config: Mild optimizations, minimal effectiveness loss
475    ///
476    /// Balances compression and effectiveness with conservative defaults.
477    /// Expected trade-offs:
478    /// - Index size: ~40-50% of Float32 baseline
479    /// - Query latency: ~20-30% faster
480    /// - Effectiveness: <1% nDCG@10 loss
481    ///
482    /// Recommended for: Production deployments prioritizing effectiveness
483    pub fn conservative() -> Self {
484        Self {
485            format: SparseFormat::MaxScore,
486            index_size: IndexSize::U32,
487            weight_quantization: WeightQuantization::Float16,
488            weight_threshold: 0.005, // Minimal pruning
489            doc_mass: None,
490            block_size: 128,
491            bmp_block_size: default_bmp_block_size(),
492            bmp_grid_bits: default_bmp_grid_bits(),
493            pruning: None, // No posting list pruning
494            query_config: Some(SparseQueryConfig {
495                tokenizer: None,
496                weighting: QueryWeighting::One,
497                heap_factor: 0.9,         // Nearly exact search
498                weight_threshold: 0.005,  // Minimal query pruning
499                max_query_dims: Some(50), // Process more dimensions
500                pruning: None,            // No fraction-based pruning
501                min_query_dims: 4,
502                lsp_gamma: None,
503            }),
504
505            dims: None,
506            max_weight: None,
507            min_terms: 4,
508        }
509    }
510
511    /// Set weight threshold (builder pattern)
512    pub fn with_weight_threshold(mut self, threshold: f32) -> Self {
513        self.weight_threshold = threshold;
514        self
515    }
516
517    /// Set document-side mass cropping fraction (builder pattern)
518    /// e.g., 0.9 = keep top-weight entries covering 90% of each vector's mass
519    pub fn with_doc_mass(mut self, fraction: f32) -> Self {
520        self.doc_mass = Some(fraction.clamp(0.0, 1.0));
521        self
522    }
523
524    /// Set posting list pruning fraction (builder pattern)
525    /// e.g., 0.1 = keep top 10% of postings per dimension
526    pub fn with_pruning(mut self, fraction: f32) -> Self {
527        self.pruning = Some(fraction.clamp(0.0, 1.0));
528        self
529    }
530
531    /// Bytes per entry (index + weight)
532    pub fn bytes_per_entry(&self) -> f32 {
533        self.index_size.bytes() as f32 + self.weight_quantization.bytes_per_weight()
534    }
535
536    /// Serialize config to a single byte.
537    ///
538    /// Layout: bits 7-4 = IndexSize, bit 3 = format (0=MaxScore, 1=BMP), bits 2-0 = WeightQuantization
539    pub fn to_byte(&self) -> u8 {
540        let format_bit = if self.format == SparseFormat::Bmp {
541            0x08
542        } else {
543            0
544        };
545        ((self.index_size as u8) << 4) | format_bit | (self.weight_quantization as u8)
546    }
547
548    /// Deserialize config from a single byte.
549    ///
550    /// Note: weight_threshold, block_size, bmp_block_size, and query_config are not
551    /// serialized in the byte — they come from the schema.
552    pub fn from_byte(b: u8) -> Option<Self> {
553        let index_size = IndexSize::from_u8((b >> 4) & 0x03)?;
554        let format = if b & 0x08 != 0 {
555            SparseFormat::Bmp
556        } else {
557            SparseFormat::MaxScore
558        };
559        let weight_quantization = WeightQuantization::from_u8(b & 0x07)?;
560        Some(Self {
561            format,
562            index_size,
563            weight_quantization,
564            weight_threshold: 0.0,
565            doc_mass: None,
566            block_size: 128,
567            bmp_block_size: default_bmp_block_size(),
568            bmp_grid_bits: default_bmp_grid_bits(),
569            pruning: None,
570            query_config: None,
571
572            dims: None,
573            max_weight: None,
574            min_terms: 4,
575        })
576    }
577
578    /// Set block size (builder pattern)
579    /// Must be power of 2, recommended: 64, 128, 256
580    pub fn with_block_size(mut self, size: usize) -> Self {
581        self.block_size = size.next_power_of_two();
582        self
583    }
584
585    /// Set query configuration (builder pattern)
586    pub fn with_query_config(mut self, config: SparseQueryConfig) -> Self {
587        self.query_config = Some(config);
588        self
589    }
590}
591
592/// A sparse vector entry: (dimension_id, weight)
593#[derive(Debug, Clone, Copy, PartialEq)]
594pub struct SparseEntry {
595    pub dim_id: u32,
596    pub weight: f32,
597}
598
599/// Sparse vector representation
600#[derive(Debug, Clone, Default)]
601pub struct SparseVector {
602    pub(super) entries: Vec<SparseEntry>,
603}
604
605impl SparseVector {
606    /// Create a new sparse vector
607    pub fn new() -> Self {
608        Self {
609            entries: Vec::new(),
610        }
611    }
612
613    /// Create with pre-allocated capacity
614    pub fn with_capacity(capacity: usize) -> Self {
615        Self {
616            entries: Vec::with_capacity(capacity),
617        }
618    }
619
620    /// Create from dimension IDs and weights
621    pub fn from_entries(dim_ids: &[u32], weights: &[f32]) -> Self {
622        assert_eq!(dim_ids.len(), weights.len());
623        let mut entries: Vec<SparseEntry> = dim_ids
624            .iter()
625            .zip(weights.iter())
626            .map(|(&dim_id, &weight)| SparseEntry { dim_id, weight })
627            .collect();
628        // Sort by dimension ID for efficient intersection
629        entries.sort_by_key(|e| e.dim_id);
630        Self { entries }
631    }
632
633    /// Add an entry (must maintain sorted order by dim_id)
634    pub fn push(&mut self, dim_id: u32, weight: f32) {
635        debug_assert!(
636            self.entries.is_empty() || self.entries.last().unwrap().dim_id < dim_id,
637            "Entries must be added in sorted order by dim_id"
638        );
639        self.entries.push(SparseEntry { dim_id, weight });
640    }
641
642    /// Number of non-zero entries
643    pub fn len(&self) -> usize {
644        self.entries.len()
645    }
646
647    /// Check if empty
648    pub fn is_empty(&self) -> bool {
649        self.entries.is_empty()
650    }
651
652    /// Iterate over entries
653    pub fn iter(&self) -> impl Iterator<Item = &SparseEntry> {
654        self.entries.iter()
655    }
656
657    /// Sort by dimension ID (required for posting list encoding)
658    pub fn sort_by_dim(&mut self) {
659        self.entries.sort_by_key(|e| e.dim_id);
660    }
661
662    /// Sort by weight descending
663    pub fn sort_by_weight_desc(&mut self) {
664        self.entries.sort_by(|a, b| {
665            b.weight
666                .partial_cmp(&a.weight)
667                .unwrap_or(std::cmp::Ordering::Equal)
668        });
669    }
670
671    /// Get top-k entries by weight
672    pub fn top_k(&self, k: usize) -> Vec<SparseEntry> {
673        let mut sorted = self.entries.clone();
674        sorted.sort_by(|a, b| {
675            b.weight
676                .partial_cmp(&a.weight)
677                .unwrap_or(std::cmp::Ordering::Equal)
678        });
679        sorted.truncate(k);
680        sorted
681    }
682
683    /// Compute dot product with another sparse vector
684    pub fn dot(&self, other: &SparseVector) -> f32 {
685        let mut result = 0.0f32;
686        let mut i = 0;
687        let mut j = 0;
688
689        while i < self.entries.len() && j < other.entries.len() {
690            let a = &self.entries[i];
691            let b = &other.entries[j];
692
693            match a.dim_id.cmp(&b.dim_id) {
694                std::cmp::Ordering::Less => i += 1,
695                std::cmp::Ordering::Greater => j += 1,
696                std::cmp::Ordering::Equal => {
697                    result += a.weight * b.weight;
698                    i += 1;
699                    j += 1;
700                }
701            }
702        }
703
704        result
705    }
706
707    /// L2 norm squared
708    pub fn norm_squared(&self) -> f32 {
709        self.entries.iter().map(|e| e.weight * e.weight).sum()
710    }
711
712    /// L2 norm
713    pub fn norm(&self) -> f32 {
714        self.norm_squared().sqrt()
715    }
716
717    /// Prune dimensions below a weight threshold
718    pub fn filter_by_weight(&self, min_weight: f32) -> Self {
719        let entries: Vec<SparseEntry> = self
720            .entries
721            .iter()
722            .filter(|e| e.weight.abs() >= min_weight)
723            .cloned()
724            .collect();
725        Self { entries }
726    }
727}
728
729impl From<Vec<(u32, f32)>> for SparseVector {
730    fn from(pairs: Vec<(u32, f32)>) -> Self {
731        Self {
732            entries: pairs
733                .into_iter()
734                .map(|(dim_id, weight)| SparseEntry { dim_id, weight })
735                .collect(),
736        }
737    }
738}
739
740impl From<SparseVector> for Vec<(u32, f32)> {
741    fn from(vec: SparseVector) -> Self {
742        vec.entries
743            .into_iter()
744            .map(|e| (e.dim_id, e.weight))
745            .collect()
746    }
747}