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}