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(
240        &self,
241        mut get: impl FnMut(&[u8]) -> Option<Vec<u8>>,
242    ) -> RowInputs {
243        let mut fields = Vec::with_capacity(self.fields.len());
244        for f in &self.fields {
245            if let Some(raw) = get(&f.name) {
246                fields.push((raw, f.weight));
247            }
248        }
249        let values = self.values.iter().map(|v| get(&v.name)).collect();
250        (fields, values)
251    }
252
253    /// [`IndexSpec::fields`] in full.
254    pub fn field(&self) -> &[u8] {
255        self.fields.first().map_or(&[][..], |f| f.name.as_slice())
256    }
257
258    /// Declare a single-field index — the shape every kind but text uses.
259    pub fn single_field(
260        name: Vec<u8>,
261        prefix: Vec<u8>,
262        field: Vec<u8>,
263        ty: ValType,
264        kind: IndexKind,
265    ) -> IndexSpec {
266        IndexSpec {
267            name,
268            prefix,
269            fields: vec![FieldSpec::new(field)],
270            ty,
271            kind,
272            max_bytes: 0,
273            ann: None,
274            group_by: None,
275            with_positions: false,
276            values: Vec::new(),
277            composite: None,
278        }
279    }
280}
281
282impl Catalog {
283    /// Empty catalog.
284    pub fn new() -> Self {
285        Self::default()
286    }
287
288    /// Register a new index. Errors on duplicate name / cap.
289    pub fn create(&mut self, spec: IndexSpec) -> Result<(), &'static str> {
290        if self.specs.len() >= MAX_INDEXES {
291            return Err("ERR index limit reached (64)");
292        }
293        if self.specs.iter().any(|(s, _)| s.name == spec.name) {
294            return Err("ERR index already exists");
295        }
296        if spec.fields.is_empty() {
297            return Err("ERR index needs at least one field");
298        }
299        // Multi-field is served by the text engine only. Every other
300        // kind reads one scalar, so a second field on a range or unique
301        // index would be declared and never consulted -- the
302        // accept-and-ignore shape this arc keeps refusing.
303        if spec.fields.len() > 1 && spec.kind != IndexKind::Text {
304            return Err("ERR only KIND text indexes several fields");
305        }
306        // Positions are a text-only capability: phrase / proximity /
307        // highlight all read the positional side-channel, which no other
308        // kind maintains, so accepting the flag elsewhere would be the
309        // accept-and-ignore shape this arc keeps refusing.
310        if spec.with_positions && spec.kind != IndexKind::Text {
311            return Err("ERR WITH POSITIONS requires KIND text");
312        }
313        // VALUES rides the kinds that carry a stored-value column: the
314        // text segment and the scalar segments (range / unique — the
315        // capacity arc's G1 generalization). Ann and agg carry none, so
316        // accepting the declaration there would store nothing and
317        // filter on nothing.
318        if !spec.values.is_empty()
319            && !matches!(spec.kind, IndexKind::Text | IndexKind::Range | IndexKind::Unique)
320        {
321            return Err("ERR VALUES requires KIND text|range|unique");
322        }
323        // Composite (ORDERPATH-compiled) combos: named refusals, body
324        // in `composite.rs` beside the encoding it protects.
325        crate::composite::composite_guard(&spec)?;
326        self.specs.push((spec, IndexState::Building));
327        Ok(())
328    }
329
330    /// Drop by name; `false` if absent.
331    pub fn drop_index(&mut self, name: &[u8]) -> bool {
332        let before = self.specs.len();
333        self.specs.retain(|(s, _)| s.name != name);
334        self.specs.len() != before
335    }
336
337    /// Set an index's lifecycle state; `false` if absent.
338    pub fn set_state(&mut self, name: &[u8], state: IndexState) -> bool {
339        for (s, st) in &mut self.specs {
340            if s.name == name {
341                *st = state;
342                return true;
343            }
344        }
345        false
346    }
347
348    /// Look up by name.
349    pub fn get(&self, name: &[u8]) -> Option<(&IndexSpec, IndexState)> {
350        self.specs.iter().find(|(s, _)| s.name == name).map(|(s, st)| (s, *st))
351    }
352
353    /// All specs with states, declaration order.
354    pub fn iter(&self) -> impl Iterator<Item = (&IndexSpec, IndexState)> {
355        self.specs.iter().map(|(s, st)| (s, *st))
356    }
357
358    /// Number of declared indexes.
359    pub fn len(&self) -> usize {
360        self.specs.len()
361    }
362
363    /// Whether no indexes are declared (the write hook's fast path).
364    pub fn is_empty(&self) -> bool {
365        self.specs.is_empty()
366    }
367
368    /// The write-path matcher: indexes whose prefix domain contains
369    /// `key`. Linear over ≤64 specs with a memcmp each — the compiled
370    /// trie of the RFC becomes worthwhile only past this cap, so the
371    /// simple form IS the fast form at our scale.
372    pub fn matching<'a>(&'a self, key: &'a [u8]) -> impl Iterator<Item = (&'a IndexSpec, IndexState)> {
373        self.specs
374            .iter()
375            .filter(move |(s, _)| key.starts_with(&s.prefix))
376            .map(|(s, st)| (s, *st))
377    }
378
379    /// Coerce a raw field value for `spec` (convenience passthrough).
380    pub fn coerce(spec: &IndexSpec, raw: &[u8]) -> Option<IndexValue> {
381        IndexValue::coerce(spec.ty, raw)
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    fn spec(name: &str, prefix: &str) -> IndexSpec {
390        IndexSpec {
391            name: name.into(),
392            prefix: prefix.into(),
393            fields: vec![FieldSpec::new(b"age".to_vec())],
394            ty: ValType::I64,
395            kind: IndexKind::Range,
396            ann: None,
397            max_bytes: 0,
398            group_by: None,
399            with_positions: false,
400            values: Vec::new(),
401            composite: None,
402        }
403    }
404
405    #[test]
406    fn create_drop_match_lifecycle() {
407        let mut c = Catalog::new();
408        c.create(spec("a", "user:")).unwrap();
409        c.create(spec("b", "sess:")).unwrap();
410        assert!(c.create(spec("a", "x:")).is_err(), "dup name");
411        assert_eq!(c.matching(b"user:42").count(), 1);
412        assert_eq!(c.matching(b"other:1").count(), 0);
413        assert_eq!(c.get(b"a").unwrap().1, IndexState::Building);
414        assert!(c.set_state(b"a", IndexState::Ready));
415        assert_eq!(c.get(b"a").unwrap().1, IndexState::Ready);
416        assert!(c.drop_index(b"b"));
417        assert!(!c.drop_index(b"b"));
418        assert_eq!(c.len(), 1);
419    }
420
421    #[test]
422    fn sidecar_roundtrip_with_escapes() {
423        let mut c = Catalog::new();
424        let mut s = spec("weird", "pre\tfix:");
425        s.fields = vec![FieldSpec::new(b"f%\n".to_vec())];
426        s.max_bytes = 1024;
427        c.create(s).unwrap();
428        let text = c.to_sidecar();
429        let c2 = Catalog::from_sidecar(&text).unwrap();
430        let (got, st) = c2.get(b"weird").unwrap();
431        assert_eq!(got.prefix, b"pre\tfix:".to_vec());
432        assert_eq!(got.field(), b"f%\n");
433        assert_eq!(got.max_bytes, 1024);
434        assert_eq!(st, IndexState::Building, "boot loads as Building");
435        assert!(Catalog::from_sidecar("bogus").is_none());
436    }
437
438    /// Text serves several fields; every other kind reads one scalar,
439    /// so a second field there would be declared and never consulted.
440    /// Accept-and-ignore is the shape this refuses.
441    #[test]
442    fn only_text_indexes_accept_several_fields() {
443        let two = || {
444            vec![
445                FieldSpec::new(b"title".to_vec()),
446                FieldSpec::new(b"body".to_vec()),
447            ]
448        };
449        let mut range = spec("multi-range", "p:");
450        range.fields = two();
451        assert!(Catalog::new().create(range).is_err(), "range must refuse two fields");
452
453        let mut text = spec("multi-text", "p:");
454        text.kind = IndexKind::Text;
455        text.fields = two();
456        assert!(Catalog::new().create(text).is_ok(), "text must accept them");
457    }
458
459    #[test]
460    fn an_index_needs_at_least_one_field() {
461        let mut s = spec("nofields", "p:");
462        s.fields.clear();
463        assert!(Catalog::new().create(s).is_err());
464    }
465
466    #[test]
467    fn cap_enforced() {
468        let mut c = Catalog::new();
469        for i in 0..MAX_INDEXES {
470            c.create(spec(&format!("i{i}"), "p:")).unwrap();
471        }
472        assert!(c.create(spec("over", "p:")).is_err());
473    }
474}