Skip to main content

kevy_index/
segcold.rs

1//! Cold-segment support for scalar indexes: the order-preserving
2//! `(value, row_key)` byte-key codec an evicted tree entry is stored
3//! under, and the membership bloom that tells the write path a row MAY
4//! have a cold entry (so it earns a tombstone instead of a ghost).
5//!
6//! The key is `frame(order_bytes(value)) ‖ frame(row_key)` — the
7//! composite codec's escape-and-terminate framing (0x00 → 0x00 0xFF,
8//! terminator 0x00 0x00; the terminator sorts below every escaped
9//! continuation byte, so memcmp order equals tuple order), without the
10//! composite column-length cap: a scalar index puts no bound on str
11//! values or row keys, so neither does its cold key.
12
13use crate::catalog::ValType;
14use crate::value::IndexValue;
15
16pub(crate) fn value_order_bytes(v: &IndexValue) -> Vec<u8> {
17    match v {
18        IndexValue::Str(s) => s.clone(),
19        IndexValue::I64(i) => ((*i as u64) ^ (1 << 63)).to_be_bytes().to_vec(),
20        IndexValue::F64(f) => {
21            let b = f.to_bits();
22            let m = if b >> 63 == 1 { !b } else { b | (1 << 63) };
23            m.to_be_bytes().to_vec()
24        }
25    }
26}
27
28/// `(value, row_key)` → the strictly-orderable segment key.
29///
30/// ```
31/// use kevy_index::{IndexValue, seg_key};
32/// // memcmp order of the keys is (value, row_key) tuple order
33/// let a = seg_key(&IndexValue::I64(-5), b"zz");
34/// let b = seg_key(&IndexValue::I64(3), b"aa");
35/// let c = seg_key(&IndexValue::I64(3), b"ab");
36/// assert!(a < b && b < c);
37/// ```
38pub fn seg_key(v: &IndexValue, row_key: &[u8]) -> Vec<u8> {
39    let vb = v.order_bytes();
40    let mut out = Vec::with_capacity(vb.len() + row_key.len() + 4);
41    frame_into(&mut out, &vb);
42    frame_into(&mut out, row_key);
43    out
44}
45
46/// Decode a segment key back to `(value, row_key)`. `ty` is the
47/// index's declared type (the key does not carry it). `None` on any
48/// malformed frame — a corrupt key is a refusal upstream, never a
49/// guessed entry.
50///
51/// ```
52/// use kevy_index::{IndexValue, ValType, decode_seg_key, seg_key};
53/// let key = seg_key(&IndexValue::F64(-2.5), b"row\0:1");
54/// let (v, row) = decode_seg_key(ValType::F64, &key).expect("well-formed");
55/// assert_eq!((v, row), (IndexValue::F64(-2.5), b"row\0:1".to_vec()));
56/// assert_eq!(decode_seg_key(ValType::F64, &key[..key.len() - 1]), None);
57/// ```
58pub fn decode_seg_key(ty: ValType, key: &[u8]) -> Option<(IndexValue, Vec<u8>)> {
59    let (vb, rest) = unframe(key)?;
60    let (row, tail) = unframe(rest)?;
61    if !tail.is_empty() {
62        return None;
63    }
64    let value = match ty {
65        ValType::Str => IndexValue::Str(vb),
66        ValType::I64 => {
67            let raw = u64::from_be_bytes(vb.try_into().ok()?);
68            IndexValue::I64((raw ^ (1 << 63)) as i64)
69        }
70        ValType::F64 => {
71            let m = u64::from_be_bytes(vb.try_into().ok()?);
72            let b = if m >> 63 == 1 { m & !(1 << 63) } else { !m };
73            IndexValue::F64(f64::from_bits(b))
74        }
75        _ => return None,
76    };
77    Some((value, row))
78}
79
80/// Inclusive byte bounds covering exactly the cold keys whose value
81/// lies in `[min, max]`. The upper bound is the max value's frame with
82/// its final terminator byte flipped to 0x01: every `frame(max) ‖
83/// frame(row)` key sorts below it (the shared prefix ends 0x00 0x00 <
84/// 0x00 0x01), and no larger value's frame can reach it (in the
85/// escaped alphabet 0x00 is always followed by 0xFF, so the flipped
86/// terminator is nobody's prefix).
87///
88/// ```
89/// use kevy_index::{IndexValue, seg_bounds, seg_key};
90/// let (lo, hi) = seg_bounds(&IndexValue::I64(10), &IndexValue::I64(20));
91/// let within = |v: i64| {
92///     let k = seg_key(&IndexValue::I64(v), b"row");
93///     lo <= k && k <= hi
94/// };
95/// assert!(within(10) && within(15) && within(20));
96/// assert!(!within(9) && !within(21));
97/// ```
98pub fn seg_bounds(min: &IndexValue, max: &IndexValue) -> (Vec<u8>, Vec<u8>) {
99    let mut lo = Vec::new();
100    frame_into(&mut lo, &min.order_bytes());
101    let mut hi = Vec::new();
102    frame_into(&mut hi, &max.order_bytes());
103    *hi.last_mut().expect("frame is never empty") = 0x01;
104    (lo, hi)
105}
106
107/// Which shape of tree a window boundary lives in: a plain i64
108/// window-column index, or a composite index the window column LEADS
109/// (ascending — its first component is the column's `order_key` 8B,
110/// so the tree prefix below a boundary is exactly the out-of-window
111/// batch, the property the slide rides).
112///
113/// ```
114/// use kevy_index::{IndexValue, WindowShape, window_bound};
115/// // the same boundary, expressed in each tree's value space
116/// assert_eq!(window_bound(7, WindowShape::PlainI64), IndexValue::I64(7));
117/// assert!(matches!(window_bound(7, WindowShape::CompositeLed), IndexValue::Str(b) if b.len() == 8));
118/// ```
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
120#[non_exhaustive]
121pub enum WindowShape {
122    /// `INDEX <wcol> range` — the tree's values ARE the window column.
123    ///
124    /// ```
125    /// use kevy_index::{IndexValue, WindowShape};
126    /// assert_eq!(IndexValue::I64(1_700).window_value(WindowShape::PlainI64), Some(1_700));
127    /// ```
128    PlainI64,
129    /// `ORDERPATH` led by the window column ascending — the values
130    /// are composite byte strings whose first 8 bytes encode it.
131    ///
132    /// ```
133    /// use kevy_index::{IndexValue, WindowShape, order_key, ValType};
134    /// let mut composite = order_key(ValType::I64, b"1700").expect("an i64");
135    /// composite.extend_from_slice(b"trailing columns");
136    /// let v = IndexValue::Str(composite);
137    /// assert_eq!(v.window_value(WindowShape::CompositeLed), Some(1_700));
138    /// ```
139    CompositeLed,
140}
141
142/// What an audit needs to tell "slid out on purpose" from "lost".
143///
144/// A row whose window value sits below `boundary` is absent from the
145/// hot tree by design — but only if it actually reached a cold
146/// segment. Position alone cannot distinguish the two, and a row lost
147/// between the two structures sits below the boundary exactly like a
148/// legitimate one. So the audit carries the cold side's own count and
149/// checks the identity instead:
150///
151///   missing = (rows below the boundary that should be indexed)
152///             - (live cold entries below it)
153///
154/// One `u64` out of the segment scope, no per-row cold lookup, and the
155/// answer is evidence rather than assumption.
156///
157/// ```
158/// use kevy_index::{WindowAudit, WindowShape};
159/// let a = WindowAudit::new(1_000, WindowShape::PlainI64, 42);
160/// assert_eq!((a.boundary, a.cold_live), (1_000, 42));
161/// ```
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
163#[non_exhaustive]
164pub struct WindowAudit {
165    /// Bucket-aligned eviction boundary: entries below it are cold.
166    ///
167    /// ```
168    /// use kevy_index::{IndexValue, WindowAudit, WindowShape};
169    /// let a = WindowAudit::new(1_000, WindowShape::PlainI64, 0);
170    /// // a row whose window value sits below the boundary belongs to the cold side
171    /// let v = IndexValue::I64(999).window_value(a.shape).expect("plain i64");
172    /// assert!(v < a.boundary);
173    /// ```
174    pub boundary: i64,
175    /// Which tree shape the boundary lives in.
176    ///
177    /// ```
178    /// use kevy_index::{WindowAudit, WindowShape, window_bound};
179    /// let a = WindowAudit::new(5, WindowShape::CompositeLed, 0);
180    /// // the shape picks the value space the boundary is compared in
181    /// assert_eq!(window_bound(a.boundary, a.shape), window_bound(5, WindowShape::CompositeLed));
182    /// ```
183    pub shape: WindowShape,
184    /// Live (non-tombstoned) cold entries below `boundary`.
185    ///
186    /// ```
187    /// use kevy_index::{WindowAudit, WindowShape};
188    /// let below_boundary_should_index = 50u64;
189    /// let a = WindowAudit::new(1_000, WindowShape::PlainI64, 48);
190    /// // the audit's identity: rows below the boundary minus live cold entries
191    /// assert_eq!(below_boundary_should_index - a.cold_live, 2, "two rows were lost");
192    /// ```
193    pub cold_live: u64,
194}
195
196impl WindowAudit {
197    /// The audit for a tree of `shape` whose eviction boundary is
198    /// `boundary`, with `cold_live` live cold entries below it.
199    ///
200    /// ```
201    /// use kevy_index::{WindowAudit, WindowShape};
202    /// assert_eq!(WindowAudit::new(0, WindowShape::CompositeLed, 0).shape, WindowShape::CompositeLed);
203    /// ```
204    pub fn new(boundary: i64, shape: WindowShape, cold_live: u64) -> WindowAudit {
205        WindowAudit { boundary, shape, cold_live }
206    }
207}
208
209pub(crate) fn window_value_of(v: &IndexValue, shape: WindowShape) -> Option<i64> {
210    match (shape, v) {
211        (WindowShape::PlainI64, IndexValue::I64(i)) => Some(*i),
212        (WindowShape::CompositeLed, IndexValue::Str(b)) => {
213            let raw = u64::from_be_bytes(b.get(..8)?.try_into().ok()?);
214            Some((raw ^ (1 << 63)) as i64)
215        }
216        _ => None,
217    }
218}
219
220/// The below-boundary bound for `scan_below` / `split_off_below`,
221/// under `shape`. For the composite shape the bound is the target's
222/// bare 8-byte first component: any key whose first component sorts
223/// below it is below the bound, and a key EQUAL in those 8 bytes is
224/// longer and therefore above it — "first column < target", exactly.
225///
226/// ```
227/// use kevy_index::{IndexValue, ValType, WindowShape, order_key, window_bound};
228/// let bound = window_bound(100, WindowShape::CompositeLed);
229/// let mut at_100 = order_key(ValType::I64, b"100").expect("an i64");
230/// at_100.extend_from_slice(b"rest");
231/// let mut at_99 = order_key(ValType::I64, b"99").expect("an i64");
232/// at_99.extend_from_slice(b"rest");
233/// assert!(IndexValue::Str(at_99) < bound && bound < IndexValue::Str(at_100));
234/// ```
235pub fn window_bound(target: i64, shape: WindowShape) -> IndexValue {
236    match shape {
237        WindowShape::PlainI64 => IndexValue::I64(target),
238        WindowShape::CompositeLed => {
239            IndexValue::Str(((target as u64) ^ (1 << 63)).to_be_bytes().to_vec())
240        }
241    }
242}
243
244/// Encode a row's stored `VALUES` into a cold entry's payload:
245/// `[n u32][per value: 0 | 1‖len u32‖bytes]`, little-endian. An index
246/// that declared no values encodes the EMPTY payload — byte-identical
247/// to the a-train's segments, which carried none.
248///
249/// ```
250/// use kevy_index::{decode_seg_values, encode_seg_values};
251/// let payload = encode_seg_values(&[Some(b"kyoto"), None]);
252/// assert_eq!(decode_seg_values(&payload), Some(vec![Some(b"kyoto".to_vec()), None]));
253/// assert!(encode_seg_values(&[]).is_empty());
254/// ```
255pub fn encode_seg_values(values: &[Option<&[u8]>]) -> Vec<u8> {
256    if values.is_empty() {
257        return Vec::new();
258    }
259    let mut out = Vec::new();
260    out.extend_from_slice(&(values.len() as u32).to_le_bytes());
261    for v in values {
262        match v {
263            None => out.push(0),
264            Some(b) => {
265                out.push(1);
266                out.extend_from_slice(&(b.len() as u32).to_le_bytes());
267                out.extend_from_slice(b);
268            }
269        }
270    }
271    out
272}
273
274/// Decode a cold entry's payload back to its stored values. The empty
275/// payload is the no-values shape; `None` on any malformed frame — a
276/// corrupt payload is a refusal upstream, never a guessed row.
277///
278/// ```
279/// use kevy_index::{decode_seg_values, encode_seg_values};
280/// assert_eq!(decode_seg_values(&[]), Some(vec![]));
281/// let mut payload = encode_seg_values(&[Some(b"v")]);
282/// payload.push(0);
283/// assert_eq!(decode_seg_values(&payload), None, "trailing bytes are a refusal");
284/// ```
285pub fn decode_seg_values(payload: &[u8]) -> Option<Vec<Option<Vec<u8>>>> {
286    if payload.is_empty() {
287        return Some(Vec::new());
288    }
289    let n = u32::from_le_bytes(payload.get(..4)?.try_into().ok()?) as usize;
290    let mut at = 4usize;
291    // A count out of the payload is a claim: every entry costs at least
292    // its one-byte tag, so `len` bytes cannot honour more than `len` of
293    // them. Unbounded, a u32 count reserves up to 4.29e9 elements here.
294    let mut out = Vec::with_capacity(n.min(payload.len()));
295    for _ in 0..n {
296        match payload.get(at)? {
297            0 => {
298                out.push(None);
299                at += 1;
300            }
301            1 => {
302                let len =
303                    u32::from_le_bytes(payload.get(at + 1..at + 5)?.try_into().ok()?) as usize;
304                out.push(Some(payload.get(at + 5..at + 5 + len)?.to_vec()));
305                at += 5 + len;
306            }
307            _ => return None,
308        }
309    }
310    (at == payload.len()).then_some(out)
311}
312
313/// Escape-and-terminate one component into `out`.
314fn frame_into(out: &mut Vec<u8>, b: &[u8]) {
315    for &c in b {
316        if c == 0 {
317            out.push(0);
318            out.push(0xFF);
319        } else {
320            out.push(c);
321        }
322    }
323    out.push(0);
324    out.push(0);
325}
326
327/// Read one framed component; returns (component, rest-after-frame).
328fn unframe(b: &[u8]) -> Option<(Vec<u8>, &[u8])> {
329    let mut out = Vec::new();
330    let mut i = 0;
331    while i < b.len() {
332        if b[i] != 0 {
333            out.push(b[i]);
334            i += 1;
335            continue;
336        }
337        match b.get(i + 1)? {
338            0xFF => {
339                out.push(0);
340                i += 2;
341            }
342            0x00 => return Some((out, &b[i + 2..])),
343            _ => return None,
344        }
345    }
346    None
347}
348
349/// The cold-membership bloom: rows whose entries were evicted into
350/// segments insert here; the write path consults it before spending a
351/// tombstone. A false positive costs one stray tombstone entry
352/// (harmless); a false negative cannot happen, which is the property
353/// correctness rides on.
354///
355/// ```
356/// use kevy_index::ColdBloom;
357/// let mut bloom = ColdBloom::new(1_000);
358/// bloom.insert(b"user:1");
359/// assert!(bloom.contains(b"user:1"), "no false negatives");
360/// let strays = (0..1_000).filter(|i| bloom.contains(format!("other:{i}").as_bytes())).count();
361/// assert!(strays < 20, "false positives stay rare");
362/// ```
363#[derive(Debug)]
364pub struct ColdBloom {
365    bits: Vec<u64>,
366    k: u32,
367}
368
369impl ColdBloom {
370    /// Sized for ~10 bits per expected item (k=7 → ~1% false-positive
371    /// rate at capacity; degrades gracefully past it).
372    pub fn new(expected_items: usize) -> Self {
373        let words = (expected_items.max(64) * 10 / 64).next_power_of_two();
374        Self { bits: vec![0u64; words], k: 7 }
375    }
376
377    /// Record that `item` has at least one cold entry.
378    pub fn insert(&mut self, item: &[u8]) {
379        let (h1, h2) = Self::hashes(item);
380        let nbits = (self.bits.len() * 64) as u64;
381        for i in 0..self.k as u64 {
382            let bit = h1.wrapping_add(i.wrapping_mul(h2)) % nbits;
383            self.bits[(bit / 64) as usize] |= 1 << (bit % 64);
384        }
385    }
386
387    /// Whether `item` MAY have a cold entry (false positives allowed,
388    /// false negatives never).
389    pub fn contains(&self, item: &[u8]) -> bool {
390        let (h1, h2) = Self::hashes(item);
391        let nbits = (self.bits.len() * 64) as u64;
392        (0..self.k as u64).all(|i| {
393            let bit = h1.wrapping_add(i.wrapping_mul(h2)) % nbits;
394            self.bits[(bit / 64) as usize] & (1 << (bit % 64)) != 0
395        })
396    }
397
398    /// FNV-1a under two seeds — double hashing derives the k probes.
399    fn hashes(item: &[u8]) -> (u64, u64) {
400        let fnv = |seed: u64| {
401            let mut h = seed;
402            for &b in item {
403                h ^= b as u64;
404                h = h.wrapping_mul(0x100000001b3);
405            }
406            h
407        };
408        (fnv(0xcbf29ce484222325), fnv(0x84222325cbf29ce4) | 1)
409    }
410}
411
412#[cfg(test)]
413#[path = "segcold_tests.rs"]
414mod tests;
415
416impl IndexValue {
417    /// The order-preserving bytes of one value — [`crate::order_key`]'s
418    /// transform, taken from an already-coerced [`IndexValue`]. The pin
419    /// test holds the two in lockstep.
420    ///
421    /// ```
422    /// use kevy_index::IndexValue;
423    /// assert!(IndexValue::I64(-1).order_bytes() < IndexValue::I64(1).order_bytes());
424    /// ```
425    pub fn order_bytes(&self) -> Vec<u8> {
426        value_order_bytes(self)
427    }
428
429    /// The window-column value a tree entry carries, under `shape`.
430    /// `None` = the entry cannot carry one (wrong variant / short bytes) —
431    /// the caller treats the tree as having no boundary to advance.
432    ///
433    /// ```
434    /// use kevy_index::{IndexValue, WindowShape};
435    /// assert_eq!(IndexValue::I64(42).window_value(WindowShape::PlainI64), Some(42));
436    /// assert_eq!(IndexValue::I64(42).window_value(WindowShape::CompositeLed), None);
437    /// ```
438    pub fn window_value(&self, shape: WindowShape) -> Option<i64> {
439        window_value_of(self, shape)
440    }
441}