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::error::{CatalogError, Declared};
5use crate::spec::IndexSpec;
6
7/// Declared scalar type of an index (`TYPE i64|f64|str`).
8///
9/// ```
10/// use kevy_index::ValType;
11/// assert_eq!(ValType::parse(b"F64"), Some(ValType::F64));
12/// assert_eq!(ValType::parse(ValType::Str.tag().as_bytes()), Some(ValType::Str));
13/// assert_eq!(ValType::parse(b"u8"), None);
14/// ```
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16#[non_exhaustive]
17pub enum ValType {
18 /// f32 LE vector blob (ANN kinds parse the field
19 /// themselves — never coerced through IndexValue).
20 ///
21 /// ```
22 /// use kevy_index::{AnnSpec, IndexKind, IndexSpec, IndexValue, ValType};
23 /// let knn = IndexSpec::builder("emb", "doc:", IndexKind::Ann, ValType::Vector)
24 /// .with_field("emb")
25 /// .with_ann(AnnSpec::new(3))
26 /// .build()?;
27 /// assert_eq!(knn.ty(), ValType::Vector);
28 /// assert_eq!(IndexValue::coerce(ValType::Vector, b"\0\0\0\0"), None, "never a scalar");
29 /// # Ok::<(), Box<dyn std::error::Error>>(())
30 /// ```
31 Vector,
32 /// Signed 64-bit integer.
33 ///
34 /// ```
35 /// use kevy_index::{IndexValue, ValType};
36 /// assert_eq!(IndexValue::coerce(ValType::I64, b"-7"), Some(IndexValue::I64(-7)));
37 /// assert_eq!(IndexValue::coerce(ValType::I64, b"7.5"), None);
38 /// ```
39 I64,
40 /// Finite 64-bit float (NaN coerce-fails).
41 ///
42 /// ```
43 /// use kevy_index::{IndexValue, ValType};
44 /// assert_eq!(IndexValue::coerce(ValType::F64, b"7.5"), Some(IndexValue::F64(7.5)));
45 /// assert_eq!(IndexValue::coerce(ValType::F64, b"nan"), None);
46 /// ```
47 F64,
48 /// Raw bytes, memcmp order.
49 ///
50 /// ```
51 /// use kevy_index::{IndexValue, ValType};
52 /// let (a, b) = (IndexValue::coerce(ValType::Str, b"B"), IndexValue::coerce(ValType::Str, b"a"));
53 /// assert!(a < b, "bytewise: 'B' (0x42) sorts before 'a' (0x61)");
54 /// ```
55 Str,
56}
57
58impl ValType {
59 /// Wire tag (catalog sidecar + IDX.LIST).
60 pub fn tag(self) -> &'static str {
61 match self {
62 ValType::I64 => "i64",
63 ValType::F64 => "f64",
64 ValType::Str => "str",
65 ValType::Vector => "vector",
66 }
67 }
68
69 /// Parse a wire tag.
70 pub fn parse(raw: &[u8]) -> Option<ValType> {
71 if raw.eq_ignore_ascii_case(b"i64") {
72 Some(ValType::I64)
73 } else if raw.eq_ignore_ascii_case(b"f64") {
74 Some(ValType::F64)
75 } else if raw.eq_ignore_ascii_case(b"str") {
76 Some(ValType::Str)
77 } else if raw.eq_ignore_ascii_case(b"vector") {
78 Some(ValType::Vector)
79 } else {
80 None
81 }
82 }
83}
84
85/// Index kind (`KIND range|unique`).
86///
87/// ```
88/// use kevy_index::IndexKind;
89/// assert_eq!(IndexKind::parse(b"UNIQUE"), Some(IndexKind::Unique));
90/// assert_eq!(IndexKind::parse(IndexKind::Agg.tag().as_bytes()), Some(IndexKind::Agg));
91/// assert_eq!(IndexKind::parse(b"hash"), None);
92/// ```
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
94#[non_exhaustive]
95pub enum IndexKind {
96 /// Ordered scan over `(value, key)` pairs.
97 ///
98 /// ```
99 /// use kevy_index::{IndexValue, Segment};
100 /// let mut seg = Segment::new();
101 /// seg.apply(b"u:1", None, Some(IndexValue::I64(30)));
102 /// seg.apply(b"u:2", None, Some(IndexValue::I64(20)));
103 /// let (hits, _) = seg.range(&IndexValue::I64(0), &IndexValue::I64(99), None, 10);
104 /// assert_eq!(hits[0].0, b"u:2", "value order, not key order");
105 /// ```
106 Range,
107 /// Point lookup by value; duplicates recorded (declarative fence:
108 /// uniqueness is verified, not write-enforced).
109 ///
110 /// ```
111 /// use kevy_index::{IndexValue, Segment};
112 /// let mut seg = Segment::new();
113 /// seg.apply(b"u:1", None, Some(IndexValue::Str(b"a@x".to_vec())));
114 /// seg.apply(b"u:2", None, Some(IndexValue::Str(b"a@x".to_vec())));
115 /// // the second write is not refused; the lookup shows the duplicate
116 /// assert_eq!(seg.eq(&IndexValue::Str(b"a@x".to_vec()), 10).len(), 2);
117 /// ```
118 Unique,
119 /// Full-text: the field tokenizes into an inverted segment
120 /// (kevy-text); queried with `MATCH`, BM25-ranked.
121 ///
122 /// ```
123 /// use kevy_index::{FieldSpec, IndexKind, IndexSpec, ValType};
124 /// let fields = vec![FieldSpec::new("title").with_weight(2.0), FieldSpec::new("body")];
125 /// let text = IndexSpec::builder("posts", "post:", IndexKind::Text, ValType::Str)
126 /// .with_fields(fields)
127 /// .build()?;
128 /// assert_eq!(text.fields().len(), 2, "only text serves several fields");
129 /// # Ok::<(), Box<dyn std::error::Error>>(())
130 /// ```
131 Text,
132 /// ANN: the field holds an f32 LE vector indexed in an HNSW
133 /// graph (kevy-vector); queried with `KNN`, distance-ranked.
134 ///
135 /// ```
136 /// use kevy_index::{AnnSpec, IndexKind, IndexSpec, ValType};
137 /// let b = IndexSpec::builder("emb", "doc:", IndexKind::Ann, ValType::Vector).with_field("v");
138 /// assert!(b.clone().build().is_err(), "an ANN index needs its parameters");
139 /// assert_eq!(b.with_ann(AnnSpec::new(128)).build()?.ann(), Some(AnnSpec::new(128)));
140 /// # Ok::<(), Box<dyn std::error::Error>>(())
141 /// ```
142 Ann,
143 /// Aggregate: per-group count/sum/min/max of the field,
144 /// grouped by `IndexSpec::group_by`; queried with `GROUP`/`GROUPS`.
145 ///
146 /// ```
147 /// use kevy_index::{IndexKind, IndexSpec, ValType};
148 /// let spend = IndexSpec::builder("spend", "o:", IndexKind::Agg, ValType::F64)
149 /// .with_field("total")
150 /// .with_group_by("region")
151 /// .build()?;
152 /// assert_eq!(spend.group_by(), Some(&b"region"[..]));
153 /// # Ok::<(), Box<dyn std::error::Error>>(())
154 /// ```
155 Agg,
156}
157
158impl IndexKind {
159 /// Wire tag.
160 pub fn tag(self) -> &'static str {
161 match self {
162 IndexKind::Range => "range",
163 IndexKind::Unique => "unique",
164 IndexKind::Text => "text",
165 IndexKind::Ann => "ann",
166 IndexKind::Agg => "agg",
167 }
168 }
169
170 /// Parse a wire tag.
171 pub fn parse(raw: &[u8]) -> Option<IndexKind> {
172 if raw.eq_ignore_ascii_case(b"range") {
173 Some(IndexKind::Range)
174 } else if raw.eq_ignore_ascii_case(b"unique") {
175 Some(IndexKind::Unique)
176 } else if raw.eq_ignore_ascii_case(b"text") {
177 Some(IndexKind::Text)
178 } else if raw.eq_ignore_ascii_case(b"ann") {
179 Some(IndexKind::Ann)
180 } else if raw.eq_ignore_ascii_case(b"agg") {
181 Some(IndexKind::Agg)
182 } else {
183 None
184 }
185 }
186}
187
188/// Lifecycle state.
189///
190/// ```
191/// use kevy_index::{Catalog, IndexKind, IndexSpec, IndexState, ValType};
192/// let mut c = Catalog::new();
193/// c.create(IndexSpec::builder("age", "u:", IndexKind::Range, ValType::I64).with_field("age").build()?)?;
194/// assert_eq!(c.get(b"age").map(|(_, st)| st), Some(IndexState::Building));
195/// assert!(c.set_state(b"age", IndexState::Ready));
196/// assert_eq!(c.get(b"age").map(|(_, st)| st), Some(IndexState::Ready));
197/// # Ok::<(), Box<dyn std::error::Error>>(())
198/// ```
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
200#[non_exhaustive]
201pub enum IndexState {
202 /// Backfill in progress; queries answer `-INDEXBUILDING`.
203 ///
204 /// ```
205 /// use kevy_index::{Catalog, IndexKind, IndexSpec, IndexState, ValType};
206 /// let mut c = Catalog::new();
207 /// c.create(IndexSpec::builder("age", "u:", IndexKind::Range, ValType::I64).with_field("age").build()?)?;
208 /// assert_eq!(c.iter().next().map(|(_, st)| st), Some(IndexState::Building), "every index starts here");
209 /// # Ok::<(), Box<dyn std::error::Error>>(())
210 /// ```
211 Building,
212 /// Serving.
213 ///
214 /// ```
215 /// use kevy_index::{Catalog, IndexKind, IndexSpec, IndexState, ValType};
216 /// let mut c = Catalog::new();
217 /// c.create(IndexSpec::builder("age", "u:", IndexKind::Range, ValType::I64).with_field("age").build()?)?;
218 /// c.set_state(b"age", IndexState::Ready); // backfill finished
219 /// let serving = c.matching(b"u:1").filter(|(_, st)| *st == IndexState::Ready).count();
220 /// assert_eq!(serving, 1);
221 /// # Ok::<(), Box<dyn std::error::Error>>(())
222 /// ```
223 Ready,
224 /// Build aborted over budget; queries answer an error.
225 ///
226 /// ```
227 /// use kevy_index::{Catalog, IndexKind, IndexSpec, IndexState, ValType};
228 /// let mut c = Catalog::new();
229 /// let spec = IndexSpec::builder("age", "u:", IndexKind::Range, ValType::I64).with_field("age");
230 /// c.create(spec.with_max_bytes(4096).build()?)?;
231 /// // the runtime's backfill passed max_bytes and gave up
232 /// c.set_state(b"age", IndexState::FailedOverBudget);
233 /// assert_eq!(c.get(b"age").map(|(_, st)| st), Some(IndexState::FailedOverBudget));
234 /// # Ok::<(), Box<dyn std::error::Error>>(())
235 /// ```
236 FailedOverBudget,
237}
238
239/// Hard cap on declared indexes.
240pub const MAX_INDEXES: usize = 64;
241
242/// The registry. The runtime holds one per process behind an RCU-style
243/// swap; shards read their clone lock-free.
244///
245/// ```
246/// use kevy_index::{Catalog, CatalogError, IndexKind, IndexSpec, ValType};
247/// let spec = |name: &str, prefix: &str| {
248/// IndexSpec::builder(name, prefix, IndexKind::Range, ValType::I64).with_field("n").build()
249/// };
250/// let mut c = Catalog::new();
251/// assert!(c.is_empty());
252/// c.create(spec("age", "user:")?)?;
253/// c.create(spec("ttl", "sess:")?)?;
254/// assert!(matches!(c.create(spec("age", "x:")?), Err(CatalogError::Exists(_))));
255/// assert_eq!(c.len(), 2);
256/// let hit: Vec<_> = c.matching(b"user:7").map(|(s, _)| s.name().to_vec()).collect();
257/// assert_eq!(hit, [b"age".to_vec()]);
258/// assert_eq!(c.get(b"ttl").map(|(s, _)| s.prefix()), Some(&b"sess:"[..]));
259/// assert!(c.drop_index(b"ttl") && !c.drop_index(b"ttl"));
260/// assert_eq!(c.iter().count(), 1);
261///
262/// // the sidecar text restores the declarations, every one as Building
263/// let back = Catalog::from_sidecar(&c.to_sidecar()).ok_or("sidecar did not parse")?;
264/// assert_eq!(back.get(b"age").map(|(s, _)| s.name()), Some(&b"age"[..]));
265/// assert!(Catalog::from_sidecar("not a sidecar").is_none());
266/// # Ok::<(), Box<dyn std::error::Error>>(())
267/// ```
268#[derive(Debug, Clone, Default)]
269pub struct Catalog {
270 pub(crate) specs: Vec<(IndexSpec, IndexState)>,
271 /// The global indexes' partitionings, by name; any other is local.
272 pub(crate) parts: Vec<(Vec<u8>, crate::Partitioning)>,
273}
274
275impl Catalog {
276 /// Empty catalog.
277 pub fn new() -> Self {
278 Self::default()
279 }
280
281 /// Register a new index. Errors on duplicate name / cap; the spec
282 /// itself is consistent by construction.
283 pub fn create(&mut self, spec: IndexSpec) -> Result<(), CatalogError> {
284 if self.specs.len() >= MAX_INDEXES {
285 return Err(CatalogError::Full(Declared::Index));
286 }
287 if self.specs.iter().any(|(s, _)| s.name == spec.name) {
288 return Err(CatalogError::Exists(Declared::Index));
289 }
290 self.specs.push((spec, IndexState::Building));
291 Ok(())
292 }
293
294 /// Drop by name; `false` if absent.
295 pub fn drop_index(&mut self, name: &[u8]) -> bool {
296 let before = self.specs.len();
297 self.specs.retain(|(s, _)| s.name != name);
298 self.parts.retain(|(n, _)| n != name);
299 self.specs.len() != before
300 }
301
302 /// Set an index's lifecycle state; `false` if absent.
303 pub fn set_state(&mut self, name: &[u8], state: IndexState) -> bool {
304 for (s, st) in &mut self.specs {
305 if s.name == name {
306 *st = state;
307 return true;
308 }
309 }
310 false
311 }
312
313 /// Look up by name.
314 pub fn get(&self, name: &[u8]) -> Option<(&IndexSpec, IndexState)> {
315 self.specs.iter().find(|(s, _)| s.name == name).map(|(s, st)| (s, *st))
316 }
317
318 /// All specs with states, declaration order.
319 pub fn iter(&self) -> impl Iterator<Item = (&IndexSpec, IndexState)> {
320 self.specs.iter().map(|(s, st)| (s, *st))
321 }
322
323 /// Number of declared indexes.
324 pub fn len(&self) -> usize {
325 self.specs.len()
326 }
327
328 /// Whether no indexes are declared (the write hook's fast path).
329 pub fn is_empty(&self) -> bool {
330 self.specs.is_empty()
331 }
332
333 /// The write-path matcher: indexes whose prefix domain contains
334 /// `key`. Linear over ≤64 specs with a memcmp each — the compiled
335 /// trie of the RFC becomes worthwhile only past this cap, so the
336 /// simple form IS the fast form at our scale.
337 pub fn matching<'a>(
338 &'a self,
339 key: &'a [u8],
340 ) -> impl Iterator<Item = (&'a IndexSpec, IndexState)> {
341 self.specs.iter().filter(move |(s, _)| key.starts_with(&s.prefix)).map(|(s, st)| (s, *st))
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::FieldSpec;
349
350 fn spec(name: &str, prefix: &str) -> IndexSpec {
351 builder(name, prefix).build().unwrap()
352 }
353
354 fn builder(name: &str, prefix: &str) -> crate::IndexSpecBuilder {
355 IndexSpec::builder(name, prefix, IndexKind::Range, ValType::I64).with_field("age")
356 }
357
358 #[test]
359 fn create_drop_match_lifecycle() {
360 let mut c = Catalog::new();
361 c.create(spec("a", "user:")).unwrap();
362 c.create(spec("b", "sess:")).unwrap();
363 assert!(c.create(spec("a", "x:")).is_err(), "dup name");
364 assert_eq!(c.matching(b"user:42").count(), 1);
365 assert_eq!(c.matching(b"other:1").count(), 0);
366 assert_eq!(c.get(b"a").unwrap().1, IndexState::Building);
367 assert!(c.set_state(b"a", IndexState::Ready));
368 assert_eq!(c.get(b"a").unwrap().1, IndexState::Ready);
369 assert!(c.drop_index(b"b"));
370 assert!(!c.drop_index(b"b"));
371 assert_eq!(c.len(), 1);
372 }
373
374 #[test]
375 fn sidecar_roundtrip_with_escapes() {
376 let mut c = Catalog::new();
377 let s = IndexSpec::builder("weird", "pre\tfix:", IndexKind::Range, ValType::I64)
378 .with_field(b"f%\n".to_vec())
379 .with_max_bytes(1024);
380 c.create(s.build().unwrap()).unwrap();
381 let text = c.to_sidecar();
382 let c2 = Catalog::from_sidecar(&text).unwrap();
383 let (got, st) = c2.get(b"weird").unwrap();
384 assert_eq!(got.prefix, b"pre\tfix:".to_vec());
385 assert_eq!(got.field(), b"f%\n");
386 assert_eq!(got.max_bytes, 1024);
387 assert_eq!(st, IndexState::Building, "boot loads as Building");
388 assert!(Catalog::from_sidecar("bogus").is_none());
389 }
390
391 /// Text serves several fields; every other kind reads one scalar,
392 /// so a second field there would be declared and never consulted.
393 /// Accept-and-ignore is the shape this refuses.
394 #[test]
395 fn only_text_indexes_accept_several_fields() {
396 let two = || vec![FieldSpec::new(b"title".to_vec()), FieldSpec::new(b"body".to_vec())];
397 let range = builder("multi-range", "p:").with_fields(two());
398 assert!(range.build().is_err(), "range must refuse two fields");
399
400 let text = IndexSpec::builder("multi-text", "p:", IndexKind::Text, ValType::Str);
401 let text = text.with_fields(two()).build().expect("text must accept them");
402 assert!(Catalog::new().create(text).is_ok());
403 }
404
405 #[test]
406 fn an_index_needs_at_least_one_field() {
407 let s = builder("nofields", "p:").with_fields(Vec::new());
408 assert!(s.build().is_err());
409 }
410
411 #[test]
412 fn cap_enforced() {
413 let mut c = Catalog::new();
414 for i in 0..MAX_INDEXES {
415 c.create(spec(&format!("i{i}"), "p:")).unwrap();
416 }
417 assert!(c.create(spec("over", "p:")).is_err());
418 }
419}