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#[derive(Debug, Clone, PartialEq)]
11pub enum IndexValue {
12    /// `TYPE i64`.
13    I64(i64),
14    /// `TYPE f64` (never NaN — coercion rejects it).
15    F64(f64),
16    /// `TYPE str` (raw bytes, memcmp order).
17    Str(Vec<u8>),
18}
19
20impl Eq for IndexValue {}
21
22impl Ord for IndexValue {
23    fn cmp(&self, other: &Self) -> Ordering {
24        match (self, other) {
25            (IndexValue::I64(a), IndexValue::I64(b)) => a.cmp(b),
26            (IndexValue::F64(a), IndexValue::F64(b)) => a.total_cmp(b),
27            (IndexValue::Str(a), IndexValue::Str(b)) => a.cmp(b),
28            // Cross-variant comparison means a catalog bug; order by
29            // discriminant to stay total rather than panic in a
30            // B-tree.
31            (a, b) => disc(a).cmp(&disc(b)),
32        }
33    }
34}
35
36impl PartialOrd for IndexValue {
37    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
38        Some(self.cmp(other))
39    }
40}
41
42fn disc(v: &IndexValue) -> u8 {
43    match v {
44        IndexValue::I64(_) => 0,
45        IndexValue::F64(_) => 1,
46        IndexValue::Str(_) => 2,
47    }
48}
49
50impl IndexValue {
51    /// Coerce raw field bytes per the declared type. `None` = the row
52    /// is excluded from the index (and counted as a coerce failure).
53    pub fn coerce(ty: crate::ValType, raw: &[u8]) -> Option<IndexValue> {
54        match ty {
55            // ANN kinds never coerce through IndexValue.
56            crate::ValType::Vector => None,
57            crate::ValType::I64 => {
58                std::str::from_utf8(raw).ok()?.trim().parse::<i64>().ok().map(IndexValue::I64)
59            }
60            crate::ValType::F64 => {
61                let f = std::str::from_utf8(raw).ok()?.trim().parse::<f64>().ok()?;
62                if f.is_nan() {
63                    return None;
64                }
65                Some(IndexValue::F64(f))
66            }
67            crate::ValType::Str => Some(IndexValue::Str(raw.to_vec())),
68        }
69    }
70
71    /// Parse a query-side literal (same rules as [`Self::coerce`]).
72    pub fn parse_literal(ty: crate::ValType, raw: &[u8]) -> Option<IndexValue> {
73        Self::coerce(ty, raw)
74    }
75
76    /// Numeric view for aggregation (Str = 0.0; agg kinds only admit
77    /// numeric types at CREATE, so this arm is unreachable there).
78    pub fn as_f64(&self) -> f64 {
79        match self {
80            IndexValue::I64(v) => *v as f64,
81            IndexValue::F64(v) => *v,
82            IndexValue::Str(_) => 0.0,
83        }
84    }
85
86    /// Approximate heap bytes (for the memory formula / IDX.LIST).
87    pub fn approx_bytes(&self) -> usize {
88        match self {
89            IndexValue::I64(_) | IndexValue::F64(_) => 8,
90            IndexValue::Str(s) => s.len(),
91        }
92    }
93}
94
95/// A comparison over a stored value's raw bytes, built once from the
96/// type the field was declared as.
97///
98/// `EQ` is the degenerate range `[v, v]`: stored values are totally
99/// ordered, so equality needs no second code path — and one path cannot
100/// disagree with itself about what a bound means.
101#[derive(Debug, Clone, PartialEq)]
102pub struct ValueTest {
103    ty: ValType,
104    lo: IndexValue,
105    hi: IndexValue,
106}
107
108/// One query BOUND's value: [`IndexValue::parse_literal`] plus the
109/// `@` time expressions on i64 fields (`@now`, `@now-7d`,
110/// a calendar literal, per [`kevy_time::eval`]) — only ever called on
111/// bound bytes, never on row data (a row whose field holds "@now" is
112/// data, not an expression, and the write path never comes here).
113/// Non-i64 fields pass through untouched, so a str field matching a
114/// literal "@…" value stays unambiguous.
115pub fn parse_literal_bound(ty: ValType, raw: &[u8], now: i64) -> Option<IndexValue> {
116    if ty == ValType::I64 && raw.first() == Some(&b'@') {
117        return kevy_time::eval(raw, now).map(IndexValue::I64);
118    }
119    IndexValue::parse_literal(ty, raw)
120}
121
122/// [`parse_literal_bound`]'s coercing sibling for FILTER bounds.
123pub fn coerce_bound(ty: ValType, raw: &[u8], now: i64) -> Option<IndexValue> {
124    if ty == ValType::I64 && raw.first() == Some(&b'@') {
125        return kevy_time::eval(raw, now).map(IndexValue::I64);
126    }
127    IndexValue::coerce(ty, raw)
128}
129
130impl ValueTest {
131    /// `RANGE min max` on a field declared as `ty`. `None` when a bound
132    /// is not of that type — a bound the index cannot interpret is an
133    /// error, not an empty result.
134    pub fn range(ty: ValType, min: &[u8], max: &[u8]) -> Option<ValueTest> {
135        Some(ValueTest { ty, lo: IndexValue::coerce(ty, min)?, hi: IndexValue::coerce(ty, max)? })
136    }
137
138    /// [`ValueTest::range`] for query bounds: `@` time expressions
139    /// resolve against `now` on i64 fields.
140    pub fn range_at(ty: ValType, min: &[u8], max: &[u8], now: i64) -> Option<ValueTest> {
141        Some(ValueTest { ty, lo: coerce_bound(ty, min, now)?, hi: coerce_bound(ty, max, now)? })
142    }
143
144    /// `EQ v` on a field declared as `ty`.
145    pub fn eq(ty: ValType, v: &[u8]) -> Option<ValueTest> {
146        let v = IndexValue::coerce(ty, v)?;
147        Some(ValueTest { ty, lo: v.clone(), hi: v })
148    }
149
150    /// [`ValueTest::eq`] for query bounds: `@` time expressions
151    /// resolve against `now` on i64 fields.
152    pub fn eq_at(ty: ValType, v: &[u8], now: i64) -> Option<ValueTest> {
153        let v = coerce_bound(ty, v, now)?;
154        Some(ValueTest { ty, lo: v.clone(), hi: v })
155    }
156
157    /// Whether a stored value's bytes satisfy the test.
158    ///
159    /// A value that does not coerce fails: text sitting in a field
160    /// declared numeric is not inside any numeric range, and passing it
161    /// would be the accept-and-ignore shape this surface keeps refusing.
162    pub fn passes(&self, raw: &[u8]) -> bool {
163        IndexValue::coerce(self.ty, raw).is_some_and(|v| v >= self.lo && v <= self.hi)
164    }
165}
166
167/// An order-preserving byte encoding of a coerced value.
168///
169/// Two values' encodings compare with `memcmp` exactly as the values
170/// themselves compare. That is what lets `kevy-text` sort by a stored
171/// value without learning what a number is: the caller encodes once per
172/// candidate, and the segment compares bytes.
173///
174/// `None` when the raw bytes are not of that type — a document whose
175/// stored value does not coerce has no place in the order, and is sorted
176/// as missing rather than guessed at.
177pub fn order_key(ty: ValType, raw: &[u8]) -> Option<Vec<u8>> {
178    match IndexValue::coerce(ty, raw)? {
179        // Bytes already compare as themselves.
180        IndexValue::Str(v) => Some(v),
181        // Flip the sign bit: two's complement negatives have the high bit
182        // set and would otherwise sort above every positive.
183        IndexValue::I64(v) => Some(((v as u64) ^ (1 << 63)).to_be_bytes().to_vec()),
184        // The standard IEEE total-order transform. A negative float's
185        // magnitude grows with its bit pattern, so inverting every bit
186        // reverses that and drops it below the positives (whose sign bit
187        // is set instead). Coercion rejects NaN, so there is none to
188        // place.
189        IndexValue::F64(v) => {
190            let b = v.to_bits();
191            let m = if b >> 63 == 1 { !b } else { b | (1 << 63) };
192            Some(m.to_be_bytes().to_vec())
193        }
194    }
195}
196
197#[cfg(test)]
198mod order_key_tests {
199    use super::*;
200
201    /// The encoding must agree with `IndexValue`'s own order on every
202    /// pair — including across zero, which is where a naive big-endian
203    /// encoding of a signed number gets it backwards.
204    fn agrees(ty: ValType, raws: &[&str]) {
205        let mut vals: Vec<(IndexValue, Vec<u8>)> = raws
206            .iter()
207            .map(|r| {
208                (
209                    IndexValue::coerce(ty, r.as_bytes()).expect("coerces"),
210                    order_key(ty, r.as_bytes()).expect("encodes"),
211                )
212            })
213            .collect();
214        vals.sort_by(|a, b| a.1.cmp(&b.1));
215        for w in vals.windows(2) {
216            assert!(w[0].0 <= w[1].0, "{:?} then {:?} for {ty:?}", w[0].0, w[1].0);
217        }
218    }
219
220    #[test]
221    fn byte_order_matches_value_order() {
222        agrees(
223            ValType::I64,
224            &["-9223372036854775808", "-5", "-1", "0", "1", "5", "9223372036854775807"],
225        );
226        agrees(ValType::F64, &["-1e308", "-1.5", "-0.5", "0", "0.5", "1.5", "1e308"]);
227        agrees(ValType::Str, &["", "a", "ab", "b", "z"]);
228    }
229
230    #[test]
231    fn a_value_that_does_not_coerce_has_no_key() {
232        assert!(order_key(ValType::I64, b"cheap").is_none());
233        assert!(order_key(ValType::F64, b"").is_none());
234        assert_eq!(order_key(ValType::Str, b"anything"), Some(b"anything".to_vec()));
235    }
236}