Skip to main content

sochdb_core/
edge_encoding.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// SochDB - LLM-Optimized Embedded Database
3// Copyright (C) 2026 Sushanth Reddy Vanagala (https://github.com/sushanthpy)
4//
5// This program is free software: you can redistribute it and/or modify
6// it under the terms of the GNU Affero General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// This program is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU Affero General Public License for more details.
14//
15// You should have received a copy of the GNU Affero General Public License
16// along with this program. If not, see <https://www.gnu.org/licenses/>.
17
18//! Binary edge key encoding for the graph overlay.
19//!
20//! Replaces the previous UTF-8 string key format with compact binary keys
21//! that support efficient prefix scans and maintain lexicographic ordering.
22//!
23//! # Key Layout
24//!
25//! All keys start with a one-byte tag that identifies the key type:
26//!
27//! | Tag  | Kind          | Format                                                        |
28//! |------|---------------|---------------------------------------------------------------|
29//! | 0x01 | Node          | `[0x01][ns_hash: 4B][table_hash: 4B][tag: 1B][id_bytes]`     |
30//! | 0x02 | Edge          | `[0x02][ns_hash: 4B][from_key...][et_hash: 4B][to_key...]`   |
31//! | 0x03 | Reverse Index | `[0x03][ns_hash: 4B][et_hash: 4B][to_key...][from_key...]`   |
32//!
33//! Where `from_key` / `to_key` are `[table_hash: 4B][tag: 1B][id_bytes]` (the RecordId key).
34//!
35//! The namespace hash ensures graphs in different namespaces never collide.
36//! All hashes are FNV-1a 32-bit, big-endian encoded for sort ordering.
37
38use crate::record_id::RecordId;
39use crate::soch::SochValue;
40
41/// Tag bytes for key type discrimination.
42const TAG_NODE: u8 = 0x01;
43const TAG_EDGE: u8 = 0x02;
44const TAG_REVERSE: u8 = 0x03;
45
46/// Length-prefixed sub-key: `[len: u16 BE][bytes]`.
47/// Used to delimit variable-length RecordId keys within edge keys.
48fn write_length_prefixed(buf: &mut Vec<u8>, data: &[u8]) {
49    assert!(data.len() <= u16::MAX as usize, "sub-key too long");
50    buf.extend_from_slice(&(data.len() as u16).to_be_bytes());
51    buf.extend_from_slice(data);
52}
53
54/// Read a length-prefixed sub-key, returning `(bytes, rest)`.
55fn read_length_prefixed(data: &[u8]) -> Option<(&[u8], &[u8])> {
56    if data.len() < 2 {
57        return None;
58    }
59    let len = u16::from_be_bytes([data[0], data[1]]) as usize;
60    let rest = &data[2..];
61    if rest.len() < len {
62        return None;
63    }
64    Some((&rest[..len], &rest[len..]))
65}
66
67/// FNV-1a 32-bit hash (same as RecordId::table_hash, duplicated to avoid coupling).
68fn fnv1a_32(bytes: &[u8]) -> u32 {
69    let mut hash: u32 = 0x811c9dc5;
70    for &b in bytes {
71        hash ^= b as u32;
72        hash = hash.wrapping_mul(0x01000193);
73    }
74    hash
75}
76
77/// Build a node storage key.
78///
79/// Format: `[0x01][ns_hash: 4B][record_id_key]`
80pub fn node_key(namespace: &str, record_id: &RecordId) -> Vec<u8> {
81    let ns_hash = fnv1a_32(namespace.as_bytes());
82    let rid_key = record_id.to_key();
83    let mut key = Vec::with_capacity(1 + 4 + rid_key.len());
84    key.push(TAG_NODE);
85    key.extend_from_slice(&ns_hash.to_be_bytes());
86    key.extend_from_slice(&rid_key);
87    key
88}
89
90/// Build a node prefix for scanning all nodes in a namespace.
91///
92/// Format: `[0x01][ns_hash: 4B]`
93pub fn node_prefix(namespace: &str) -> Vec<u8> {
94    let ns_hash = fnv1a_32(namespace.as_bytes());
95    let mut key = Vec::with_capacity(5);
96    key.push(TAG_NODE);
97    key.extend_from_slice(&ns_hash.to_be_bytes());
98    key
99}
100
101/// Build a node prefix for scanning all nodes of a specific table in a namespace.
102///
103/// Format: `[0x01][ns_hash: 4B][table_hash: 4B]`
104pub fn node_table_prefix(namespace: &str, table: &str) -> Vec<u8> {
105    let ns_hash = fnv1a_32(namespace.as_bytes());
106    let tbl_prefix = RecordId::table_prefix(table);
107    let mut key = Vec::with_capacity(1 + 4 + tbl_prefix.len());
108    key.push(TAG_NODE);
109    key.extend_from_slice(&ns_hash.to_be_bytes());
110    key.extend_from_slice(&tbl_prefix);
111    key
112}
113
114/// Build an edge storage key.
115///
116/// Format: `[0x02][ns_hash: 4B][from_key_len: 2B][from_key][et_hash: 4B][to_key_len: 2B][to_key]`
117pub fn edge_key(
118    namespace: &str,
119    from_id: &RecordId,
120    edge_type: &str,
121    to_id: &RecordId,
122) -> Vec<u8> {
123    let ns_hash = fnv1a_32(namespace.as_bytes());
124    let et_hash = fnv1a_32(edge_type.as_bytes());
125    let from_key = from_id.to_key();
126    let to_key = to_id.to_key();
127    let mut key = Vec::with_capacity(1 + 4 + 2 + from_key.len() + 4 + 2 + to_key.len());
128    key.push(TAG_EDGE);
129    key.extend_from_slice(&ns_hash.to_be_bytes());
130    write_length_prefixed(&mut key, &from_key);
131    key.extend_from_slice(&et_hash.to_be_bytes());
132    write_length_prefixed(&mut key, &to_key);
133    key
134}
135
136/// Build an edge prefix for scanning all edges from a node.
137///
138/// Format: `[0x02][ns_hash: 4B][from_key_len: 2B][from_key]`
139pub fn edge_from_prefix(namespace: &str, from_id: &RecordId) -> Vec<u8> {
140    let ns_hash = fnv1a_32(namespace.as_bytes());
141    let from_key = from_id.to_key();
142    let mut key = Vec::with_capacity(1 + 4 + 2 + from_key.len());
143    key.push(TAG_EDGE);
144    key.extend_from_slice(&ns_hash.to_be_bytes());
145    write_length_prefixed(&mut key, &from_key);
146    key
147}
148
149/// Build an edge prefix for scanning edges of a specific type from a node.
150///
151/// Format: `[0x02][ns_hash: 4B][from_key_len: 2B][from_key][et_hash: 4B]`
152pub fn edge_from_type_prefix(
153    namespace: &str,
154    from_id: &RecordId,
155    edge_type: &str,
156) -> Vec<u8> {
157    let ns_hash = fnv1a_32(namespace.as_bytes());
158    let et_hash = fnv1a_32(edge_type.as_bytes());
159    let from_key = from_id.to_key();
160    let mut key = Vec::with_capacity(1 + 4 + 2 + from_key.len() + 4);
161    key.push(TAG_EDGE);
162    key.extend_from_slice(&ns_hash.to_be_bytes());
163    write_length_prefixed(&mut key, &from_key);
164    key.extend_from_slice(&et_hash.to_be_bytes());
165    key
166}
167
168/// Build an edge prefix for scanning all edges in a namespace.
169///
170/// Format: `[0x02][ns_hash: 4B]`
171pub fn edge_prefix(namespace: &str) -> Vec<u8> {
172    let ns_hash = fnv1a_32(namespace.as_bytes());
173    let mut key = Vec::with_capacity(5);
174    key.push(TAG_EDGE);
175    key.extend_from_slice(&ns_hash.to_be_bytes());
176    key
177}
178
179/// Build a reverse index key.
180///
181/// Format: `[0x03][ns_hash: 4B][et_hash: 4B][to_key_len: 2B][to_key][from_key_len: 2B][from_key]`
182pub fn reverse_key(
183    namespace: &str,
184    edge_type: &str,
185    to_id: &RecordId,
186    from_id: &RecordId,
187) -> Vec<u8> {
188    let ns_hash = fnv1a_32(namespace.as_bytes());
189    let et_hash = fnv1a_32(edge_type.as_bytes());
190    let to_key = to_id.to_key();
191    let from_key = from_id.to_key();
192    let mut key = Vec::with_capacity(1 + 4 + 4 + 2 + to_key.len() + 2 + from_key.len());
193    key.push(TAG_REVERSE);
194    key.extend_from_slice(&ns_hash.to_be_bytes());
195    key.extend_from_slice(&et_hash.to_be_bytes());
196    write_length_prefixed(&mut key, &to_key);
197    write_length_prefixed(&mut key, &from_key);
198    key
199}
200
201/// Build a reverse index prefix for all edges of a given type pointing to a node.
202///
203/// Format: `[0x03][ns_hash: 4B][et_hash: 4B][to_key_len: 2B][to_key]`
204pub fn reverse_type_to_prefix(
205    namespace: &str,
206    edge_type: &str,
207    to_id: &RecordId,
208) -> Vec<u8> {
209    let ns_hash = fnv1a_32(namespace.as_bytes());
210    let et_hash = fnv1a_32(edge_type.as_bytes());
211    let to_key = to_id.to_key();
212    let mut key = Vec::with_capacity(1 + 4 + 4 + 2 + to_key.len());
213    key.push(TAG_REVERSE);
214    key.extend_from_slice(&ns_hash.to_be_bytes());
215    key.extend_from_slice(&et_hash.to_be_bytes());
216    write_length_prefixed(&mut key, &to_key);
217    key
218}
219
220/// Build a reverse index prefix for all reverse entries in a namespace.
221///
222/// Format: `[0x03][ns_hash: 4B]`
223pub fn reverse_prefix(namespace: &str) -> Vec<u8> {
224    let ns_hash = fnv1a_32(namespace.as_bytes());
225    let mut key = Vec::with_capacity(5);
226    key.push(TAG_REVERSE);
227    key.extend_from_slice(&ns_hash.to_be_bytes());
228    key
229}
230
231/// Decoded edge key components.
232#[derive(Debug, Clone)]
233pub struct DecodedEdgeKey {
234    pub from_key: Vec<u8>,
235    pub edge_type_hash: u32,
236    pub to_key: Vec<u8>,
237}
238
239/// Decode an edge key (tag 0x02) into its components.
240///
241/// Returns None if the key is malformed or not an edge key.
242pub fn decode_edge_key(key: &[u8]) -> Option<DecodedEdgeKey> {
243    if key.is_empty() || key[0] != TAG_EDGE {
244        return None;
245    }
246    let rest = &key[1..]; // skip tag
247    if rest.len() < 4 {
248        return None;
249    }
250    let rest = &rest[4..]; // skip ns_hash
251
252    let (from_key, rest) = read_length_prefixed(rest)?;
253    if rest.len() < 4 {
254        return None;
255    }
256    let et_hash = u32::from_be_bytes([rest[0], rest[1], rest[2], rest[3]]);
257    let rest = &rest[4..];
258    let (to_key, _rest) = read_length_prefixed(rest)?;
259
260    Some(DecodedEdgeKey {
261        from_key: from_key.to_vec(),
262        edge_type_hash: et_hash,
263        to_key: to_key.to_vec(),
264    })
265}
266
267/// Decoded reverse index key components.
268#[derive(Debug, Clone)]
269pub struct DecodedReverseKey {
270    pub edge_type_hash: u32,
271    pub to_key: Vec<u8>,
272    pub from_key: Vec<u8>,
273}
274
275/// Decode a reverse index key (tag 0x03) into its components.
276pub fn decode_reverse_key(key: &[u8]) -> Option<DecodedReverseKey> {
277    if key.is_empty() || key[0] != TAG_REVERSE {
278        return None;
279    }
280    let rest = &key[1..];
281    if rest.len() < 4 {
282        return None;
283    }
284    let rest = &rest[4..]; // skip ns_hash
285    if rest.len() < 4 {
286        return None;
287    }
288    let et_hash = u32::from_be_bytes([rest[0], rest[1], rest[2], rest[3]]);
289    let rest = &rest[4..];
290    let (to_key, rest) = read_length_prefixed(rest)?;
291    let (from_key, _rest) = read_length_prefixed(rest)?;
292
293    Some(DecodedReverseKey {
294        edge_type_hash: et_hash,
295        to_key: to_key.to_vec(),
296        from_key: from_key.to_vec(),
297    })
298}
299
300/// Encode a `HashMap<String, SochValue>` as a compact binary value.
301///
302/// Format: `[num_entries: u32 BE] { [key_len: u16 BE][key_utf8][value_json_len: u32 BE][value_json] }*`
303///
304/// Uses JSON for individual SochValues as a pragmatic choice — the hot path is keys,
305/// and values are typically small property bags. A future optimization can replace this
306/// with PackedRow encoding without changing the key format.
307pub fn encode_properties(props: &std::collections::HashMap<String, SochValue>) -> Vec<u8> {
308    let mut buf = Vec::new();
309    buf.extend_from_slice(&(props.len() as u32).to_be_bytes());
310    for (k, v) in props {
311        let key_bytes = k.as_bytes();
312        buf.extend_from_slice(&(key_bytes.len() as u16).to_be_bytes());
313        buf.extend_from_slice(key_bytes);
314        // Encode SochValue as JSON for now (pragmatic; PackedRow upgrade later)
315        let val_json = serde_json::to_vec(v).unwrap_or_default();
316        buf.extend_from_slice(&(val_json.len() as u32).to_be_bytes());
317        buf.extend_from_slice(&val_json);
318    }
319    buf
320}
321
322/// Decode a `HashMap<String, SochValue>` from compact binary encoding.
323pub fn decode_properties(data: &[u8]) -> Option<std::collections::HashMap<String, SochValue>> {
324    if data.len() < 4 {
325        return None;
326    }
327    let num = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
328    let mut offset = 4;
329    let mut map = std::collections::HashMap::with_capacity(num);
330
331    for _ in 0..num {
332        if offset + 2 > data.len() {
333            return None;
334        }
335        let key_len = u16::from_be_bytes([data[offset], data[offset + 1]]) as usize;
336        offset += 2;
337        if offset + key_len > data.len() {
338            return None;
339        }
340        let key = std::str::from_utf8(&data[offset..offset + key_len]).ok()?.to_string();
341        offset += key_len;
342
343        if offset + 4 > data.len() {
344            return None;
345        }
346        let val_len = u32::from_be_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]) as usize;
347        offset += 4;
348        if offset + val_len > data.len() {
349            return None;
350        }
351        let val: SochValue = serde_json::from_slice(&data[offset..offset + val_len]).ok()?;
352        offset += val_len;
353
354        map.insert(key, val);
355    }
356
357    Some(map)
358}
359
360/// Encode a node value (node_type + properties) as binary.
361///
362/// Format: `[type_len: u16 BE][type_utf8][properties_bytes]`
363pub fn encode_node_value(node_type: &str, props: &std::collections::HashMap<String, SochValue>) -> Vec<u8> {
364    let type_bytes = node_type.as_bytes();
365    let props_bytes = encode_properties(props);
366    let mut buf = Vec::with_capacity(2 + type_bytes.len() + props_bytes.len());
367    buf.extend_from_slice(&(type_bytes.len() as u16).to_be_bytes());
368    buf.extend_from_slice(type_bytes);
369    buf.extend_from_slice(&props_bytes);
370    buf
371}
372
373/// Decode a node value into (node_type, properties).
374pub fn decode_node_value(data: &[u8]) -> Option<(String, std::collections::HashMap<String, SochValue>)> {
375    if data.len() < 2 {
376        return None;
377    }
378    let type_len = u16::from_be_bytes([data[0], data[1]]) as usize;
379    if data.len() < 2 + type_len {
380        return None;
381    }
382    let node_type = std::str::from_utf8(&data[2..2 + type_len]).ok()?.to_string();
383    let props = decode_properties(&data[2 + type_len..])?;
384    Some((node_type, props))
385}
386
387/// Encode an edge value (from_table, from_id_display, edge_type, to_table, to_id_display + properties).
388///
389/// Format: `[edge_type_len: u16 BE][edge_type_utf8][from_rid_str_len: u16 BE][from_rid_str][to_rid_str_len: u16 BE][to_rid_str][properties_bytes]`
390///
391/// We store the full RecordId display strings so we can reconstitute them on read
392/// without needing another lookup.
393pub fn encode_edge_value(
394    from_id: &RecordId,
395    edge_type: &str,
396    to_id: &RecordId,
397    props: &std::collections::HashMap<String, SochValue>,
398) -> Vec<u8> {
399    let et_bytes = edge_type.as_bytes();
400    let from_str = from_id.to_string();
401    let from_bytes = from_str.as_bytes();
402    let to_str = to_id.to_string();
403    let to_bytes = to_str.as_bytes();
404    let props_bytes = encode_properties(props);
405
406    let mut buf = Vec::with_capacity(2 + et_bytes.len() + 2 + from_bytes.len() + 2 + to_bytes.len() + props_bytes.len());
407    buf.extend_from_slice(&(et_bytes.len() as u16).to_be_bytes());
408    buf.extend_from_slice(et_bytes);
409    buf.extend_from_slice(&(from_bytes.len() as u16).to_be_bytes());
410    buf.extend_from_slice(from_bytes);
411    buf.extend_from_slice(&(to_bytes.len() as u16).to_be_bytes());
412    buf.extend_from_slice(to_bytes);
413    buf.extend_from_slice(&props_bytes);
414    buf
415}
416
417/// Edge value decoded components.
418#[derive(Debug, Clone)]
419pub struct DecodedEdgeValue {
420    pub edge_type: String,
421    pub from_id: RecordId,
422    pub to_id: RecordId,
423    pub properties: std::collections::HashMap<String, SochValue>,
424}
425
426/// Decode an edge value.
427pub fn decode_edge_value(data: &[u8]) -> Option<DecodedEdgeValue> {
428    let mut offset = 0;
429
430    // edge_type
431    if offset + 2 > data.len() { return None; }
432    let et_len = u16::from_be_bytes([data[offset], data[offset + 1]]) as usize;
433    offset += 2;
434    if offset + et_len > data.len() { return None; }
435    let edge_type = std::str::from_utf8(&data[offset..offset + et_len]).ok()?.to_string();
436    offset += et_len;
437
438    // from_id
439    if offset + 2 > data.len() { return None; }
440    let from_len = u16::from_be_bytes([data[offset], data[offset + 1]]) as usize;
441    offset += 2;
442    if offset + from_len > data.len() { return None; }
443    let from_str = std::str::from_utf8(&data[offset..offset + from_len]).ok()?;
444    let from_id = RecordId::parse(from_str)?;
445    offset += from_len;
446
447    // to_id
448    if offset + 2 > data.len() { return None; }
449    let to_len = u16::from_be_bytes([data[offset], data[offset + 1]]) as usize;
450    offset += 2;
451    if offset + to_len > data.len() { return None; }
452    let to_str = std::str::from_utf8(&data[offset..offset + to_len]).ok()?;
453    let to_id = RecordId::parse(to_str)?;
454    offset += to_len;
455
456    // properties
457    let props = decode_properties(&data[offset..])?;
458
459    Some(DecodedEdgeValue {
460        edge_type,
461        from_id,
462        to_id,
463        properties: props,
464    })
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    // SochValue is already imported via `use crate::soch::SochValue` in the parent module
472
473    #[test]
474    fn test_node_key_format() {
475        let rid = RecordId::new("person", 42);
476        let key = node_key("agent_001", &rid);
477        assert_eq!(key[0], TAG_NODE);
478        // Namespace hash occupies bytes 1..5
479        // RecordId key follows
480        assert!(key.len() > 5);
481    }
482
483    #[test]
484    fn test_node_prefix_is_prefix_of_node_key() {
485        let rid = RecordId::new("person", 42);
486        let key = node_key("agent_001", &rid);
487        let prefix = node_prefix("agent_001");
488        assert!(key.starts_with(&prefix));
489    }
490
491    #[test]
492    fn test_edge_key_roundtrip() {
493        let from = RecordId::new("user", 1);
494        let to = RecordId::new("conv", 100);
495        let key = edge_key("ns", &from, "STARTED", &to);
496        let decoded = decode_edge_key(&key).unwrap();
497        assert_eq!(decoded.from_key, from.to_key());
498        assert_eq!(decoded.to_key, to.to_key());
499        assert_eq!(decoded.edge_type_hash, fnv1a_32(b"STARTED"));
500    }
501
502    #[test]
503    fn test_edge_from_prefix_is_prefix() {
504        let from = RecordId::new("user", 1);
505        let to = RecordId::new("conv", 100);
506        let key = edge_key("ns", &from, "SENT", &to);
507        let prefix = edge_from_prefix("ns", &from);
508        assert!(key.starts_with(&prefix));
509    }
510
511    #[test]
512    fn test_edge_from_type_prefix_is_prefix() {
513        let from = RecordId::new("user", 1);
514        let to = RecordId::new("conv", 100);
515        let key = edge_key("ns", &from, "SENT", &to);
516        let prefix = edge_from_type_prefix("ns", &from, "SENT");
517        assert!(key.starts_with(&prefix));
518    }
519
520    #[test]
521    fn test_reverse_key_roundtrip() {
522        let from = RecordId::new("user", 1);
523        let to = RecordId::new("msg", 42);
524        let key = reverse_key("ns", "SENT", &to, &from);
525        let decoded = decode_reverse_key(&key).unwrap();
526        assert_eq!(decoded.from_key, from.to_key());
527        assert_eq!(decoded.to_key, to.to_key());
528        assert_eq!(decoded.edge_type_hash, fnv1a_32(b"SENT"));
529    }
530
531    #[test]
532    fn test_reverse_prefix_is_prefix() {
533        let from = RecordId::new("user", 1);
534        let to = RecordId::new("msg", 42);
535        let key = reverse_key("ns", "SENT", &to, &from);
536        let prefix = reverse_type_to_prefix("ns", "SENT", &to);
537        assert!(key.starts_with(&prefix));
538    }
539
540    #[test]
541    fn test_encode_decode_properties() {
542        let mut props = std::collections::HashMap::new();
543        props.insert("name".to_string(), SochValue::Text("Alice".to_string()));
544        props.insert("age".to_string(), SochValue::Int(30));
545        props.insert("active".to_string(), SochValue::Bool(true));
546
547        let encoded = encode_properties(&props);
548        let decoded = decode_properties(&encoded).unwrap();
549
550        assert_eq!(decoded.len(), 3);
551        assert_eq!(decoded.get("name"), Some(&SochValue::Text("Alice".to_string())));
552        assert_eq!(decoded.get("age"), Some(&SochValue::Int(30)));
553        assert_eq!(decoded.get("active"), Some(&SochValue::Bool(true)));
554    }
555
556    #[test]
557    fn test_encode_decode_node_value() {
558        let mut props = std::collections::HashMap::new();
559        props.insert("email".to_string(), SochValue::Text("a@b.com".to_string()));
560
561        let encoded = encode_node_value("User", &props);
562        let (node_type, decoded_props) = decode_node_value(&encoded).unwrap();
563        assert_eq!(node_type, "User");
564        assert_eq!(decoded_props.get("email"), Some(&SochValue::Text("a@b.com".to_string())));
565    }
566
567    #[test]
568    fn test_encode_decode_edge_value() {
569        let from = RecordId::new("user", 1);
570        let to = RecordId::from_string("conv", "abc");
571        let mut props = std::collections::HashMap::new();
572        props.insert("weight".to_string(), SochValue::Float(0.95));
573
574        let encoded = encode_edge_value(&from, "STARTED", &to, &props);
575        let decoded = decode_edge_value(&encoded).unwrap();
576        assert_eq!(decoded.edge_type, "STARTED");
577        assert_eq!(decoded.from_id, from);
578        assert_eq!(decoded.to_id, to);
579        assert_eq!(decoded.properties.get("weight"), Some(&SochValue::Float(0.95)));
580    }
581
582    #[test]
583    fn test_empty_properties() {
584        let props = std::collections::HashMap::new();
585        let encoded = encode_properties(&props);
586        let decoded = decode_properties(&encoded).unwrap();
587        assert!(decoded.is_empty());
588    }
589
590    #[test]
591    fn test_different_namespaces_produce_different_keys() {
592        let rid = RecordId::new("person", 1);
593        let key1 = node_key("ns_a", &rid);
594        let key2 = node_key("ns_b", &rid);
595        assert_ne!(key1, key2);
596    }
597}