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(namespace: &str, from_id: &RecordId, edge_type: &str, to_id: &RecordId) -> Vec<u8> {
118    let ns_hash = fnv1a_32(namespace.as_bytes());
119    let et_hash = fnv1a_32(edge_type.as_bytes());
120    let from_key = from_id.to_key();
121    let to_key = to_id.to_key();
122    let mut key = Vec::with_capacity(1 + 4 + 2 + from_key.len() + 4 + 2 + to_key.len());
123    key.push(TAG_EDGE);
124    key.extend_from_slice(&ns_hash.to_be_bytes());
125    write_length_prefixed(&mut key, &from_key);
126    key.extend_from_slice(&et_hash.to_be_bytes());
127    write_length_prefixed(&mut key, &to_key);
128    key
129}
130
131/// Build an edge prefix for scanning all edges from a node.
132///
133/// Format: `[0x02][ns_hash: 4B][from_key_len: 2B][from_key]`
134pub fn edge_from_prefix(namespace: &str, from_id: &RecordId) -> Vec<u8> {
135    let ns_hash = fnv1a_32(namespace.as_bytes());
136    let from_key = from_id.to_key();
137    let mut key = Vec::with_capacity(1 + 4 + 2 + from_key.len());
138    key.push(TAG_EDGE);
139    key.extend_from_slice(&ns_hash.to_be_bytes());
140    write_length_prefixed(&mut key, &from_key);
141    key
142}
143
144/// Build an edge prefix for scanning edges of a specific type from a node.
145///
146/// Format: `[0x02][ns_hash: 4B][from_key_len: 2B][from_key][et_hash: 4B]`
147pub fn edge_from_type_prefix(namespace: &str, from_id: &RecordId, edge_type: &str) -> Vec<u8> {
148    let ns_hash = fnv1a_32(namespace.as_bytes());
149    let et_hash = fnv1a_32(edge_type.as_bytes());
150    let from_key = from_id.to_key();
151    let mut key = Vec::with_capacity(1 + 4 + 2 + from_key.len() + 4);
152    key.push(TAG_EDGE);
153    key.extend_from_slice(&ns_hash.to_be_bytes());
154    write_length_prefixed(&mut key, &from_key);
155    key.extend_from_slice(&et_hash.to_be_bytes());
156    key
157}
158
159/// Build an edge prefix for scanning all edges in a namespace.
160///
161/// Format: `[0x02][ns_hash: 4B]`
162pub fn edge_prefix(namespace: &str) -> Vec<u8> {
163    let ns_hash = fnv1a_32(namespace.as_bytes());
164    let mut key = Vec::with_capacity(5);
165    key.push(TAG_EDGE);
166    key.extend_from_slice(&ns_hash.to_be_bytes());
167    key
168}
169
170/// Build a reverse index key.
171///
172/// Format: `[0x03][ns_hash: 4B][et_hash: 4B][to_key_len: 2B][to_key][from_key_len: 2B][from_key]`
173pub fn reverse_key(
174    namespace: &str,
175    edge_type: &str,
176    to_id: &RecordId,
177    from_id: &RecordId,
178) -> Vec<u8> {
179    let ns_hash = fnv1a_32(namespace.as_bytes());
180    let et_hash = fnv1a_32(edge_type.as_bytes());
181    let to_key = to_id.to_key();
182    let from_key = from_id.to_key();
183    let mut key = Vec::with_capacity(1 + 4 + 4 + 2 + to_key.len() + 2 + from_key.len());
184    key.push(TAG_REVERSE);
185    key.extend_from_slice(&ns_hash.to_be_bytes());
186    key.extend_from_slice(&et_hash.to_be_bytes());
187    write_length_prefixed(&mut key, &to_key);
188    write_length_prefixed(&mut key, &from_key);
189    key
190}
191
192/// Build a reverse index prefix for all edges of a given type pointing to a node.
193///
194/// Format: `[0x03][ns_hash: 4B][et_hash: 4B][to_key_len: 2B][to_key]`
195pub fn reverse_type_to_prefix(namespace: &str, edge_type: &str, to_id: &RecordId) -> Vec<u8> {
196    let ns_hash = fnv1a_32(namespace.as_bytes());
197    let et_hash = fnv1a_32(edge_type.as_bytes());
198    let to_key = to_id.to_key();
199    let mut key = Vec::with_capacity(1 + 4 + 4 + 2 + to_key.len());
200    key.push(TAG_REVERSE);
201    key.extend_from_slice(&ns_hash.to_be_bytes());
202    key.extend_from_slice(&et_hash.to_be_bytes());
203    write_length_prefixed(&mut key, &to_key);
204    key
205}
206
207/// Build a reverse index prefix for all reverse entries in a namespace.
208///
209/// Format: `[0x03][ns_hash: 4B]`
210pub fn reverse_prefix(namespace: &str) -> Vec<u8> {
211    let ns_hash = fnv1a_32(namespace.as_bytes());
212    let mut key = Vec::with_capacity(5);
213    key.push(TAG_REVERSE);
214    key.extend_from_slice(&ns_hash.to_be_bytes());
215    key
216}
217
218/// Decoded edge key components.
219#[derive(Debug, Clone)]
220pub struct DecodedEdgeKey {
221    pub from_key: Vec<u8>,
222    pub edge_type_hash: u32,
223    pub to_key: Vec<u8>,
224}
225
226/// Decode an edge key (tag 0x02) into its components.
227///
228/// Returns None if the key is malformed or not an edge key.
229pub fn decode_edge_key(key: &[u8]) -> Option<DecodedEdgeKey> {
230    if key.is_empty() || key[0] != TAG_EDGE {
231        return None;
232    }
233    let rest = &key[1..]; // skip tag
234    if rest.len() < 4 {
235        return None;
236    }
237    let rest = &rest[4..]; // skip ns_hash
238
239    let (from_key, rest) = read_length_prefixed(rest)?;
240    if rest.len() < 4 {
241        return None;
242    }
243    let et_hash = u32::from_be_bytes([rest[0], rest[1], rest[2], rest[3]]);
244    let rest = &rest[4..];
245    let (to_key, _rest) = read_length_prefixed(rest)?;
246
247    Some(DecodedEdgeKey {
248        from_key: from_key.to_vec(),
249        edge_type_hash: et_hash,
250        to_key: to_key.to_vec(),
251    })
252}
253
254/// Decoded reverse index key components.
255#[derive(Debug, Clone)]
256pub struct DecodedReverseKey {
257    pub edge_type_hash: u32,
258    pub to_key: Vec<u8>,
259    pub from_key: Vec<u8>,
260}
261
262/// Decode a reverse index key (tag 0x03) into its components.
263pub fn decode_reverse_key(key: &[u8]) -> Option<DecodedReverseKey> {
264    if key.is_empty() || key[0] != TAG_REVERSE {
265        return None;
266    }
267    let rest = &key[1..];
268    if rest.len() < 4 {
269        return None;
270    }
271    let rest = &rest[4..]; // skip ns_hash
272    if rest.len() < 4 {
273        return None;
274    }
275    let et_hash = u32::from_be_bytes([rest[0], rest[1], rest[2], rest[3]]);
276    let rest = &rest[4..];
277    let (to_key, rest) = read_length_prefixed(rest)?;
278    let (from_key, _rest) = read_length_prefixed(rest)?;
279
280    Some(DecodedReverseKey {
281        edge_type_hash: et_hash,
282        to_key: to_key.to_vec(),
283        from_key: from_key.to_vec(),
284    })
285}
286
287/// Encode a `HashMap<String, SochValue>` as a compact binary value.
288///
289/// Format: `[num_entries: u32 BE] { [key_len: u16 BE][key_utf8][value_json_len: u32 BE][value_json] }*`
290///
291/// Uses JSON for individual SochValues as a pragmatic choice — the hot path is keys,
292/// and values are typically small property bags. A future optimization can replace this
293/// with PackedRow encoding without changing the key format.
294pub fn encode_properties(props: &std::collections::HashMap<String, SochValue>) -> Vec<u8> {
295    let mut buf = Vec::new();
296    buf.extend_from_slice(&(props.len() as u32).to_be_bytes());
297    for (k, v) in props {
298        let key_bytes = k.as_bytes();
299        buf.extend_from_slice(&(key_bytes.len() as u16).to_be_bytes());
300        buf.extend_from_slice(key_bytes);
301        // Encode SochValue as JSON for now (pragmatic; PackedRow upgrade later)
302        let val_json = serde_json::to_vec(v).unwrap_or_default();
303        buf.extend_from_slice(&(val_json.len() as u32).to_be_bytes());
304        buf.extend_from_slice(&val_json);
305    }
306    buf
307}
308
309/// Decode a `HashMap<String, SochValue>` from compact binary encoding.
310pub fn decode_properties(data: &[u8]) -> Option<std::collections::HashMap<String, SochValue>> {
311    if data.len() < 4 {
312        return None;
313    }
314    let num = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
315    let mut offset = 4;
316    let mut map = std::collections::HashMap::with_capacity(num);
317
318    for _ in 0..num {
319        if offset + 2 > data.len() {
320            return None;
321        }
322        let key_len = u16::from_be_bytes([data[offset], data[offset + 1]]) as usize;
323        offset += 2;
324        if offset + key_len > data.len() {
325            return None;
326        }
327        let key = std::str::from_utf8(&data[offset..offset + key_len])
328            .ok()?
329            .to_string();
330        offset += key_len;
331
332        if offset + 4 > data.len() {
333            return None;
334        }
335        let val_len = u32::from_be_bytes([
336            data[offset],
337            data[offset + 1],
338            data[offset + 2],
339            data[offset + 3],
340        ]) as usize;
341        offset += 4;
342        if offset + val_len > data.len() {
343            return None;
344        }
345        let val: SochValue = serde_json::from_slice(&data[offset..offset + val_len]).ok()?;
346        offset += val_len;
347
348        map.insert(key, val);
349    }
350
351    Some(map)
352}
353
354/// Encode a node value (node_type + properties) as binary.
355///
356/// Format: `[type_len: u16 BE][type_utf8][properties_bytes]`
357pub fn encode_node_value(
358    node_type: &str,
359    props: &std::collections::HashMap<String, SochValue>,
360) -> Vec<u8> {
361    let type_bytes = node_type.as_bytes();
362    let props_bytes = encode_properties(props);
363    let mut buf = Vec::with_capacity(2 + type_bytes.len() + props_bytes.len());
364    buf.extend_from_slice(&(type_bytes.len() as u16).to_be_bytes());
365    buf.extend_from_slice(type_bytes);
366    buf.extend_from_slice(&props_bytes);
367    buf
368}
369
370/// Decode a node value into (node_type, properties).
371pub fn decode_node_value(
372    data: &[u8],
373) -> Option<(String, std::collections::HashMap<String, SochValue>)> {
374    if data.len() < 2 {
375        return None;
376    }
377    let type_len = u16::from_be_bytes([data[0], data[1]]) as usize;
378    if data.len() < 2 + type_len {
379        return None;
380    }
381    let node_type = std::str::from_utf8(&data[2..2 + type_len])
382        .ok()?
383        .to_string();
384    let props = decode_properties(&data[2 + type_len..])?;
385    Some((node_type, props))
386}
387
388/// Encode an edge value (from_table, from_id_display, edge_type, to_table, to_id_display + properties).
389///
390/// 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]`
391///
392/// We store the full RecordId display strings so we can reconstitute them on read
393/// without needing another lookup.
394pub fn encode_edge_value(
395    from_id: &RecordId,
396    edge_type: &str,
397    to_id: &RecordId,
398    props: &std::collections::HashMap<String, SochValue>,
399) -> Vec<u8> {
400    let et_bytes = edge_type.as_bytes();
401    let from_str = from_id.to_string();
402    let from_bytes = from_str.as_bytes();
403    let to_str = to_id.to_string();
404    let to_bytes = to_str.as_bytes();
405    let props_bytes = encode_properties(props);
406
407    let mut buf = Vec::with_capacity(
408        2 + et_bytes.len() + 2 + from_bytes.len() + 2 + to_bytes.len() + props_bytes.len(),
409    );
410    buf.extend_from_slice(&(et_bytes.len() as u16).to_be_bytes());
411    buf.extend_from_slice(et_bytes);
412    buf.extend_from_slice(&(from_bytes.len() as u16).to_be_bytes());
413    buf.extend_from_slice(from_bytes);
414    buf.extend_from_slice(&(to_bytes.len() as u16).to_be_bytes());
415    buf.extend_from_slice(to_bytes);
416    buf.extend_from_slice(&props_bytes);
417    buf
418}
419
420/// Edge value decoded components.
421#[derive(Debug, Clone)]
422pub struct DecodedEdgeValue {
423    pub edge_type: String,
424    pub from_id: RecordId,
425    pub to_id: RecordId,
426    pub properties: std::collections::HashMap<String, SochValue>,
427}
428
429/// Decode an edge value.
430pub fn decode_edge_value(data: &[u8]) -> Option<DecodedEdgeValue> {
431    let mut offset = 0;
432
433    // edge_type
434    if offset + 2 > data.len() {
435        return None;
436    }
437    let et_len = u16::from_be_bytes([data[offset], data[offset + 1]]) as usize;
438    offset += 2;
439    if offset + et_len > data.len() {
440        return None;
441    }
442    let edge_type = std::str::from_utf8(&data[offset..offset + et_len])
443        .ok()?
444        .to_string();
445    offset += et_len;
446
447    // from_id
448    if offset + 2 > data.len() {
449        return None;
450    }
451    let from_len = u16::from_be_bytes([data[offset], data[offset + 1]]) as usize;
452    offset += 2;
453    if offset + from_len > data.len() {
454        return None;
455    }
456    let from_str = std::str::from_utf8(&data[offset..offset + from_len]).ok()?;
457    let from_id = RecordId::parse(from_str)?;
458    offset += from_len;
459
460    // to_id
461    if offset + 2 > data.len() {
462        return None;
463    }
464    let to_len = u16::from_be_bytes([data[offset], data[offset + 1]]) as usize;
465    offset += 2;
466    if offset + to_len > data.len() {
467        return None;
468    }
469    let to_str = std::str::from_utf8(&data[offset..offset + to_len]).ok()?;
470    let to_id = RecordId::parse(to_str)?;
471    offset += to_len;
472
473    // properties
474    let props = decode_properties(&data[offset..])?;
475
476    Some(DecodedEdgeValue {
477        edge_type,
478        from_id,
479        to_id,
480        properties: props,
481    })
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    // SochValue is already imported via `use crate::soch::SochValue` in the parent module
489
490    #[test]
491    fn test_node_key_format() {
492        let rid = RecordId::new("person", 42);
493        let key = node_key("agent_001", &rid);
494        assert_eq!(key[0], TAG_NODE);
495        // Namespace hash occupies bytes 1..5
496        // RecordId key follows
497        assert!(key.len() > 5);
498    }
499
500    #[test]
501    fn test_node_prefix_is_prefix_of_node_key() {
502        let rid = RecordId::new("person", 42);
503        let key = node_key("agent_001", &rid);
504        let prefix = node_prefix("agent_001");
505        assert!(key.starts_with(&prefix));
506    }
507
508    #[test]
509    fn test_edge_key_roundtrip() {
510        let from = RecordId::new("user", 1);
511        let to = RecordId::new("conv", 100);
512        let key = edge_key("ns", &from, "STARTED", &to);
513        let decoded = decode_edge_key(&key).unwrap();
514        assert_eq!(decoded.from_key, from.to_key());
515        assert_eq!(decoded.to_key, to.to_key());
516        assert_eq!(decoded.edge_type_hash, fnv1a_32(b"STARTED"));
517    }
518
519    #[test]
520    fn test_edge_from_prefix_is_prefix() {
521        let from = RecordId::new("user", 1);
522        let to = RecordId::new("conv", 100);
523        let key = edge_key("ns", &from, "SENT", &to);
524        let prefix = edge_from_prefix("ns", &from);
525        assert!(key.starts_with(&prefix));
526    }
527
528    #[test]
529    fn test_edge_from_type_prefix_is_prefix() {
530        let from = RecordId::new("user", 1);
531        let to = RecordId::new("conv", 100);
532        let key = edge_key("ns", &from, "SENT", &to);
533        let prefix = edge_from_type_prefix("ns", &from, "SENT");
534        assert!(key.starts_with(&prefix));
535    }
536
537    #[test]
538    fn test_reverse_key_roundtrip() {
539        let from = RecordId::new("user", 1);
540        let to = RecordId::new("msg", 42);
541        let key = reverse_key("ns", "SENT", &to, &from);
542        let decoded = decode_reverse_key(&key).unwrap();
543        assert_eq!(decoded.from_key, from.to_key());
544        assert_eq!(decoded.to_key, to.to_key());
545        assert_eq!(decoded.edge_type_hash, fnv1a_32(b"SENT"));
546    }
547
548    #[test]
549    fn test_reverse_prefix_is_prefix() {
550        let from = RecordId::new("user", 1);
551        let to = RecordId::new("msg", 42);
552        let key = reverse_key("ns", "SENT", &to, &from);
553        let prefix = reverse_type_to_prefix("ns", "SENT", &to);
554        assert!(key.starts_with(&prefix));
555    }
556
557    #[test]
558    fn test_encode_decode_properties() {
559        let mut props = std::collections::HashMap::new();
560        props.insert("name".to_string(), SochValue::Text("Alice".to_string()));
561        props.insert("age".to_string(), SochValue::Int(30));
562        props.insert("active".to_string(), SochValue::Bool(true));
563
564        let encoded = encode_properties(&props);
565        let decoded = decode_properties(&encoded).unwrap();
566
567        assert_eq!(decoded.len(), 3);
568        assert_eq!(
569            decoded.get("name"),
570            Some(&SochValue::Text("Alice".to_string()))
571        );
572        assert_eq!(decoded.get("age"), Some(&SochValue::Int(30)));
573        assert_eq!(decoded.get("active"), Some(&SochValue::Bool(true)));
574    }
575
576    #[test]
577    fn test_encode_decode_node_value() {
578        let mut props = std::collections::HashMap::new();
579        props.insert("email".to_string(), SochValue::Text("a@b.com".to_string()));
580
581        let encoded = encode_node_value("User", &props);
582        let (node_type, decoded_props) = decode_node_value(&encoded).unwrap();
583        assert_eq!(node_type, "User");
584        assert_eq!(
585            decoded_props.get("email"),
586            Some(&SochValue::Text("a@b.com".to_string()))
587        );
588    }
589
590    #[test]
591    fn test_encode_decode_edge_value() {
592        let from = RecordId::new("user", 1);
593        let to = RecordId::from_string("conv", "abc");
594        let mut props = std::collections::HashMap::new();
595        props.insert("weight".to_string(), SochValue::Float(0.95));
596
597        let encoded = encode_edge_value(&from, "STARTED", &to, &props);
598        let decoded = decode_edge_value(&encoded).unwrap();
599        assert_eq!(decoded.edge_type, "STARTED");
600        assert_eq!(decoded.from_id, from);
601        assert_eq!(decoded.to_id, to);
602        assert_eq!(
603            decoded.properties.get("weight"),
604            Some(&SochValue::Float(0.95))
605        );
606    }
607
608    #[test]
609    fn test_empty_properties() {
610        let props = std::collections::HashMap::new();
611        let encoded = encode_properties(&props);
612        let decoded = decode_properties(&encoded).unwrap();
613        assert!(decoded.is_empty());
614    }
615
616    #[test]
617    fn test_different_namespaces_produce_different_keys() {
618        let rid = RecordId::new("person", 1);
619        let key1 = node_key("ns_a", &rid);
620        let key2 = node_key("ns_b", &rid);
621        assert_ne!(key1, key2);
622    }
623}