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}