Skip to main content

kernel/
keys.rs

1//! The keyspaces. Extensibility = new tags, never new structures.
2//!
3//! Big-endian everywhere: byte order IS numeric order, so "everything of X" is a
4//! range scan from a prefix. Tag first, so spaces never interleave.
5//!
6//! 0x00 catalog | 0x01 node(id) -> payload | 0x03 edge(src,type,dst) -> props
7//! 0x04 redge(dst,type,src) mirror | 0x05 vec(field,id) -> embedding (phase 2)
8
9pub const TAG_CATALOG: u8 = 0x00;
10pub const TAG_NODE: u8 = 0x01;
11pub const TAG_EDGE: u8 = 0x03;
12pub const TAG_REDGE: u8 = 0x04;
13/// Embedding rows (2e): 0x05 | field | id -> f32-LE coordinates. FIELD
14/// FIRST, like every other index family here: each vector field owns a
15/// disjoint, prefix-scannable range, so two embedding columns of two
16/// different widths never meet in one scan.
17pub const TAG_VEC: u8 = 0x05;
18pub const TAG_LABEL: u8 = 0x02;
19pub const TAG_EXT: u8 = 0x06;
20/// ctx != 0 edges live in their own, wider keyspace. The base graph (ctx = 0)
21/// keeps 25-byte keys: measured, the flat +8B/key grew a 5M-node file by
22/// 640 MB and doubled 3-hop latency through OS-cache pressure alone -- a price
23/// paid by workloads that never use perspectives. Split tags, each side pays
24/// its own way. Sort order per space is unchanged (separate tags).
25pub const TAG_CEDGE: u8 = 0x08;
26pub const TAG_CREDGE: u8 = 0x09;
27/// Property index: 0x0A | prop_id | value | node_id, empty value. 25B.
28/// The VALUE is an order-preserving 8-byte encoding, so every property query
29/// is a range scan: equality = one (prop,value) prefix, range = (prop,lo)..hi,
30/// top-k DESC = a descending-encoded prop scanned forward with LIMIT.
31pub const TAG_PROP: u8 = 0x0A;
32/// Vector fingerprint (2g): 0x0B | field | id -> [norm f32-LE][packed code].
33/// Its own keyspace so `nearest` scans codes ONLY -- never the 6KB vectors.
34/// Codes of different fields are different WIDTHS (the width follows the
35/// field's dimension), so the field must be in the key: a scan that mixed
36/// them would decode one field's bytes with another field's recipe.
37pub const TAG_VCODE: u8 = 0x0B;
38/// Full-text postings (2h). Two shapes share the tag, split by segment id:
39///   HEAD (seg 0, mutable):   0x0C | field | 0u32 | term | 0x00 | docid  -> tf varint
40///   FOLDED (seg>0, immut.):  0x0C | field | seg  | term               -> packed postings
41/// The head is row-per-posting so every write is BLIND (appending to a
42/// packed value would be read-modify-write -- the e1 BM25 wound); folds
43/// pack head rows into value-per-term segments. 0x00 separates term from docid in head keys: tokenizer terms are
44/// alphanumeric UTF-8 (never 0x00), and the separator MUST sort before
45/// every text byte -- with 0xFF, longer terms sorted ahead of their own
46/// prefixes ("handle" before "hand") and the fuzzy walk's seek skipped
47/// real terms; the oracle test caught it.
48pub const TAG_TEXT: u8 = 0x0C;
49/// Per-(field, doc) token count -- BM25's |d|. Point lookups at scoring
50/// time only (candidates are few), so no packed norm blocks are needed.
51pub const TAG_TEXTNORM: u8 = 0x0D;
52/// Per-(field, seg) metadata: doc_count + total_tokens (BM25 denominators)
53/// and the segment's alive-bitmap (the deletion discipline: one value).
54pub const TAG_TEXTMETA: u8 = 0x0E;
55/// Spatial cell postings (2i): 0x0F | field | level u8 | hilbert u64 | id
56/// -> [bbox f32x4 outward-rounded]. Hilbert order makes a neighbourhood's
57/// postings contiguous on disk; the bbox filters without payload reads;
58/// a degenerate bbox IS the point (exact tier free for point data).
59pub const TAG_SPAT: u8 = 0x0F;
60/// Geometry rows (2i): 0x10 | field | id -> binary-encoded Geom. The
61/// kernel speaks TYPED geometry only; GeoJSON parsing is an API-layer
62/// concern (the core stays pure -- no JSON dependency below the SQL line).
63pub const TAG_GEOM: u8 = 0x10;
64/// Vector navigation rows (2k): 0x11 | field | id -> [norm f32][2-bit code]
65/// [n u16][neighbor u64...]. Code co-located with links (FACT-02): one row
66/// read per visited node during a beam walk -- ranking data arrives with
67/// the topology, no second lookup. One small-world graph per field: links
68/// carry bare ids, so a shared keyspace would wire two fields' vectors
69/// into one another's neighbourhoods.
70pub const TAG_NAV: u8 = 0x11;
71/// SQL-layer metadata (phase 3): collection-name interning, schemas,
72/// edge-type names. The kernel never reads these rows; the tag is minted
73/// here so the keyspace vocabulary stays in ONE place (D4).
74pub const TAG_SQLMETA: u8 = 0x12;
75/// SQL field (secondary) indexes: (coll, field, order-preserving value
76/// bytes, id) -> []. The VALUE ENCODING is the SQL layer's business; the
77/// kernel only promises byte-ordered iteration (D4: an index is rows).
78pub const TAG_FIELDIDX: u8 = 0x13;
79/// SQL SEARCH-index slot maps (phase 3c): dense u32 slots <-> node hashes,
80/// per search index. Kind 0: slot -> hash. Kind 1: hash -> slot.
81/// Kind 2: next-slot counter.
82pub const TAG_SEARCHSLOT: u8 = 0x14;
83
84/// Catalogue subspace for the SQL covering `_key` order index.
85///
86/// This deliberately is NOT a new top-level tag. A tag above the append-heavy
87/// data spaces interrupts their right-edge growth and measurably lowers leaf
88/// occupancy. `TAG_CATALOG` is 0x00, ahead of every data row, so these records
89/// cannot split the vector/node growth point. The fixed identity following the
90/// slot is `(collection, field)`; a field name alone is never globally unique.
91pub const CAT_KEY_ORDER: u64 = 4;
92/// Disposable SQL aggregate summaries. Kept before append-heavy data tags.
93/// Recovery must discard these: a salvaged source can differ from its summary.
94pub const CAT_FIELD_AGGREGATE: u64 = 5;
95
96pub fn field_aggregate_key(coll: u64, field: u64) -> Vec<u8> {
97    k(TAG_CATALOG, &[CAT_FIELD_AGGREGATE, coll, field])
98}
99
100pub fn is_field_aggregate_key(key: &[u8]) -> bool {
101    key.len() == 25 && key[0] == TAG_CATALOG
102        && key[1..9] == CAT_FIELD_AGGREGATE.to_be_bytes()
103}
104
105fn k(tag: u8, parts: &[u64]) -> Vec<u8> {
106    let mut v = Vec::with_capacity(1 + parts.len() * 8);
107    v.push(tag);
108    for p in parts { v.extend_from_slice(&p.to_be_bytes()); }
109    v
110}
111
112pub fn node(id: u64) -> Vec<u8> { k(TAG_NODE, &[id]) }
113// ctx first (GRAPH contract): RCA traverses within one perspective, so each
114// perspective's edges cluster physically, and a whole perspective's KG is one
115// contiguous range. ctx=0 is the base graph.
116pub fn edge(ctx: u64, src: u64, ty: u64, dst: u64) -> Vec<u8> {
117    if ctx == 0 { k(TAG_EDGE, &[src, ty, dst]) } else { k(TAG_CEDGE, &[ctx, src, ty, dst]) }
118}
119pub fn redge(ctx: u64, dst: u64, ty: u64, src: u64) -> Vec<u8> {
120    if ctx == 0 { k(TAG_REDGE, &[dst, ty, src]) } else { k(TAG_CREDGE, &[ctx, dst, ty, src]) }
121}
122pub fn edge_prefix(ctx: u64, src: u64) -> Vec<u8> {
123    if ctx == 0 { k(TAG_EDGE, &[src]) } else { k(TAG_CEDGE, &[ctx, src]) }
124}
125pub fn ctx_prefix(ctx: u64) -> Vec<u8> {
126    if ctx == 0 { vec![TAG_EDGE] } else { k(TAG_CEDGE, &[ctx]) }
127}
128pub fn vec_key(field: u64, id: u64) -> Vec<u8> { k(TAG_VEC, &[field, id]) }
129pub fn vec_prefix(field: u64) -> Vec<u8> { k(TAG_VEC, &[field]) }
130pub fn vcode_key(field: u64, id: u64) -> Vec<u8> { k(TAG_VCODE, &[field, id]) }
131pub fn vcode_prefix(field: u64) -> Vec<u8> { k(TAG_VCODE, &[field]) }
132pub fn nav_prefix(field: u64) -> Vec<u8> { k(TAG_NAV, &[field]) }
133
134pub fn text_head_key(field: u64, term: &[u8], docid: u64) -> Vec<u8> {
135    let mut v = Vec::with_capacity(1 + 8 + 4 + term.len() + 1 + 8);
136    v.push(TAG_TEXT);
137    v.extend_from_slice(&field.to_be_bytes());
138    v.extend_from_slice(&0u32.to_be_bytes());
139    v.extend_from_slice(term);
140    v.push(0x00);
141    v.extend_from_slice(&docid.to_be_bytes());
142    v
143}
144/// One document-membership row in the mutable text head.  Empty terms do not
145/// exist, so the zero byte after segment 0 is an unambiguous namespace for
146/// streaming/counting the documents owned by the head.
147pub fn text_head_doc_key(field: u64, docid: u64) -> Vec<u8> {
148    text_head_key(field, b"", docid)
149}
150pub fn text_seg_key(field: u64, seg: u32, term: &[u8]) -> Vec<u8> {
151    let mut v = Vec::with_capacity(1 + 8 + 4 + term.len());
152    v.push(TAG_TEXT);
153    v.extend_from_slice(&field.to_be_bytes());
154    v.extend_from_slice(&seg.to_be_bytes());
155    v.extend_from_slice(term);
156    v
157}
158/// Prefix and key for a bounded immutable posting block.  Token bytes never
159/// contain zero, so `[term|0x00|first_doc]` remains prefix-seekable by term.
160pub fn text_seg_block_prefix(field: u64, seg: u32, term: &[u8]) -> Vec<u8> {
161    let mut v = text_seg_key(field, seg, term);
162    v.push(0);
163    v
164}
165pub fn text_seg_block_key(field: u64, seg: u32, term: &[u8], first_doc: u64) -> Vec<u8> {
166    let mut v = text_seg_block_prefix(field, seg, term);
167    v.extend_from_slice(&first_doc.to_be_bytes());
168    v
169}
170/// Per-document membership/length row for an immutable segment.  Ownership
171/// metadata belongs under TEXTMETA so folded posting-row shape stays stable.
172pub fn text_seg_doc_prefix(field: u64, seg: u32) -> Vec<u8> {
173    let mut v = text_meta_key(field, seg);
174    v.push(0);
175    v
176}
177pub fn text_seg_doc_key(field: u64, seg: u32, docid: u64) -> Vec<u8> {
178    let mut v = text_seg_doc_prefix(field, seg);
179    v.extend_from_slice(&docid.to_be_bytes());
180    v
181}
182pub fn text_norm_key(field: u64, docid: u64) -> Vec<u8> { k(TAG_TEXTNORM, &[field, docid]) }
183pub fn text_prefix(field: u64) -> Vec<u8> { k(TAG_TEXT, &[field]) }
184pub fn text_norm_prefix(field: u64) -> Vec<u8> { k(TAG_TEXTNORM, &[field]) }
185pub fn text_meta_prefix(field: u64) -> Vec<u8> { k(TAG_TEXTMETA, &[field]) }
186pub fn spat_key(field: u64, level: u8, cell: u64, id: u64) -> Vec<u8> {
187    let mut v = Vec::with_capacity(1 + 8 + 1 + 8 + 8);
188    v.push(TAG_SPAT);
189    v.extend_from_slice(&field.to_be_bytes());
190    v.push(level);
191    v.extend_from_slice(&cell.to_be_bytes());
192    v.extend_from_slice(&id.to_be_bytes());
193    v
194}
195pub fn geom_key(field: u64, id: u64) -> Vec<u8> { k(TAG_GEOM, &[field, id]) }
196pub fn geom_prefix(field: u64) -> Vec<u8> { k(TAG_GEOM, &[field]) }
197pub fn nav_key(field: u64, id: u64) -> Vec<u8> { k(TAG_NAV, &[field, id]) }
198/// (kind, hash) -> bytes. kind: 0 = collection name, 1 = table schema,
199/// 2 = edge-type name, 3 = index definition, 4 = edge-insertion counter
200/// (hash 0; the SQL layer's monotonic edge seq), 6 = vector field name.
201/// (coll_hash, field_hash, value_bytes, id). id is the fixed 8-byte
202/// suffix -- positional, never searched, so value bytes are unrestricted.
203pub fn fieldidx_key(coll: u64, field: u64, value: &[u8], id: u64) -> Vec<u8> {
204    let mut v = Vec::with_capacity(17 + value.len() + 8);
205    v.push(TAG_FIELDIDX);
206    v.extend_from_slice(&coll.to_be_bytes());
207    v.extend_from_slice(&field.to_be_bytes());
208    v.extend_from_slice(value);
209    v.extend_from_slice(&id.to_be_bytes());
210    v
211}
212pub fn fieldidx_prefix(coll: u64, field: u64) -> Vec<u8> {
213    let mut v = Vec::with_capacity(17);
214    v.push(TAG_FIELDIDX);
215    v.extend_from_slice(&coll.to_be_bytes());
216    v.extend_from_slice(&field.to_be_bytes());
217    v
218}
219
220pub fn searchslot_key(idx: u64, kind: u8, id: u64) -> Vec<u8> {
221    let mut v = Vec::with_capacity(18);
222    v.push(TAG_SEARCHSLOT);
223    v.extend_from_slice(&idx.to_be_bytes());
224    v.push(kind);
225    v.extend_from_slice(&id.to_be_bytes());
226    v
227}
228
229pub fn searchslot_prefix(idx: u64) -> Vec<u8> {
230    let mut v = Vec::with_capacity(9);
231    v.push(TAG_SEARCHSLOT);
232    v.extend_from_slice(&idx.to_be_bytes());
233    v
234}
235
236pub fn sqlmeta_key(kind: u8, hash: u64) -> Vec<u8> {
237    let mut v = Vec::with_capacity(10);
238    v.push(TAG_SQLMETA); v.push(kind); v.extend_from_slice(&hash.to_be_bytes()); v
239}
240pub fn spat_prefix(field: u64, level: u8) -> Vec<u8> {
241    let mut v = Vec::with_capacity(1 + 8 + 1);
242    v.push(TAG_SPAT);
243    v.extend_from_slice(&field.to_be_bytes());
244    v.push(level);
245    v
246}
247
248pub fn text_meta_key(field: u64, seg: u32) -> Vec<u8> {
249    let mut v = Vec::with_capacity(1 + 8 + 4);
250    v.push(TAG_TEXTMETA);
251    v.extend_from_slice(&field.to_be_bytes());
252    v.extend_from_slice(&seg.to_be_bytes());
253    v
254}
255/// Reserved metadata coordinates. Segment ids are allocated below these two
256/// values; keeping them under the existing metadata tag avoids minting a tag
257/// above append-heavy data keyspaces (the measured occupancy trap in D30).
258pub const TEXT_TERM_STATS_SEG: u32 = u32::MAX - 1;
259pub const TEXT_FIELD_META_SEG: u32 = u32::MAX;
260pub fn text_field_meta_key(field: u64) -> Vec<u8> {
261    text_meta_key(field, TEXT_FIELD_META_SEG)
262}
263pub fn text_term_id_key(field: u64, term_id: u64) -> Vec<u8> {
264    let mut v = text_meta_key(field, TEXT_TERM_STATS_SEG);
265    v.push(0);
266    v.extend_from_slice(&term_id.to_be_bytes());
267    v
268}
269/// Lexically ordered live-term directory used by prefix and typo expansion.
270/// Counts remain in the hashed point-lookup rows above; this second view keeps
271/// expansion from walking posting blocks or the number of immutable batches.
272pub fn text_term_lex_prefix(field: u64) -> Vec<u8> {
273    let mut v = text_meta_key(field, TEXT_TERM_STATS_SEG);
274    v.push(1);
275    v
276}
277pub fn text_term_lex_key(field: u64, term: &[u8]) -> Vec<u8> {
278    let mut v = text_term_lex_prefix(field);
279    v.extend_from_slice(term);
280    v
281}
282pub fn label(l: u64, id: u64) -> Vec<u8> { k(TAG_LABEL, &[l, id]) }
283pub fn label_prefix(l: u64) -> Vec<u8> { k(TAG_LABEL, &[l]) }
284pub fn edge_type_prefix(ctx: u64, src: u64, ty: u64) -> Vec<u8> {
285    if ctx == 0 { k(TAG_EDGE, &[src, ty]) } else { k(TAG_CEDGE, &[ctx, src, ty]) }
286}
287pub fn redge_prefix(ctx: u64, dst: u64) -> Vec<u8> {
288    if ctx == 0 { k(TAG_REDGE, &[dst]) } else { k(TAG_CREDGE, &[ctx, dst]) }
289}
290pub fn redge_type_prefix(ctx: u64, dst: u64, ty: u64) -> Vec<u8> {
291    if ctx == 0 { k(TAG_REDGE, &[dst, ty]) } else { k(TAG_CREDGE, &[ctx, dst, ty]) }
292}
293pub fn extkey(h: u64) -> Vec<u8> { k(TAG_EXT, &[h]) }
294pub fn catalog(field: u64) -> Vec<u8> { k(TAG_CATALOG, &[field]) }
295/// A catalog slot subdivided PER FIELD: 0x00 | slot | field, 17 bytes.
296/// It cannot collide with the 9-byte global slots -- different lengths are
297/// different keys -- which is why an arbitrary field hash is safe here
298/// while `catalog(field_hash)` would not be: that would land on whichever
299/// global slot the hash happened to equal.
300///
301/// It has to be the CATALOG and not a fresh tag. Every keyspace shares one
302/// tree, and the leaf splitter only closes a left page full when the
303/// growth point is the RIGHTMOST leaf. A per-field vector record under a
304/// tag above 0x0B put permanent rows past the fingerprints, so appending a
305/// fingerprint stopped being an append: leaves settled at 4 entries where
306/// they had held 7, the file grew 5% and every scan read the extra pages.
307/// The catalog sorts FIRST, ahead of every data keyspace, so it disturbs
308/// no space's growth point.
309pub fn catalog_field(slot: u64, field: u64) -> Vec<u8> { k(TAG_CATALOG, &[slot, field]) }
310pub fn catalog_field_prefix(slot: u64) -> Vec<u8> { k(TAG_CATALOG, &[slot]) }
311/// A per-item record within a catalog field: 0x00 | slot | field | item.
312/// Use this for sparse bookkeeping that must sort ahead of every data
313/// keyspace; putting it under a later tag changes right-edge append splits.
314pub fn catalog_field_item(slot: u64, field: u64, item: u64) -> Vec<u8> {
315    k(TAG_CATALOG, &[slot, field, item])
316}
317
318/// `(catalog, key-order slot, collection, field)` prefix.
319pub fn key_order_prefix(collection: u64, field: u64) -> Vec<u8> {
320    k(TAG_CATALOG, &[CAT_KEY_ORDER, collection, field])
321}
322
323/// One live-row entry. NUL is escaped as `00 ff`, then `00 00` terminates the
324/// UTF-8 key before the row hash tie-breaker. This is order preserving (a key
325/// sorts before every longer key it prefixes), supports embedded NUL, and makes
326/// duplicate logical keys distinct physical rows rather than an overwritten
327/// value. The key bytes remain covering and need no payload read.
328pub fn key_order_key(collection: u64, field: u64, key: &[u8], id: u64) -> Vec<u8> {
329    let mut out = key_order_prefix(collection, field);
330    for &byte in key {
331        if byte == 0 { out.extend_from_slice(&[0, u8::MAX]); }
332        else { out.push(byte); }
333    }
334    out.extend_from_slice(&[0, 0]);
335    out.extend_from_slice(&id.to_be_bytes());
336    out
337}
338
339/// FNV-1a 64. Only contract: same bytes -> same hash. Collision = wrong id
340/// returned by resolve(); the extkey VALUE stores the full external key so a
341/// collision is DETECTED (compare) rather than silently wrong.
342pub fn ext_hash(b: &[u8]) -> u64 {
343    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
344    for x in b { h ^= *x as u64; h = h.wrapping_mul(0x1000_0000_01b3); }
345    h
346}
347
348pub fn prop(prop_id: u64, value: u64, id: u64) -> Vec<u8> { k(TAG_PROP, &[prop_id, value, id]) }
349pub fn prop_prefix(prop_id: u64) -> Vec<u8> { k(TAG_PROP, &[prop_id]) }
350pub fn prop_value_prefix(prop_id: u64, value: u64) -> Vec<u8> { k(TAG_PROP, &[prop_id, value]) }
351
352/// Order-preserving encodings: byte order of the 8-byte result == the natural
353/// order of the value, which is the whole trick that turns queries into ranges.
354/// i64: flip the sign bit. f64 (IEEE 754): negative -> flip ALL bits,
355/// non-negative -> flip the sign bit; total order matches numeric order
356/// (NaN sorts above +inf; callers who care filter NaN before indexing).
357pub fn enc_i64(v: i64) -> u64 { (v as u64) ^ (1 << 63) }
358pub fn enc_f64(v: f64) -> u64 {
359    let b = v.to_bits();
360    if b >> 63 == 1 { !b } else { b ^ (1 << 63) }
361}
362/// Descending variants: an ascending scan over these yields DESC order, which
363/// is how top-k works on a forward-only iterator.
364pub fn enc_f64_desc(v: f64) -> u64 { !enc_f64(v) }
365pub fn enc_i64_desc(v: i64) -> u64 { !enc_i64(v) }
366
367/// Decode helpers for scans.
368pub fn u64_at(k: &[u8], off: usize) -> u64 {
369    let mut w = [0u8; 8]; w.copy_from_slice(&k[off..off+8]); u64::from_be_bytes(w)
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    #[test]
376    fn byte_order_is_numeric_order_and_tags_never_interleave() {
377        let mut ks: Vec<Vec<u8>> = (0..64u64).map(|i| edge(0, i * 7919, 1, i)).collect();
378        let want = ks.clone();
379        ks.sort();
380        assert_eq!(ks, want, "big-endian keys must already be sorted");
381        assert!(node(u64::MAX) < edge(0, 0, 0, 0), "node space must sort before edge space");
382        assert!(edge(0, u64::MAX, u64::MAX, u64::MAX) < redge(0, 0, 0, 0));
383        assert!(redge(0, u64::MAX, 0, 0) < edge(1, 0, 0, 0), "ctx spaces sort after base");
384        assert!(edge(1, u64::MAX, 0, 0) < redge(1, 0, 0, 0));
385    }
386    #[test]
387    fn adjacency_is_a_contiguous_prefix() {
388        let pre = edge_prefix(0, 42);
389        for d in 0..16u64 {
390            assert!(edge(0, 42, 7, d).starts_with(&pre));
391            assert!(!edge(0, 43, 7, d).starts_with(&pre));
392            assert!(!edge(1, 42, 7, d).starts_with(&pre), "another ctx leaked in");
393        }
394        // a whole perspective is one contiguous prefix
395        let kg = ctx_prefix(5);
396        assert!(edge(5, 0, 0, 0).starts_with(&kg) && edge(5, u64::MAX, 1, 1).starts_with(&kg));
397        assert!(!edge(6, 0, 0, 0).starts_with(&kg));
398    }
399}