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::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}