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    /// v2.8: 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    /// RFC D3: uniqueness is verified, not write-enforced).
54    Unique,
55    /// v2.7 full-text: the field tokenizes into an inverted segment
56    /// (kevy-text); queried with `MATCH`, BM25-ranked.
57    Text,
58    /// v2.8 ANN: the field holds an f32 LE vector indexed in an HNSW
59    /// graph (kevy-vector); queried with `KNN`, distance-ranked.
60    Ann,
61}
62
63impl IndexKind {
64    /// Wire tag.
65    pub fn tag(self) -> &'static str {
66        match self {
67            IndexKind::Range => "range",
68            IndexKind::Unique => "unique",
69            IndexKind::Text => "text",
70            IndexKind::Ann => "ann",
71        }
72    }
73
74    /// Parse a wire tag.
75    pub fn parse(raw: &[u8]) -> Option<IndexKind> {
76        if raw.eq_ignore_ascii_case(b"range") {
77            Some(IndexKind::Range)
78        } else if raw.eq_ignore_ascii_case(b"unique") {
79            Some(IndexKind::Unique)
80        } else if raw.eq_ignore_ascii_case(b"text") {
81            Some(IndexKind::Text)
82        } else if raw.eq_ignore_ascii_case(b"ann") {
83            Some(IndexKind::Ann)
84        } else {
85            None
86        }
87    }
88}
89
90/// Lifecycle state (RFC D5).
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum IndexState {
93    /// Backfill in progress; queries answer `-INDEXBUILDING`.
94    Building,
95    /// Serving.
96    Ready,
97    /// Build aborted over budget (RFC D7); queries answer an error.
98    FailedOverBudget,
99}
100
101/// One declared index.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct IndexSpec {
104    /// Unique catalog name.
105    pub name: Vec<u8>,
106    /// Key-prefix domain (`ON PREFIX user:`).
107    pub prefix: Vec<u8>,
108    /// Hash field the value comes from.
109    pub field: Vec<u8>,
110    /// Declared scalar type.
111    pub ty: ValType,
112    /// Range or unique.
113    pub kind: IndexKind,
114    /// Optional per-index byte budget (`MAXMEM`); 0 = unlimited.
115    pub max_bytes: u64,
116    /// v2.8: ANN parameters (`Some` iff kind == Ann).
117    pub ann: Option<AnnSpec>,
118}
119
120/// v2.8 — HNSW declaration (immutable once created; RFC D2).
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct AnnSpec {
123    /// Vector dimensionality (field bytes must be dim×4 f32 LE).
124    pub dim: u32,
125    /// 0=cosine 1=l2 2=ip (kevy-vector's Distance tags).
126    pub distance: u8,
127    /// Max links per node per layer.
128    pub m: u16,
129    /// Construction beam width.
130    pub ef: u16,
131}
132
133/// Hard cap on declared indexes (RFC D1).
134pub const MAX_INDEXES: usize = 64;
135
136/// The registry. The runtime holds one per process behind an RCU-style
137/// swap; shards read their clone lock-free.
138#[derive(Debug, Clone, Default)]
139pub struct Catalog {
140    specs: Vec<(IndexSpec, IndexState)>,
141}
142
143impl Catalog {
144    /// Empty catalog.
145    pub fn new() -> Self {
146        Self::default()
147    }
148
149    /// Register a new index. Errors on duplicate name / cap.
150    pub fn create(&mut self, spec: IndexSpec) -> Result<(), &'static str> {
151        if self.specs.len() >= MAX_INDEXES {
152            return Err("ERR index limit reached (64)");
153        }
154        if self.specs.iter().any(|(s, _)| s.name == spec.name) {
155            return Err("ERR index already exists");
156        }
157        self.specs.push((spec, IndexState::Building));
158        Ok(())
159    }
160
161    /// Drop by name; `false` if absent.
162    pub fn drop_index(&mut self, name: &[u8]) -> bool {
163        let before = self.specs.len();
164        self.specs.retain(|(s, _)| s.name != name);
165        self.specs.len() != before
166    }
167
168    /// Set an index's lifecycle state; `false` if absent.
169    pub fn set_state(&mut self, name: &[u8], state: IndexState) -> bool {
170        for (s, st) in &mut self.specs {
171            if s.name == name {
172                *st = state;
173                return true;
174            }
175        }
176        false
177    }
178
179    /// Look up by name.
180    pub fn get(&self, name: &[u8]) -> Option<(&IndexSpec, IndexState)> {
181        self.specs.iter().find(|(s, _)| s.name == name).map(|(s, st)| (s, *st))
182    }
183
184    /// All specs with states, declaration order.
185    pub fn iter(&self) -> impl Iterator<Item = (&IndexSpec, IndexState)> {
186        self.specs.iter().map(|(s, st)| (s, *st))
187    }
188
189    /// Number of declared indexes.
190    pub fn len(&self) -> usize {
191        self.specs.len()
192    }
193
194    /// Whether no indexes are declared (the write hook's fast path).
195    pub fn is_empty(&self) -> bool {
196        self.specs.is_empty()
197    }
198
199    /// The write-path matcher: indexes whose prefix domain contains
200    /// `key`. Linear over ≤64 specs with a memcmp each — the compiled
201    /// trie of the RFC becomes worthwhile only past this cap, so the
202    /// simple form IS the fast form at our scale.
203    pub fn matching<'a>(&'a self, key: &'a [u8]) -> impl Iterator<Item = (&'a IndexSpec, IndexState)> {
204        self.specs
205            .iter()
206            .filter(move |(s, _)| key.starts_with(&s.prefix))
207            .map(|(s, st)| (s, *st))
208    }
209
210    /// Coerce a raw field value for `spec` (convenience passthrough).
211    pub fn coerce(spec: &IndexSpec, raw: &[u8]) -> Option<IndexValue> {
212        IndexValue::coerce(spec.ty, raw)
213    }
214
215    /// Serialize to the sidecar text form (one line per index:
216    /// `name<TAB>prefix<TAB>field<TAB>ty<TAB>kind<TAB>max_bytes[<TAB>ann]`,
217    /// fields hex-escaped for tabs/newlines via `%XX`; the 7th column
218    /// is `dim,distance,m,ef` for ANN kinds).
219    pub fn to_sidecar(&self) -> String {
220        let mut out = String::from("kevy-index-catalog v1\n");
221        for (s, _) in &self.specs {
222            out.push_str(&format!(
223                "{}\t{}\t{}\t{}\t{}\t{}",
224                esc(&s.name),
225                esc(&s.prefix),
226                esc(&s.field),
227                s.ty.tag(),
228                s.kind.tag(),
229                s.max_bytes
230            ));
231            if let Some(a) = &s.ann {
232                out.push_str(&format!("\t{},{},{},{}", a.dim, a.distance, a.m, a.ef));
233            }
234            out.push('\n');
235        }
236        out
237    }
238
239    /// Parse the sidecar text form; all indexes load as `Building`
240    /// (boot rebuild, RFC D5). `None` on malformed input.
241    pub fn from_sidecar(text: &str) -> Option<Catalog> {
242        let mut lines = text.lines();
243        if lines.next()? != "kevy-index-catalog v1" {
244            return None;
245        }
246        let mut c = Catalog::new();
247        for line in lines {
248            if line.is_empty() {
249                continue;
250            }
251            let parts: Vec<&str> = line.split('\t').collect();
252            if !(parts.len() == 6 || parts.len() == 7) {
253                return None;
254            }
255            let ann = if parts.len() == 7 {
256                let nums: Vec<&str> = parts[6].split(',').collect();
257                if nums.len() != 4 {
258                    return None;
259                }
260                Some(AnnSpec {
261                    dim: nums[0].parse().ok()?,
262                    distance: nums[1].parse().ok()?,
263                    m: nums[2].parse().ok()?,
264                    ef: nums[3].parse().ok()?,
265                })
266            } else {
267                None
268            };
269            let spec = IndexSpec {
270                name: unesc(parts[0])?,
271                prefix: unesc(parts[1])?,
272                field: unesc(parts[2])?,
273                ty: ValType::parse(parts[3].as_bytes())?,
274                kind: IndexKind::parse(parts[4].as_bytes())?,
275            max_bytes: parts[5].parse().ok()?,
276                ann,
277            };
278            c.create(spec).ok()?;
279        }
280        Some(c)
281    }
282}
283
284fn esc(b: &[u8]) -> String {
285    let mut out = String::with_capacity(b.len());
286    for &c in b {
287        if c == b'\t' || c == b'\n' || c == b'%' || !(32..127).contains(&c) {
288            out.push_str(&format!("%{c:02X}"));
289        } else {
290            out.push(c as char);
291        }
292    }
293    out
294}
295
296fn unesc(s: &str) -> Option<Vec<u8>> {
297    let mut out = Vec::with_capacity(s.len());
298    let bytes = s.as_bytes();
299    let mut i = 0;
300    while i < bytes.len() {
301        if bytes[i] == b'%' {
302            let hex = s.get(i + 1..i + 3)?;
303            out.push(u8::from_str_radix(hex, 16).ok()?);
304            i += 3;
305        } else {
306            out.push(bytes[i]);
307            i += 1;
308        }
309    }
310    Some(out)
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    fn spec(name: &str, prefix: &str) -> IndexSpec {
318        IndexSpec {
319            name: name.into(),
320            prefix: prefix.into(),
321            field: b"age".to_vec(),
322            ty: ValType::I64,
323            kind: IndexKind::Range,
324            ann: None,
325            max_bytes: 0,
326        }
327    }
328
329    #[test]
330    fn create_drop_match_lifecycle() {
331        let mut c = Catalog::new();
332        c.create(spec("a", "user:")).unwrap();
333        c.create(spec("b", "sess:")).unwrap();
334        assert!(c.create(spec("a", "x:")).is_err(), "dup name");
335        assert_eq!(c.matching(b"user:42").count(), 1);
336        assert_eq!(c.matching(b"other:1").count(), 0);
337        assert_eq!(c.get(b"a").unwrap().1, IndexState::Building);
338        assert!(c.set_state(b"a", IndexState::Ready));
339        assert_eq!(c.get(b"a").unwrap().1, IndexState::Ready);
340        assert!(c.drop_index(b"b"));
341        assert!(!c.drop_index(b"b"));
342        assert_eq!(c.len(), 1);
343    }
344
345    #[test]
346    fn sidecar_roundtrip_with_escapes() {
347        let mut c = Catalog::new();
348        let mut s = spec("weird", "pre\tfix:");
349        s.field = b"f%\n".to_vec();
350        s.max_bytes = 1024;
351        c.create(s).unwrap();
352        let text = c.to_sidecar();
353        let c2 = Catalog::from_sidecar(&text).unwrap();
354        let (got, st) = c2.get(b"weird").unwrap();
355        assert_eq!(got.prefix, b"pre\tfix:".to_vec());
356        assert_eq!(got.field, b"f%\n".to_vec());
357        assert_eq!(got.max_bytes, 1024);
358        assert_eq!(st, IndexState::Building, "boot loads as Building");
359        assert!(Catalog::from_sidecar("bogus").is_none());
360    }
361
362    #[test]
363    fn cap_enforced() {
364        let mut c = Catalog::new();
365        for i in 0..MAX_INDEXES {
366            c.create(spec(&format!("i{i}"), "p:")).unwrap();
367        }
368        assert!(c.create(spec("over", "p:")).is_err());
369    }
370}