Skip to main content

kevy_index/
value.rs

1//! [`IndexValue`] — the three scalar types an index can hold, with a
2//! total order (f64 via `total_cmp`, so NaN coerce-fails upstream and
3//! never enters a segment).
4
5use crate::catalog::ValType;
6use std::cmp::Ordering;
7
8/// One indexed scalar. Ordering is total within a type; the catalog
9/// guarantees a segment only ever holds one variant.
10///
11/// Values enter through [`coerce`](Self::coerce), which applies the
12/// declared type to a field's raw bytes; a row whose field does not
13/// coerce is left out of the index.
14///
15/// ```
16/// use kevy_index::{IndexValue, ValType};
17///
18/// let age = IndexValue::coerce(ValType::I64, b" 41 ");
19/// assert_eq!(age, Some(IndexValue::I64(41)));
20/// assert_eq!(IndexValue::coerce(ValType::I64, b"forty"), None);
21/// assert_eq!(IndexValue::coerce(ValType::F64, b"NaN"), None);
22/// assert_eq!(IndexValue::parse_literal(ValType::F64, b"2.5"), Some(IndexValue::F64(2.5)));
23///
24/// assert_eq!(IndexValue::I64(3).as_f64(), 3.0);
25/// assert_eq!(IndexValue::Str(b"kyoto".to_vec()).approx_bytes(), 5);
26/// assert!(IndexValue::F64(-1.0) < IndexValue::F64(0.5));
27/// ```
28#[derive(Debug, Clone, PartialEq)]
29#[non_exhaustive]
30pub enum IndexValue {
31    /// `TYPE i64`.
32    ///
33    /// ```
34    /// use kevy_index::{IndexValue, ValType};
35    /// assert_eq!(IndexValue::coerce(ValType::I64, b"-7"), Some(IndexValue::I64(-7)));
36    /// ```
37    I64(i64),
38    /// `TYPE f64` (never NaN — coercion rejects it).
39    ///
40    /// ```
41    /// use kevy_index::{IndexValue, ValType};
42    /// assert_eq!(IndexValue::coerce(ValType::F64, b"1e3"), Some(IndexValue::F64(1000.0)));
43    /// ```
44    F64(f64),
45    /// `TYPE str` (raw bytes, memcmp order).
46    ///
47    /// ```
48    /// use kevy_index::{IndexValue, ValType};
49    /// let v = IndexValue::coerce(ValType::Str, b"Zed").unwrap();
50    /// assert_eq!(v, IndexValue::Str(b"Zed".to_vec()));
51    /// assert!(v < IndexValue::Str(b"apple".to_vec()), "bytes compare, not letters");
52    /// ```
53    Str(Vec<u8>),
54}
55
56impl Eq for IndexValue {}
57
58impl Ord for IndexValue {
59    fn cmp(&self, other: &Self) -> Ordering {
60        match (self, other) {
61            (IndexValue::I64(a), IndexValue::I64(b)) => a.cmp(b),
62            (IndexValue::F64(a), IndexValue::F64(b)) => a.total_cmp(b),
63            (IndexValue::Str(a), IndexValue::Str(b)) => a.cmp(b),
64            // Cross-variant comparison means a catalog bug; order by
65            // discriminant to stay total rather than panic in a
66            // B-tree.
67            (a, b) => disc(a).cmp(&disc(b)),
68        }
69    }
70}
71
72impl PartialOrd for IndexValue {
73    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
74        Some(self.cmp(other))
75    }
76}
77
78fn disc(v: &IndexValue) -> u8 {
79    match v {
80        IndexValue::I64(_) => 0,
81        IndexValue::F64(_) => 1,
82        IndexValue::Str(_) => 2,
83    }
84}
85
86impl IndexValue {
87    /// Coerce raw field bytes per the declared type. `None` = the row
88    /// is excluded from the index (and counted as a coerce failure).
89    pub fn coerce(ty: crate::ValType, raw: &[u8]) -> Option<IndexValue> {
90        match ty {
91            // ANN kinds never coerce through IndexValue.
92            crate::ValType::Vector => None,
93            crate::ValType::I64 => {
94                std::str::from_utf8(raw).ok()?.trim().parse::<i64>().ok().map(IndexValue::I64)
95            }
96            crate::ValType::F64 => {
97                let f = std::str::from_utf8(raw).ok()?.trim().parse::<f64>().ok()?;
98                if f.is_nan() {
99                    return None;
100                }
101                Some(IndexValue::F64(f))
102            }
103            crate::ValType::Str => Some(IndexValue::Str(raw.to_vec())),
104        }
105    }
106
107    /// Parse a query-side literal (same rules as [`Self::coerce`]).
108    pub fn parse_literal(ty: crate::ValType, raw: &[u8]) -> Option<IndexValue> {
109        Self::coerce(ty, raw)
110    }
111
112    /// Numeric view for aggregation (Str = 0.0; agg kinds only admit
113    /// numeric types at CREATE, so this arm is unreachable there).
114    pub fn as_f64(&self) -> f64 {
115        match self {
116            IndexValue::I64(v) => *v as f64,
117            IndexValue::F64(v) => *v,
118            IndexValue::Str(_) => 0.0,
119        }
120    }
121
122    /// Approximate heap bytes (for the memory formula / IDX.LIST).
123    pub fn approx_bytes(&self) -> usize {
124        match self {
125            IndexValue::I64(_) | IndexValue::F64(_) => 8,
126            IndexValue::Str(s) => s.len(),
127        }
128    }
129
130    /// The text a reply shows for this value: decimal for `i64`, Rust's
131    /// shortest round-trip form for `f64`, the raw bytes for `str`.
132    ///
133    /// ```
134    /// use kevy_index::IndexValue;
135    /// assert_eq!(IndexValue::I64(-7).render(), b"-7");
136    /// assert_eq!(IndexValue::F64(2.5).render(), b"2.5");
137    /// assert_eq!(IndexValue::Str(b"kyoto".to_vec()).render(), b"kyoto");
138    /// ```
139    pub fn render(&self) -> Vec<u8> {
140        match self {
141            IndexValue::I64(i) => i.to_string().into_bytes(),
142            IndexValue::F64(f) => format!("{f}").into_bytes(),
143            IndexValue::Str(s) => s.clone(),
144        }
145    }
146
147    /// Append the tagged binary form cursors and shard replies carry:
148    /// a tag byte (0 `i64`, 1 `f64`, 2 `str`), then the value — eight
149    /// little-endian bytes, or a `u32` little-endian length and the bytes.
150    ///
151    /// ```
152    /// use kevy_index::IndexValue;
153    /// let mut out = Vec::new();
154    /// IndexValue::I64(1).encode(&mut out);
155    /// assert_eq!(out, [0, 1, 0, 0, 0, 0, 0, 0, 0]);
156    /// ```
157    pub fn encode(&self, out: &mut Vec<u8>) {
158        match self {
159            IndexValue::I64(i) => {
160                out.push(0);
161                out.extend_from_slice(&i.to_le_bytes());
162            }
163            IndexValue::F64(f) => {
164                out.push(1);
165                out.extend_from_slice(&f.to_le_bytes());
166            }
167            IndexValue::Str(s) => {
168                out.push(2);
169                out.extend_from_slice(&(s.len() as u32).to_le_bytes());
170                out.extend_from_slice(s);
171            }
172        }
173    }
174
175    /// Read one [`encode`](Self::encode)d value at `*pos`, advancing
176    /// `pos` past it; `None` on an unknown tag or truncated bytes.
177    ///
178    /// ```
179    /// use kevy_index::IndexValue;
180    /// let mut buf = Vec::new();
181    /// IndexValue::Str(b"ab".to_vec()).encode(&mut buf);
182    /// IndexValue::F64(0.5).encode(&mut buf);
183    /// let mut pos = 0;
184    /// assert_eq!(IndexValue::decode(&buf, &mut pos), Some(IndexValue::Str(b"ab".to_vec())));
185    /// assert_eq!(IndexValue::decode(&buf, &mut pos), Some(IndexValue::F64(0.5)));
186    /// assert_eq!((pos, IndexValue::decode(&buf, &mut pos)), (buf.len(), None));
187    /// ```
188    pub fn decode(b: &[u8], pos: &mut usize) -> Option<IndexValue> {
189        let tag = *b.get(*pos)?;
190        let body = pos.checked_add(1)?;
191        let (v, end) = match tag {
192            0 => (
193                IndexValue::I64(i64::from_le_bytes(b.get(body..body + 8)?.try_into().ok()?)),
194                body + 8,
195            ),
196            1 => (
197                IndexValue::F64(f64::from_le_bytes(b.get(body..body + 8)?.try_into().ok()?)),
198                body + 8,
199            ),
200            2 => {
201                let n = u32::from_le_bytes(b.get(body..body + 4)?.try_into().ok()?) as usize;
202                let start = body + 4;
203                (IndexValue::Str(b.get(start..start.checked_add(n)?)?.to_vec()), start + n)
204            }
205            _ => return None,
206        };
207        *pos = end;
208        Some(v)
209    }
210}
211
212/// A comparison over a stored value's raw bytes, built once from the
213/// type the field was declared as.
214///
215/// `EQ` is the degenerate range `[v, v]`: stored values are totally
216/// ordered, so equality needs no second code path — and one path cannot
217/// disagree with itself about what a bound means.
218///
219/// ```
220/// use kevy_index::{ValType, ValueTest};
221///
222/// let adults = ValueTest::range(ValType::I64, b"18", b"64").unwrap();
223/// assert!(adults.passes(b"41"));
224/// assert!(!adults.passes(b"70"));
225/// assert!(!adults.passes(b"n/a"), "text in a numeric field is in no range");
226///
227/// let kyoto = ValueTest::eq(ValType::Str, b"kyoto").unwrap();
228/// assert!(kyoto.passes(b"kyoto") && !kyoto.passes(b"osaka"));
229///
230/// // query bounds may be time expressions on i64 fields
231/// let now = 1_000_000;
232/// let last_day = ValueTest::range_at(ValType::I64, b"@now-1d", b"@now", now).unwrap();
233/// assert!(last_day.passes(b"999000") && !last_day.passes(b"1"));
234/// assert!(ValueTest::eq_at(ValType::I64, b"@now", now).unwrap().passes(b"1000000"));
235///
236/// assert_eq!(ValueTest::range(ValType::I64, b"1", b"ten"), None);
237/// ```
238#[derive(Debug, Clone, PartialEq)]
239pub struct ValueTest {
240    ty: ValType,
241    lo: IndexValue,
242    hi: IndexValue,
243}
244
245/// One query BOUND's value: [`IndexValue::parse_literal`] plus the
246/// `@` time expressions on i64 fields (`@now`, `@now-7d`,
247/// a calendar literal, per [`kevy_time::eval`]) — only ever called on
248/// bound bytes, never on row data (a row whose field holds "@now" is
249/// data, not an expression, and the write path never comes here).
250/// Non-i64 fields pass through untouched, so a str field matching a
251/// literal "@…" value stays unambiguous.
252///
253/// ```
254/// use kevy_index::{IndexValue, ValType, parse_literal_bound};
255///
256/// let now = 86_400; // seconds
257/// assert_eq!(parse_literal_bound(ValType::I64, b"@now", now), Some(IndexValue::I64(now)));
258/// assert_eq!(parse_literal_bound(ValType::I64, b"@now-1d", now), Some(IndexValue::I64(0)));
259/// assert_eq!(parse_literal_bound(ValType::I64, b"12", now), Some(IndexValue::I64(12)));
260/// // on a str field "@now" is just text
261/// assert_eq!(
262///     parse_literal_bound(ValType::Str, b"@now", now),
263///     Some(IndexValue::Str(b"@now".to_vec()))
264/// );
265/// ```
266pub fn parse_literal_bound(ty: ValType, raw: &[u8], now: i64) -> Option<IndexValue> {
267    if ty == ValType::I64 && raw.first() == Some(&b'@') {
268        return kevy_time::eval(raw, now).map(IndexValue::I64);
269    }
270    IndexValue::parse_literal(ty, raw)
271}
272
273/// [`parse_literal_bound`]'s coercing sibling for FILTER bounds.
274///
275/// ```
276/// use kevy_index::{IndexValue, ValType, coerce_bound};
277///
278/// let now = 5_000;
279/// assert_eq!(coerce_bound(ValType::I64, b"@now", now), Some(IndexValue::I64(5_000)));
280/// assert_eq!(coerce_bound(ValType::F64, b" 0.25 ", now), Some(IndexValue::F64(0.25)));
281/// assert_eq!(coerce_bound(ValType::I64, b"soon", now), None);
282/// ```
283pub fn coerce_bound(ty: ValType, raw: &[u8], now: i64) -> Option<IndexValue> {
284    if ty == ValType::I64 && raw.first() == Some(&b'@') {
285        return kevy_time::eval(raw, now).map(IndexValue::I64);
286    }
287    IndexValue::coerce(ty, raw)
288}
289
290impl ValueTest {
291    /// `RANGE min max` on a field declared as `ty`. `None` when a bound
292    /// is not of that type — a bound the index cannot interpret is an
293    /// error, not an empty result.
294    pub fn range(ty: ValType, min: &[u8], max: &[u8]) -> Option<ValueTest> {
295        Some(ValueTest { ty, lo: IndexValue::coerce(ty, min)?, hi: IndexValue::coerce(ty, max)? })
296    }
297
298    /// [`ValueTest::range`] for query bounds: `@` time expressions
299    /// resolve against `now` on i64 fields.
300    pub fn range_at(ty: ValType, min: &[u8], max: &[u8], now: i64) -> Option<ValueTest> {
301        Some(ValueTest { ty, lo: coerce_bound(ty, min, now)?, hi: coerce_bound(ty, max, now)? })
302    }
303
304    /// `EQ v` on a field declared as `ty`.
305    pub fn eq(ty: ValType, v: &[u8]) -> Option<ValueTest> {
306        let v = IndexValue::coerce(ty, v)?;
307        Some(ValueTest { ty, lo: v.clone(), hi: v })
308    }
309
310    /// [`ValueTest::eq`] for query bounds: `@` time expressions
311    /// resolve against `now` on i64 fields.
312    pub fn eq_at(ty: ValType, v: &[u8], now: i64) -> Option<ValueTest> {
313        let v = coerce_bound(ty, v, now)?;
314        Some(ValueTest { ty, lo: v.clone(), hi: v })
315    }
316
317    /// Whether a stored value's bytes satisfy the test.
318    ///
319    /// A value that does not coerce fails: text sitting in a field
320    /// declared numeric is not inside any numeric range, and passing it
321    /// would be the accept-and-ignore shape this surface keeps refusing.
322    pub fn passes(&self, raw: &[u8]) -> bool {
323        IndexValue::coerce(self.ty, raw).is_some_and(|v| v >= self.lo && v <= self.hi)
324    }
325}
326
327/// An order-preserving byte encoding of a coerced value.
328///
329/// Two values' encodings compare with `memcmp` exactly as the values
330/// themselves compare. That is what lets `kevy-text` sort by a stored
331/// value without learning what a number is: the caller encodes once per
332/// candidate, and the segment compares bytes.
333///
334/// `None` when the raw bytes are not of that type — a document whose
335/// stored value does not coerce has no place in the order, and is sorted
336/// as missing rather than guessed at.
337///
338/// ```
339/// use kevy_index::{ValType, order_key};
340///
341/// let neg = order_key(ValType::I64, b"-5").unwrap();
342/// let pos = order_key(ValType::I64, b"3").unwrap();
343/// assert!(neg < pos, "memcmp order matches numeric order");
344///
345/// let small = order_key(ValType::F64, b"-0.5").unwrap();
346/// let big = order_key(ValType::F64, b"10").unwrap();
347/// assert!(small < big);
348///
349/// assert_eq!(order_key(ValType::Str, b"abc"), Some(b"abc".to_vec()));
350/// assert_eq!(order_key(ValType::I64, b"abc"), None);
351/// ```
352pub fn order_key(ty: ValType, raw: &[u8]) -> Option<Vec<u8>> {
353    match IndexValue::coerce(ty, raw)? {
354        // Bytes already compare as themselves.
355        IndexValue::Str(v) => Some(v),
356        // Flip the sign bit: two's complement negatives have the high bit
357        // set and would otherwise sort above every positive.
358        IndexValue::I64(v) => Some(((v as u64) ^ (1 << 63)).to_be_bytes().to_vec()),
359        // The standard IEEE total-order transform. A negative float's
360        // magnitude grows with its bit pattern, so inverting every bit
361        // reverses that and drops it below the positives (whose sign bit
362        // is set instead). Coercion rejects NaN, so there is none to
363        // place.
364        IndexValue::F64(v) => {
365            let b = v.to_bits();
366            let m = if b >> 63 == 1 { !b } else { b | (1 << 63) };
367            Some(m.to_be_bytes().to_vec())
368        }
369    }
370}
371
372#[cfg(test)]
373#[path = "value_codec_tests.rs"]
374mod codec_tests;
375
376#[cfg(test)]
377mod order_key_tests {
378    use super::*;
379
380    /// The encoding must agree with `IndexValue`'s own order on every
381    /// pair — including across zero, which is where a naive big-endian
382    /// encoding of a signed number gets it backwards.
383    fn agrees(ty: ValType, raws: &[&str]) {
384        let mut vals: Vec<(IndexValue, Vec<u8>)> = raws
385            .iter()
386            .map(|r| {
387                (
388                    IndexValue::coerce(ty, r.as_bytes()).expect("coerces"),
389                    order_key(ty, r.as_bytes()).expect("encodes"),
390                )
391            })
392            .collect();
393        vals.sort_by(|a, b| a.1.cmp(&b.1));
394        for w in vals.windows(2) {
395            assert!(w[0].0 <= w[1].0, "{:?} then {:?} for {ty:?}", w[0].0, w[1].0);
396        }
397    }
398
399    #[test]
400    fn byte_order_matches_value_order() {
401        agrees(
402            ValType::I64,
403            &["-9223372036854775808", "-5", "-1", "0", "1", "5", "9223372036854775807"],
404        );
405        agrees(ValType::F64, &["-1e308", "-1.5", "-0.5", "0", "0.5", "1.5", "1e308"]);
406        agrees(ValType::Str, &["", "a", "ab", "b", "z"]);
407    }
408
409    #[test]
410    fn a_value_that_does_not_coerce_has_no_key() {
411        assert!(order_key(ValType::I64, b"cheap").is_none());
412        assert!(order_key(ValType::F64, b"").is_none());
413        assert_eq!(order_key(ValType::Str, b"anything"), Some(b"anything".to_vec()));
414    }
415}