Skip to main content

kevy_index/
catalog.rs

1//! [`Catalog`] — the index registry: declarations, states, and the
2//! compiled prefix matcher the write-path hook consults.
3
4use crate::value::IndexValue;
5
6/// Declared scalar type of an index (`TYPE i64|f64|str`).
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum ValType {
9    /// f32 LE vector blob (ANN kinds parse the field
10    /// themselves — never coerced through IndexValue).
11    Vector,
12    /// Signed 64-bit integer.
13    I64,
14    /// Finite 64-bit float (NaN coerce-fails).
15    F64,
16    /// Raw bytes, memcmp order.
17    Str,
18}
19
20impl ValType {
21    /// Wire tag (catalog sidecar + IDX.LIST).
22    pub fn tag(self) -> &'static str {
23        match self {
24            ValType::I64 => "i64",
25            ValType::F64 => "f64",
26            ValType::Str => "str",
27            ValType::Vector => "vector",
28        }
29    }
30
31    /// Parse a wire tag.
32    pub fn parse(raw: &[u8]) -> Option<ValType> {
33        if raw.eq_ignore_ascii_case(b"i64") {
34            Some(ValType::I64)
35        } else if raw.eq_ignore_ascii_case(b"f64") {
36            Some(ValType::F64)
37        } else if raw.eq_ignore_ascii_case(b"str") {
38            Some(ValType::Str)
39        } else if raw.eq_ignore_ascii_case(b"vector") {
40            Some(ValType::Vector)
41        } else {
42            None
43        }
44    }
45}
46
47/// Index kind (`KIND range|unique`).
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum IndexKind {
50    /// Ordered scan over `(value, key)` pairs.
51    Range,
52    /// Point lookup by value; duplicates recorded (declarative fence:
53    /// uniqueness is verified, not write-enforced).
54    Unique,
55    /// Full-text: the field tokenizes into an inverted segment
56    /// (kevy-text); queried with `MATCH`, BM25-ranked.
57    Text,
58    /// ANN: the field holds an f32 LE vector indexed in an HNSW
59    /// graph (kevy-vector); queried with `KNN`, distance-ranked.
60    Ann,
61    /// Aggregate: per-group count/sum/min/max of the field,
62    /// grouped by `IndexSpec::group_by`; queried with `GROUP`/`GROUPS`.
63    Agg,
64}
65
66impl IndexKind {
67    /// Wire tag.
68    pub fn tag(self) -> &'static str {
69        match self {
70            IndexKind::Range => "range",
71            IndexKind::Unique => "unique",
72            IndexKind::Text => "text",
73            IndexKind::Ann => "ann",
74            IndexKind::Agg => "agg",
75        }
76    }
77
78    /// Parse a wire tag.
79    pub fn parse(raw: &[u8]) -> Option<IndexKind> {
80        if raw.eq_ignore_ascii_case(b"range") {
81            Some(IndexKind::Range)
82        } else if raw.eq_ignore_ascii_case(b"unique") {
83            Some(IndexKind::Unique)
84        } else if raw.eq_ignore_ascii_case(b"text") {
85            Some(IndexKind::Text)
86        } else if raw.eq_ignore_ascii_case(b"ann") {
87            Some(IndexKind::Ann)
88        } else if raw.eq_ignore_ascii_case(b"agg") {
89            Some(IndexKind::Agg)
90        } else {
91            None
92        }
93    }
94}
95
96/// Lifecycle state.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum IndexState {
99    /// Backfill in progress; queries answer `-INDEXBUILDING`.
100    Building,
101    /// Serving.
102    Ready,
103    /// Build aborted over budget; queries answer an error.
104    FailedOverBudget,
105}
106
107/// One indexed attribute of a document.
108///
109/// `weight` scales this field's contribution to the BM25 score, so a hit
110/// in a title can outrank one in a body. Weighting per field is exactly
111/// what a per-field index cannot express: BM25 normalises by document
112/// length, so separate indexes normalise over separate corpora and their
113/// scores are not comparable. That is why multi-attribute is a struct
114/// change rather than something a caller can assemble from several
115/// single-field indexes.
116#[derive(Debug, Clone, PartialEq)]
117pub struct FieldSpec {
118    /// Hash field name.
119    pub name: Vec<u8>,
120    /// BM25 weight; 1.0 is neutral.
121    pub weight: f32,
122}
123
124impl FieldSpec {
125    /// A neutrally-weighted field.
126    pub fn new(name: impl Into<Vec<u8>>) -> FieldSpec {
127        FieldSpec { name: name.into(), weight: 1.0 }
128    }
129}
130
131/// One stored value field: which hash field it reads, and how its bytes
132/// compare.
133///
134/// The type is declared, not guessed per query. A numeric range compared
135/// lexicographically is silently wrong — `"9"` sorts above `"10"` — and
136/// deciding it by whether both sides happen to parse as a number would
137/// make the answer depend on the data. Declaring it also means `SORT` and
138/// `FACET` inherit an order and an identity rather than re-deciding one.
139#[derive(Debug, Clone, PartialEq)]
140pub struct ValueSpec {
141    /// Hash field name.
142    pub name: Vec<u8>,
143    /// How the stored bytes compare.
144    pub ty: ValType,
145}
146
147impl ValueSpec {
148    /// A value field compared as text — the default when no type is
149    /// declared for it.
150    pub fn new(name: impl Into<Vec<u8>>) -> ValueSpec {
151        ValueSpec { name: name.into(), ty: ValType::Str }
152    }
153}
154
155// `ValueTest` (the stored-value comparison) lives in `value.rs` with
156// the rest of the coercion/order logic; re-exported unchanged.
157
158/// One declared index.
159#[derive(Debug, Clone, PartialEq)]
160pub struct IndexSpec {
161    /// Unique catalog name.
162    pub name: Vec<u8>,
163    /// Key-prefix domain (`ON PREFIX user:`).
164    pub prefix: Vec<u8>,
165    /// Hash fields the value comes from, in declaration order.
166    ///
167    /// No single-field twin is kept alongside this: two sources of truth
168    /// for "which field" is the shape that drifts. Single-field indexes
169    /// are the one-element case, read through [`IndexSpec::field`].
170    pub fields: Vec<FieldSpec>,
171    /// Declared scalar type.
172    pub ty: ValType,
173    /// Range or unique.
174    pub kind: IndexKind,
175    /// Optional per-index byte budget (`MAXMEM`); 0 = unlimited.
176    pub max_bytes: u64,
177    /// ANN parameters (`Some` iff kind == Ann).
178    pub ann: Option<AnnSpec>,
179    /// Grouping field (`Some` iff kind == Agg).
180    pub group_by: Option<Vec<u8>>,
181    /// Record token positions (`WITH POSITIONS`, kind == Text only), so
182    /// phrase / proximity / highlight queries can verify adjacency. Off
183    /// by default: a corpus that never runs a phrase query does not pay
184    /// the positional side-channel's memory.
185    pub with_positions: bool,
186    /// Hash fields stored per document (`VALUES`, kind == Text only), so
187    /// the clauses that read a document's own value — `FILTER` and, in
188    /// time, `SORT` / `DISTINCT` / `FACET` — have something to read.
189    /// Empty by default: an index that never filters does not pay for
190    /// the stored column.
191    pub values: Vec<ValueSpec>,
192    /// Composite columns (`Some` = an ORDERPATH-compiled index): the
193    /// index value is the order-preserving concatenation of these
194    /// columns' encodings (see [`crate::composite`]). Legal ONLY on
195    /// `KIND range` with `TYPE str`; `None` = every existing index,
196    /// byte-identical in memory and on the sidecar (A5).
197    pub composite: Option<Vec<crate::composite::CompositeCol>>,
198}
199
200/// HNSW declaration (immutable once created).
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub struct AnnSpec {
203    /// Vector dimensionality (field bytes must be dim×4 f32 LE).
204    pub dim: u32,
205    /// 0=cosine 1=l2 2=ip (kevy-vector's Distance tags).
206    pub distance: u8,
207    /// Max links per node per layer.
208    pub m: u16,
209    /// Construction beam width.
210    pub ef: u16,
211}
212
213/// Hard cap on declared indexes.
214pub const MAX_INDEXES: usize = 64;
215
216/// The registry. The runtime holds one per process behind an RCU-style
217/// swap; shards read their clone lock-free.
218#[derive(Debug, Clone, Default)]
219pub struct Catalog {
220    pub(crate) specs: Vec<(IndexSpec, IndexState)>,
221}
222
223/// What one row looks like to an index: each declared field's raw bytes
224/// with its BM25 weight, and each declared `VALUES` field's raw bytes
225/// (`None` where the row has none).
226pub type RowInputs = (Vec<(Vec<u8>, f32)>, Vec<Option<Vec<u8>>>);
227
228impl IndexSpec {
229    /// The primary field — the first declared one. Every kind except
230    /// text indexes exactly one attribute; text is the kind that reads
231    /// What this index reads out of one row: each declared field's raw
232    /// bytes with its BM25 weight, and each declared `VALUES` field's raw
233    /// bytes (`None` where the row has none).
234    ///
235    /// `get` fetches a hash field, so this stays free of any storage
236    /// dependency while keeping the answer in one place — the server and
237    /// the embedded store index the same row the same way by
238    /// construction, rather than by two copies of the same loop agreeing.
239    pub fn read_row(&self, mut get: impl FnMut(&[u8]) -> Option<Vec<u8>>) -> RowInputs {
240        let mut fields = Vec::with_capacity(self.fields.len());
241        for f in &self.fields {
242            if let Some(raw) = get(&f.name) {
243                fields.push((raw, f.weight));
244            }
245        }
246        let values = self.values.iter().map(|v| get(&v.name)).collect();
247        (fields, values)
248    }
249
250    /// [`IndexSpec::fields`] in full.
251    pub fn field(&self) -> &[u8] {
252        self.fields.first().map_or(&[][..], |f| f.name.as_slice())
253    }
254
255    /// Declare a single-field index — the shape every kind but text uses.
256    pub fn single_field(
257        name: Vec<u8>,
258        prefix: Vec<u8>,
259        field: Vec<u8>,
260        ty: ValType,
261        kind: IndexKind,
262    ) -> IndexSpec {
263        IndexSpec {
264            name,
265            prefix,
266            fields: vec![FieldSpec::new(field)],
267            ty,
268            kind,
269            max_bytes: 0,
270            ann: None,
271            group_by: None,
272            with_positions: false,
273            values: Vec::new(),
274            composite: None,
275        }
276    }
277}
278
279impl Catalog {
280    /// Empty catalog.
281    pub fn new() -> Self {
282        Self::default()
283    }
284
285    /// Register a new index. Errors on duplicate name / cap.
286    pub fn create(&mut self, spec: IndexSpec) -> Result<(), &'static str> {
287        if self.specs.len() >= MAX_INDEXES {
288            return Err("ERR index limit reached (64)");
289        }
290        if self.specs.iter().any(|(s, _)| s.name == spec.name) {
291            return Err("ERR index already exists");
292        }
293        if spec.fields.is_empty() {
294            return Err("ERR index needs at least one field");
295        }
296        // Multi-field is served by the text engine only. Every other
297        // kind reads one scalar, so a second field on a range or unique
298        // index would be declared and never consulted -- the
299        // accept-and-ignore shape this arc keeps refusing.
300        if spec.fields.len() > 1 && spec.kind != IndexKind::Text {
301            return Err("ERR only KIND text indexes several fields");
302        }
303        // Positions are a text-only capability: phrase / proximity /
304        // highlight all read the positional side-channel, which no other
305        // kind maintains, so accepting the flag elsewhere would be the
306        // accept-and-ignore shape this arc keeps refusing.
307        if spec.with_positions && spec.kind != IndexKind::Text {
308            return Err("ERR WITH POSITIONS requires KIND text");
309        }
310        // VALUES rides the kinds that carry a stored-value column: the
311        // text segment and the scalar segments (range / unique — the
312        // capacity arc's G1 generalization). Ann and agg carry none, so
313        // accepting the declaration there would store nothing and
314        // filter on nothing.
315        if !spec.values.is_empty()
316            && !matches!(spec.kind, IndexKind::Text | IndexKind::Range | IndexKind::Unique)
317        {
318            return Err("ERR VALUES requires KIND text|range|unique");
319        }
320        // Composite (ORDERPATH-compiled) combos: named refusals, body
321        // in `composite.rs` beside the encoding it protects.
322        crate::composite::composite_guard(&spec)?;
323        self.specs.push((spec, IndexState::Building));
324        Ok(())
325    }
326
327    /// Drop by name; `false` if absent.
328    pub fn drop_index(&mut self, name: &[u8]) -> bool {
329        let before = self.specs.len();
330        self.specs.retain(|(s, _)| s.name != name);
331        self.specs.len() != before
332    }
333
334    /// Set an index's lifecycle state; `false` if absent.
335    pub fn set_state(&mut self, name: &[u8], state: IndexState) -> bool {
336        for (s, st) in &mut self.specs {
337            if s.name == name {
338                *st = state;
339                return true;
340            }
341        }
342        false
343    }
344
345    /// Look up by name.
346    pub fn get(&self, name: &[u8]) -> Option<(&IndexSpec, IndexState)> {
347        self.specs.iter().find(|(s, _)| s.name == name).map(|(s, st)| (s, *st))
348    }
349
350    /// All specs with states, declaration order.
351    pub fn iter(&self) -> impl Iterator<Item = (&IndexSpec, IndexState)> {
352        self.specs.iter().map(|(s, st)| (s, *st))
353    }
354
355    /// Number of declared indexes.
356    pub fn len(&self) -> usize {
357        self.specs.len()
358    }
359
360    /// Whether no indexes are declared (the write hook's fast path).
361    pub fn is_empty(&self) -> bool {
362        self.specs.is_empty()
363    }
364
365    /// The write-path matcher: indexes whose prefix domain contains
366    /// `key`. Linear over ≤64 specs with a memcmp each — the compiled
367    /// trie of the RFC becomes worthwhile only past this cap, so the
368    /// simple form IS the fast form at our scale.
369    pub fn matching<'a>(
370        &'a self,
371        key: &'a [u8],
372    ) -> impl Iterator<Item = (&'a IndexSpec, IndexState)> {
373        self.specs.iter().filter(move |(s, _)| key.starts_with(&s.prefix)).map(|(s, st)| (s, *st))
374    }
375
376    /// Coerce a raw field value for `spec` (convenience passthrough).
377    pub fn coerce(spec: &IndexSpec, raw: &[u8]) -> Option<IndexValue> {
378        IndexValue::coerce(spec.ty, raw)
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    fn spec(name: &str, prefix: &str) -> IndexSpec {
387        IndexSpec {
388            name: name.into(),
389            prefix: prefix.into(),
390            fields: vec![FieldSpec::new(b"age".to_vec())],
391            ty: ValType::I64,
392            kind: IndexKind::Range,
393            ann: None,
394            max_bytes: 0,
395            group_by: None,
396            with_positions: false,
397            values: Vec::new(),
398            composite: None,
399        }
400    }
401
402    #[test]
403    fn create_drop_match_lifecycle() {
404        let mut c = Catalog::new();
405        c.create(spec("a", "user:")).unwrap();
406        c.create(spec("b", "sess:")).unwrap();
407        assert!(c.create(spec("a", "x:")).is_err(), "dup name");
408        assert_eq!(c.matching(b"user:42").count(), 1);
409        assert_eq!(c.matching(b"other:1").count(), 0);
410        assert_eq!(c.get(b"a").unwrap().1, IndexState::Building);
411        assert!(c.set_state(b"a", IndexState::Ready));
412        assert_eq!(c.get(b"a").unwrap().1, IndexState::Ready);
413        assert!(c.drop_index(b"b"));
414        assert!(!c.drop_index(b"b"));
415        assert_eq!(c.len(), 1);
416    }
417
418    #[test]
419    fn sidecar_roundtrip_with_escapes() {
420        let mut c = Catalog::new();
421        let mut s = spec("weird", "pre\tfix:");
422        s.fields = vec![FieldSpec::new(b"f%\n".to_vec())];
423        s.max_bytes = 1024;
424        c.create(s).unwrap();
425        let text = c.to_sidecar();
426        let c2 = Catalog::from_sidecar(&text).unwrap();
427        let (got, st) = c2.get(b"weird").unwrap();
428        assert_eq!(got.prefix, b"pre\tfix:".to_vec());
429        assert_eq!(got.field(), b"f%\n");
430        assert_eq!(got.max_bytes, 1024);
431        assert_eq!(st, IndexState::Building, "boot loads as Building");
432        assert!(Catalog::from_sidecar("bogus").is_none());
433    }
434
435    /// Text serves several fields; every other kind reads one scalar,
436    /// so a second field there would be declared and never consulted.
437    /// Accept-and-ignore is the shape this refuses.
438    #[test]
439    fn only_text_indexes_accept_several_fields() {
440        let two = || vec![FieldSpec::new(b"title".to_vec()), FieldSpec::new(b"body".to_vec())];
441        let mut range = spec("multi-range", "p:");
442        range.fields = two();
443        assert!(Catalog::new().create(range).is_err(), "range must refuse two fields");
444
445        let mut text = spec("multi-text", "p:");
446        text.kind = IndexKind::Text;
447        text.fields = two();
448        assert!(Catalog::new().create(text).is_ok(), "text must accept them");
449    }
450
451    #[test]
452    fn an_index_needs_at_least_one_field() {
453        let mut s = spec("nofields", "p:");
454        s.fields.clear();
455        assert!(Catalog::new().create(s).is_err());
456    }
457
458    #[test]
459    fn cap_enforced() {
460        let mut c = Catalog::new();
461        for i in 0..MAX_INDEXES {
462            c.create(spec(&format!("i{i}"), "p:")).unwrap();
463        }
464        assert!(c.create(spec("over", "p:")).is_err());
465    }
466}