Skip to main content

nodedb_vector/collection/
payload_index.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! In-memory payload bitmap indexes for vector-primary collections.
4//!
5//! Each indexed field stores a `HashMap<PayloadKey, RoaringBitmap>` mapping
6//! distinct field values to the set of HNSW node ids that carry that value.
7//! Pre-filter queries intersect/union bitmaps to produce a node-id allow-set
8//! that is passed directly into HNSW search, eliminating costly post-filter
9//! passes for high-selectivity predicates.
10//!
11//! **Ordering invariant**: callers must call `insert_row` *after* the HNSW
12//! node id is assigned so that the id is stable. Similarly, `delete_row`
13//! should be called *before* or *after* HNSW deletion is attempted — the
14//! bitmap and the HNSW graph are always consistent after a successful pair.
15
16use std::collections::HashMap;
17
18use roaring::RoaringBitmap;
19
20use nodedb_types::Value;
21
22// ── Key type ─────────────────────────────────────────────────────────────────
23
24/// A deterministic, hashable representation of a `Value` used as a bitmap key.
25///
26/// `Float` stores the total-ordered bit pattern of the `f64` (NaN is
27/// canonicalised to a single sentinel bit-pattern so two NaNs map to the
28/// same key).
29#[derive(
30    Debug,
31    Clone,
32    PartialEq,
33    Eq,
34    PartialOrd,
35    Ord,
36    Hash,
37    serde::Serialize,
38    serde::Deserialize,
39    zerompk::ToMessagePack,
40    zerompk::FromMessagePack,
41)]
42#[non_exhaustive]
43pub enum PayloadKey {
44    Null,
45    Bool(bool),
46    Integer(i64),
47    /// Total-ordered f64 bits. NaN is canonicalised to `u64::MAX`.
48    Float(u64),
49    String(String),
50    Bytes(Vec<u8>),
51    /// DateTime stored as a string for deterministic hashing.
52    DateTime(String),
53    Uuid(String),
54}
55
56impl PayloadKey {
57    /// Convert a `Value` to a `PayloadKey`.
58    ///
59    /// Returns `None` for values that cannot serve as index keys (e.g. nested
60    /// objects, arrays, geometry). The caller should skip the field rather than
61    /// panic.
62    pub fn from_value(v: &Value) -> Option<PayloadKey> {
63        match v {
64            Value::Null => Some(PayloadKey::Null),
65            Value::Bool(b) => Some(PayloadKey::Bool(*b)),
66            Value::Integer(i) => Some(PayloadKey::Integer(*i)),
67            Value::Float(f) => {
68                // Canonical NaN: map every NaN to the same sentinel.
69                let bits = if f.is_nan() { u64::MAX } else { f.to_bits() };
70                Some(PayloadKey::Float(bits))
71            }
72            Value::String(s) => Some(PayloadKey::String(s.clone())),
73            Value::Bytes(b) => Some(PayloadKey::Bytes(b.clone())),
74            Value::Uuid(s) | Value::Ulid(s) | Value::Regex(s) => Some(PayloadKey::Uuid(s.clone())),
75            Value::DateTime(dt) | Value::NaiveDateTime(dt) => {
76                Some(PayloadKey::DateTime(format!("{dt:?}")))
77            }
78            Value::Decimal(d) => Some(PayloadKey::String(d.to_string())),
79            // Complex values that cannot be equality-indexed.
80            Value::Array(_)
81            | Value::Object(_)
82            | Value::Set(_)
83            | Value::Geometry(_)
84            | Value::Duration(_)
85            | Value::Range { .. }
86            | Value::Record { .. }
87            | Value::ArrayCell(_) => None,
88            // Value is #[non_exhaustive]; future variants with no known
89            // payload key representation are not indexable.
90            _ => None,
91        }
92    }
93}
94
95// ── Index kind ────────────────────────────────────────────────────────────────
96
97/// Re-export the kind enum from `nodedb-types` so the on-disk snapshot and
98/// the DDL config wire format share a single definition.
99pub use nodedb_types::PayloadIndexKind;
100
101// ── Single-field index ────────────────────────────────────────────────────────
102
103/// Per-field bitmap storage. `Equality` indexes use a hash map (O(1)
104/// point lookup); `Range` indexes use a B-tree (O(log n) point + range
105/// scan over keys).
106#[derive(Debug, Clone)]
107#[non_exhaustive]
108pub enum PayloadIndexBitmaps {
109    Equality(HashMap<PayloadKey, RoaringBitmap>),
110    Range(std::collections::BTreeMap<PayloadKey, RoaringBitmap>),
111}
112
113impl PayloadIndexBitmaps {
114    fn for_kind(kind: PayloadIndexKind) -> Self {
115        match kind {
116            PayloadIndexKind::Range => Self::Range(std::collections::BTreeMap::new()),
117            // Boolean is rare-cardinality; equality storage is fine.
118            // Unknown future variants also default to equality storage.
119            _ => Self::Equality(HashMap::new()),
120        }
121    }
122
123    fn get(&self, key: &PayloadKey) -> Option<&RoaringBitmap> {
124        match self {
125            Self::Equality(m) => m.get(key),
126            Self::Range(m) => m.get(key),
127        }
128    }
129
130    fn entry_or_default(&mut self, key: PayloadKey) -> &mut RoaringBitmap {
131        match self {
132            Self::Equality(m) => m.entry(key).or_default(),
133            Self::Range(m) => m.entry(key).or_default(),
134        }
135    }
136
137    fn get_mut(&mut self, key: &PayloadKey) -> Option<&mut RoaringBitmap> {
138        match self {
139            Self::Equality(m) => m.get_mut(key),
140            Self::Range(m) => m.get_mut(key),
141        }
142    }
143
144    fn remove(&mut self, key: &PayloadKey) -> Option<RoaringBitmap> {
145        match self {
146            Self::Equality(m) => m.remove(key),
147            Self::Range(m) => m.remove(key),
148        }
149    }
150
151    fn clear(&mut self) {
152        match self {
153            Self::Equality(m) => m.clear(),
154            Self::Range(m) => m.clear(),
155        }
156    }
157
158    fn iter(&self) -> Box<dyn Iterator<Item = (&PayloadKey, &RoaringBitmap)> + '_> {
159        match self {
160            Self::Equality(m) => Box::new(m.iter()),
161            Self::Range(m) => Box::new(m.iter()),
162        }
163    }
164}
165
166/// Bitmap index for a single payload field.
167#[derive(Debug, Clone)]
168pub struct PayloadIndex {
169    /// The field name this index covers.
170    pub field: String,
171    /// Index kind.
172    pub kind: PayloadIndexKind,
173    /// `value → set of node ids`. Storage layout depends on `kind`.
174    pub bitmaps: PayloadIndexBitmaps,
175}
176
177impl Default for PayloadIndex {
178    fn default() -> Self {
179        Self::new(String::new(), PayloadIndexKind::Equality)
180    }
181}
182
183impl PayloadIndex {
184    /// Create a new empty index for `field`.
185    pub fn new(field: impl Into<String>, kind: PayloadIndexKind) -> Self {
186        Self {
187            field: field.into(),
188            kind,
189            bitmaps: PayloadIndexBitmaps::for_kind(kind),
190        }
191    }
192
193    /// Record that `node_id` has `value` for this field.
194    pub fn insert(&mut self, node_id: u32, value: &Value) {
195        let Some(key) = PayloadKey::from_value(value) else {
196            return;
197        };
198        self.bitmaps.entry_or_default(key).insert(node_id);
199    }
200
201    /// Remove `node_id` from the bitmap for `value`.
202    pub fn delete(&mut self, node_id: u32, value: &Value) {
203        let Some(key) = PayloadKey::from_value(value) else {
204            return;
205        };
206        if let Some(bm) = self.bitmaps.get_mut(&key) {
207            bm.remove(node_id);
208            if bm.is_empty() {
209                self.bitmaps.remove(&key);
210            }
211        }
212    }
213
214    /// Return the bitmap of node ids with `field = value`, or `None` if the
215    /// value has never been inserted (empty set).
216    pub fn equality(&self, value: &Value) -> Option<&RoaringBitmap> {
217        let key = PayloadKey::from_value(value)?;
218        self.bitmaps.get(&key)
219    }
220
221    /// Return the union bitmap of all entries whose key falls in
222    /// `[low, high]` (with `low_inclusive` / `high_inclusive` controlling
223    /// strictness). `None` bounds mean "open on that side". Returns
224    /// `None` when this index is not a `Range` index — the caller falls
225    /// back to a full post-filter scan.
226    pub fn range(
227        &self,
228        low: Option<&Value>,
229        low_inclusive: bool,
230        high: Option<&Value>,
231        high_inclusive: bool,
232    ) -> Option<RoaringBitmap> {
233        let PayloadIndexBitmaps::Range(map) = &self.bitmaps else {
234            return None;
235        };
236        use std::ops::Bound;
237        let low_key = low.and_then(PayloadKey::from_value);
238        let high_key = high.and_then(PayloadKey::from_value);
239        let low_bound = match (&low_key, low_inclusive) {
240            (Some(k), true) => Bound::Included(k.clone()),
241            (Some(k), false) => Bound::Excluded(k.clone()),
242            (None, _) => Bound::Unbounded,
243        };
244        let high_bound = match (&high_key, high_inclusive) {
245            (Some(k), true) => Bound::Included(k.clone()),
246            (Some(k), false) => Bound::Excluded(k.clone()),
247            (None, _) => Bound::Unbounded,
248        };
249        let mut acc = RoaringBitmap::new();
250        for (_, bm) in map.range((low_bound, high_bound)) {
251            acc |= bm;
252        }
253        Some(acc)
254    }
255}
256
257// ── Multi-field index set ─────────────────────────────────────────────────────
258
259/// Set of per-field payload bitmap indexes for a collection.
260#[derive(Debug, Default)]
261pub struct PayloadIndexSet {
262    indexes: HashMap<String, PayloadIndex>,
263}
264
265impl PayloadIndexSet {
266    /// Register a new field index. Idempotent — safe to call multiple times for
267    /// the same field (subsequent calls are no-ops).
268    pub fn add_index(&mut self, field: impl Into<String>, kind: PayloadIndexKind) {
269        let field = field.into();
270        self.indexes
271            .entry(field.clone())
272            .or_insert_with(|| PayloadIndex::new(field, kind));
273    }
274
275    /// Returns `true` if any index is registered.
276    pub fn is_empty(&self) -> bool {
277        self.indexes.is_empty()
278    }
279
280    /// Insert all indexed fields from `fields` for `node_id`.
281    pub fn insert_row(&mut self, node_id: u32, fields: &HashMap<String, Value>) {
282        for (field, idx) in &mut self.indexes {
283            if let Some(value) = fields.get(field) {
284                idx.insert(node_id, value);
285            }
286        }
287    }
288
289    /// Remove all indexed fields for `node_id`.
290    pub fn delete_row(&mut self, node_id: u32, fields: &HashMap<String, Value>) {
291        for (field, idx) in &mut self.indexes {
292            if let Some(value) = fields.get(field) {
293                idx.delete(node_id, value);
294            }
295        }
296    }
297
298    /// A set with the same registered fields and kinds and no rows.
299    pub fn definitions_only(&self) -> Self {
300        Self {
301            indexes: self
302                .indexes
303                .iter()
304                .map(|(field, index)| {
305                    (
306                        field.clone(),
307                        PayloadIndex::new(index.field.clone(), index.kind),
308                    )
309                })
310                .collect(),
311        }
312    }
313
314    /// Drop every node from every index. The registered fields and their
315    /// kinds stay, so the next `insert_row` indexes the same columns.
316    pub fn clear_rows(&mut self) {
317        for idx in self.indexes.values_mut() {
318            idx.bitmaps.clear();
319        }
320    }
321
322    /// Evaluate a filter predicate against the bitmap indexes.
323    ///
324    /// Returns `Some(bitmap)` when the predicate is **fully covered** by
325    /// indexed fields (every leaf field has an index). Returns `None` when any
326    /// leaf references an un-indexed field — the caller must then fall back to a
327    /// full post-filter scan. This avoids silently dropping un-indexed
328    /// predicates.
329    pub fn pre_filter(&self, predicate: &FilterPredicate) -> Option<RoaringBitmap> {
330        match predicate {
331            FilterPredicate::Eq { field, value } => {
332                let idx = self.indexes.get(field)?;
333                Some(
334                    idx.equality(value)
335                        .cloned()
336                        .unwrap_or_else(RoaringBitmap::new),
337                )
338            }
339            FilterPredicate::In { field, values } => {
340                let idx = self.indexes.get(field)?;
341                let mut acc = RoaringBitmap::new();
342                for v in values {
343                    if let Some(bm) = idx.equality(v) {
344                        acc |= bm;
345                    }
346                }
347                Some(acc)
348            }
349            FilterPredicate::Range {
350                field,
351                low,
352                low_inclusive,
353                high,
354                high_inclusive,
355            } => {
356                let idx = self.indexes.get(field)?;
357                idx.range(low.as_ref(), *low_inclusive, high.as_ref(), *high_inclusive)
358            }
359            FilterPredicate::And(preds) => {
360                let mut result: Option<RoaringBitmap> = None;
361                for pred in preds {
362                    let bm = self.pre_filter(pred)?;
363                    result = Some(match result {
364                        None => bm,
365                        Some(acc) => acc & bm,
366                    });
367                }
368                Some(result.unwrap_or_default())
369            }
370            FilterPredicate::Or(preds) => {
371                let mut result = RoaringBitmap::new();
372                for pred in preds {
373                    let bm = self.pre_filter(pred)?;
374                    result |= bm;
375                }
376                Some(result)
377            }
378            FilterPredicate::Not(pred) => {
379                // NOT requires knowing the full set of node ids to complement
380                // against; we do not have that here, so return None (full scan).
381                // The predicate is still valid — callers handle it via
382                // post-filter on the HNSW results.
383                let _ = pred;
384                None
385            }
386        }
387    }
388
389    /// Return an iterator over all registered field names.
390    pub fn field_names(&self) -> impl Iterator<Item = &str> {
391        self.indexes.keys().map(String::as_str)
392    }
393}
394
395// ── Filter predicate ──────────────────────────────────────────────────────────
396
397/// A structured filter predicate that can be evaluated against the bitmap
398/// indexes. Mirrors the SQL WHERE predicates the planner builds.
399#[derive(Debug, Clone)]
400#[non_exhaustive]
401pub enum FilterPredicate {
402    /// `field = value` equality.
403    Eq { field: String, value: Value },
404    /// `field IN (v1, v2, ...)` — union of per-value bitmaps.
405    /// Lowered to `Or(Eq, Eq, ...)` internally.
406    In { field: String, values: Vec<Value> },
407    /// `field` within bounded range against a sorted (`Range`) payload
408    /// index. Either bound being `None` means open on that side.
409    Range {
410        field: String,
411        low: Option<Value>,
412        low_inclusive: bool,
413        high: Option<Value>,
414        high_inclusive: bool,
415    },
416    /// Conjunction.
417    And(Vec<FilterPredicate>),
418    /// Disjunction.
419    Or(Vec<FilterPredicate>),
420    /// Negation — always falls back to full scan (no complement support).
421    Not(Box<FilterPredicate>),
422}
423
424// ── Serialization helpers ─────────────────────────────────────────────────────
425
426/// Serializable snapshot of a single `PayloadIndex` for checkpointing.
427#[derive(
428    serde::Serialize, serde::Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack,
429)]
430pub struct PayloadIndexSnapshot {
431    pub field: String,
432    pub kind: PayloadIndexKind,
433    /// `(key_bytes, bitmap_bytes)` pairs — key serialised as msgpack,
434    /// bitmap serialised via its standard serialization.
435    pub entries: Vec<(Vec<u8>, Vec<u8>)>,
436}
437
438/// Serializable snapshot of the full `PayloadIndexSet`.
439#[derive(
440    serde::Serialize, serde::Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack,
441)]
442pub struct PayloadIndexSetSnapshot {
443    pub indexes: Vec<PayloadIndexSnapshot>,
444}
445
446impl PayloadIndexSet {
447    /// Serialize to a snapshot for checkpointing.
448    pub fn to_snapshot(&self) -> PayloadIndexSetSnapshot {
449        let indexes = self
450            .indexes
451            .values()
452            .map(|idx| {
453                let entries = idx
454                    .bitmaps
455                    .iter()
456                    .filter_map(|(key, bm)| {
457                        let key_bytes = zerompk::to_msgpack_vec(key).ok()?;
458                        let mut bm_bytes = Vec::new();
459                        bm.serialize_into(&mut bm_bytes).ok()?;
460                        Some((key_bytes, bm_bytes))
461                    })
462                    .collect();
463                PayloadIndexSnapshot {
464                    field: idx.field.clone(),
465                    kind: idx.kind,
466                    entries,
467                }
468            })
469            .collect();
470        PayloadIndexSetSnapshot { indexes }
471    }
472
473    /// Restore from a checkpoint snapshot.
474    pub fn from_snapshot(snap: PayloadIndexSetSnapshot) -> Self {
475        let mut indexes = HashMap::new();
476        for idx_snap in snap.indexes {
477            let mut bitmaps = PayloadIndexBitmaps::for_kind(idx_snap.kind);
478            for (key_bytes, bm_bytes) in idx_snap.entries {
479                let Ok(key) = zerompk::from_msgpack::<PayloadKey>(&key_bytes) else {
480                    continue;
481                };
482                let Ok(bm) = RoaringBitmap::deserialize_from(bm_bytes.as_slice()) else {
483                    continue;
484                };
485                *bitmaps.entry_or_default(key) = bm;
486            }
487            let idx = PayloadIndex {
488                field: idx_snap.field.clone(),
489                kind: idx_snap.kind,
490                bitmaps,
491            };
492            indexes.insert(idx_snap.field, idx);
493        }
494        Self { indexes }
495    }
496}
497
498// ── Tests ─────────────────────────────────────────────────────────────────────
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    fn string_val(s: &str) -> Value {
505        Value::String(s.to_string())
506    }
507    fn int_val(i: i64) -> Value {
508        Value::Integer(i)
509    }
510
511    #[test]
512    fn insert_and_equality_lookup() {
513        let mut idx = PayloadIndex::new("category", PayloadIndexKind::Equality);
514        idx.insert(0, &string_val("A"));
515        idx.insert(1, &string_val("B"));
516        idx.insert(2, &string_val("A"));
517
518        let a_bm = idx.equality(&string_val("A")).unwrap();
519        assert!(a_bm.contains(0));
520        assert!(a_bm.contains(2));
521        assert!(!a_bm.contains(1));
522
523        let b_bm = idx.equality(&string_val("B")).unwrap();
524        assert!(b_bm.contains(1));
525    }
526
527    #[test]
528    fn delete_removes_from_bitmap() {
529        let mut idx = PayloadIndex::new("category", PayloadIndexKind::Equality);
530        idx.insert(0, &string_val("A"));
531        idx.insert(1, &string_val("A"));
532        idx.delete(0, &string_val("A"));
533
534        let bm = idx.equality(&string_val("A")).unwrap();
535        assert!(!bm.contains(0));
536        assert!(bm.contains(1));
537    }
538
539    #[test]
540    fn delete_empty_bitmap_removed() {
541        let mut idx = PayloadIndex::new("x", PayloadIndexKind::Equality);
542        idx.insert(5, &string_val("v"));
543        idx.delete(5, &string_val("v"));
544        // Entry for "v" should be gone.
545        assert!(idx.equality(&string_val("v")).is_none());
546    }
547
548    #[test]
549    fn pre_filter_equality() {
550        let mut set = PayloadIndexSet::default();
551        set.add_index("category", PayloadIndexKind::Equality);
552
553        let mut fields = HashMap::new();
554        for i in 0u32..10 {
555            fields.clear();
556            fields.insert(
557                "category".to_string(),
558                string_val(if i < 5 { "A" } else { "B" }),
559            );
560            set.insert_row(i, &fields);
561        }
562
563        let pred = FilterPredicate::Eq {
564            field: "category".to_string(),
565            value: string_val("A"),
566        };
567        let bm = set.pre_filter(&pred).unwrap();
568        assert_eq!(bm.len(), 5);
569        for i in 0..5u32 {
570            assert!(bm.contains(i));
571        }
572    }
573
574    #[test]
575    fn pre_filter_and() {
576        let mut set = PayloadIndexSet::default();
577        set.add_index("category", PayloadIndexKind::Equality);
578        set.add_index("active", PayloadIndexKind::Boolean);
579
580        let mut fields = HashMap::new();
581        // node 0: A + true
582        fields.insert("category".to_string(), string_val("A"));
583        fields.insert("active".to_string(), Value::Bool(true));
584        set.insert_row(0, &fields);
585        // node 1: A + false
586        fields.insert("active".to_string(), Value::Bool(false));
587        set.insert_row(1, &fields);
588        // node 2: B + true
589        fields.insert("category".to_string(), string_val("B"));
590        fields.insert("active".to_string(), Value::Bool(true));
591        set.insert_row(2, &fields);
592
593        let pred = FilterPredicate::And(vec![
594            FilterPredicate::Eq {
595                field: "category".to_string(),
596                value: string_val("A"),
597            },
598            FilterPredicate::Eq {
599                field: "active".to_string(),
600                value: Value::Bool(true),
601            },
602        ]);
603        let bm = set.pre_filter(&pred).unwrap();
604        assert_eq!(bm.len(), 1);
605        assert!(bm.contains(0));
606    }
607
608    #[test]
609    fn pre_filter_or() {
610        let mut set = PayloadIndexSet::default();
611        set.add_index("category", PayloadIndexKind::Equality);
612
613        let mut fields = HashMap::new();
614        for i in 0u32..9 {
615            fields.clear();
616            let cat = match i % 3 {
617                0 => "A",
618                1 => "B",
619                _ => "C",
620            };
621            fields.insert("category".to_string(), string_val(cat));
622            set.insert_row(i, &fields);
623        }
624
625        let pred = FilterPredicate::Or(vec![
626            FilterPredicate::Eq {
627                field: "category".to_string(),
628                value: string_val("A"),
629            },
630            FilterPredicate::Eq {
631                field: "category".to_string(),
632                value: string_val("B"),
633            },
634        ]);
635        let bm = set.pre_filter(&pred).unwrap();
636        assert_eq!(bm.len(), 6);
637    }
638
639    #[test]
640    fn pre_filter_missing_index_returns_none() {
641        let set = PayloadIndexSet::default(); // no indexes
642
643        let pred = FilterPredicate::Eq {
644            field: "category".to_string(),
645            value: string_val("A"),
646        };
647        assert!(set.pre_filter(&pred).is_none());
648    }
649
650    #[test]
651    fn pre_filter_not_returns_none() {
652        let mut set = PayloadIndexSet::default();
653        set.add_index("category", PayloadIndexKind::Equality);
654
655        let pred = FilterPredicate::Not(Box::new(FilterPredicate::Eq {
656            field: "category".to_string(),
657            value: string_val("A"),
658        }));
659        // NOT always requires full scan.
660        assert!(set.pre_filter(&pred).is_none());
661    }
662
663    #[test]
664    fn pre_filter_and_partial_index_returns_none() {
665        // AND where one field is indexed but the other is not — must return None.
666        let mut set = PayloadIndexSet::default();
667        set.add_index("category", PayloadIndexKind::Equality);
668
669        let pred = FilterPredicate::And(vec![
670            FilterPredicate::Eq {
671                field: "category".to_string(),
672                value: string_val("A"),
673            },
674            FilterPredicate::Eq {
675                field: "unindexed_field".to_string(),
676                value: int_val(42),
677            },
678        ]);
679        assert!(set.pre_filter(&pred).is_none());
680    }
681
682    #[test]
683    fn nan_equality() {
684        let mut idx = PayloadIndex::new("score", PayloadIndexKind::Equality);
685        idx.insert(0, &Value::Float(f64::NAN));
686        idx.insert(1, &Value::Float(f64::NAN));
687        idx.insert(2, &Value::Float(1.0));
688
689        // Both NaN-bearing nodes should be in the same bucket.
690        let bm = idx.equality(&Value::Float(f64::NAN)).unwrap();
691        assert_eq!(bm.len(), 2);
692        assert!(bm.contains(0));
693        assert!(bm.contains(1));
694    }
695
696    #[test]
697    fn null_value_indexed() {
698        let mut idx = PayloadIndex::new("f", PayloadIndexKind::Equality);
699        idx.insert(0, &Value::Null);
700        idx.insert(1, &Value::Null);
701        let bm = idx.equality(&Value::Null).unwrap();
702        assert_eq!(bm.len(), 2);
703    }
704
705    #[test]
706    fn snapshot_roundtrip() {
707        let mut set = PayloadIndexSet::default();
708        set.add_index("category", PayloadIndexKind::Equality);
709
710        let mut fields = HashMap::new();
711        for i in 0u32..6 {
712            fields.clear();
713            fields.insert(
714                "category".to_string(),
715                string_val(if i < 3 { "A" } else { "B" }),
716            );
717            set.insert_row(i, &fields);
718        }
719
720        let snap = set.to_snapshot();
721        let snap_bytes = zerompk::to_msgpack_vec(&snap).unwrap();
722        let restored_snap: PayloadIndexSetSnapshot = zerompk::from_msgpack(&snap_bytes).unwrap();
723        let restored = PayloadIndexSet::from_snapshot(restored_snap);
724
725        let pred = FilterPredicate::Eq {
726            field: "category".to_string(),
727            value: string_val("A"),
728        };
729        let bm = restored.pre_filter(&pred).unwrap();
730        assert_eq!(bm.len(), 3);
731    }
732}