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}