Skip to main content

agentstategraph_core/
object.rs

1//! Content-addressed objects — the fundamental unit of state storage.
2//!
3//! All state in AgentStateGraph is composed of Objects. Every Object is individually
4//! content-addressed via BLAKE3 hash of its canonical serialization.
5//!
6//! Two objects with identical content always produce the same ObjectId,
7//! enabling automatic deduplication of identical subtrees.
8
9use blake3;
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::fmt;
13
14/// A BLAKE3 hash identifying an object by its content.
15/// Two objects with identical content always produce the same ObjectId.
16#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
17pub struct ObjectId([u8; 32]);
18
19impl ObjectId {
20    /// Create an ObjectId from raw bytes.
21    pub fn from_bytes(bytes: [u8; 32]) -> Self {
22        Self(bytes)
23    }
24
25    /// Return the raw bytes of this ObjectId.
26    pub fn as_bytes(&self) -> &[u8; 32] {
27        &self.0
28    }
29
30    /// Compute the ObjectId for the given canonical bytes.
31    pub fn hash(data: &[u8]) -> Self {
32        Self(*blake3::hash(data).as_bytes())
33    }
34
35    /// Display as a short hex prefix (for logging/debugging).
36    pub fn short(&self) -> String {
37        format!("sg_{}", to_hex(&self.0[..6]))
38    }
39
40    /// Full 64-character lowercase hex of the 32-byte id, WITHOUT the `sg_`
41    /// display prefix. Useful for prefix matching against a stored id.
42    pub fn to_hex(&self) -> String {
43        to_hex(&self.0)
44    }
45
46    /// Parse a full commit id from hex, tolerating an optional `sg_` prefix and
47    /// surrounding whitespace. Returns `None` unless the input decodes to
48    /// exactly 32 bytes (64 hex chars). For partial ids use [`ObjectId::to_hex`]
49    /// and compare prefixes.
50    pub fn from_hex(s: &str) -> Option<Self> {
51        let hex = s.trim().strip_prefix("sg_").unwrap_or(s.trim());
52        if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
53            return None;
54        }
55        let mut bytes = [0u8; 32];
56        for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
57            let hi = (chunk[0] as char).to_digit(16)?;
58            let lo = (chunk[1] as char).to_digit(16)?;
59            bytes[i] = (hi * 16 + lo) as u8;
60        }
61        Some(Self(bytes))
62    }
63
64    /// Normalize a user-supplied commit-ref fragment for prefix matching:
65    /// strips an optional `sg_` prefix and lowercases. Returns `None` if the
66    /// remainder is empty or contains non-hex characters.
67    pub fn normalize_hex_prefix(s: &str) -> Option<String> {
68        let frag = s.trim().strip_prefix("sg_").unwrap_or(s.trim());
69        if frag.is_empty() || !frag.bytes().all(|b| b.is_ascii_hexdigit()) {
70            return None;
71        }
72        Some(frag.to_ascii_lowercase())
73    }
74}
75
76impl fmt::Display for ObjectId {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        write!(f, "sg_{}", to_hex(&self.0))
79    }
80}
81
82/// Convert bytes to hex string without external dependency.
83fn to_hex(bytes: &[u8]) -> String {
84    bytes.iter().map(|b| format!("{:02x}", b)).collect()
85}
86
87impl fmt::Debug for ObjectId {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        write!(f, "ObjectId({})", self.short())
90    }
91}
92
93/// A leaf value in the state tree.
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95#[serde(tag = "type", content = "value")]
96pub enum Atom {
97    Null,
98    Bool(bool),
99    Int(i64),
100    Float(f64),
101    String(String),
102    Bytes(Vec<u8>),
103    /// An integer above `i64::MAX` — a u64 id or hash. Every integer that
104    /// fits `i64` is `Int`, so each number has one representation (and one
105    /// object id); build integers with [`Object::uint`] or
106    /// [`Object::from_json_number`] rather than this variant directly.
107    /// Last, so the serialized form of every other variant is unchanged.
108    UInt(u64),
109}
110
111/// A container value in the state tree.
112/// Nodes reference children by ObjectId, forming a Merkle DAG.
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114#[serde(tag = "type", content = "value")]
115pub enum Node {
116    /// String-keyed map. Keys are sorted for canonical serialization.
117    Map(BTreeMap<String, ObjectId>),
118    /// Ordered list of values.
119    List(Vec<ObjectId>),
120    /// Unordered set of unique values. Sorted by ObjectId for canonical serialization.
121    Set(Vec<ObjectId>),
122}
123
124/// An Object is either an Atom (leaf) or a Node (container).
125/// This is the fundamental unit of state storage in AgentStateGraph.
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127#[serde(tag = "kind")]
128pub enum Object {
129    Atom(Atom),
130    Node(Node),
131}
132
133impl Object {
134    /// Compute the canonical serialization of this object.
135    /// The serialization is deterministic: map keys are sorted,
136    /// sets are sorted by ObjectId, and numeric types use fixed encoding.
137    pub fn canonical_bytes(&self) -> Vec<u8> {
138        // serde_json with BTreeMap guarantees sorted keys
139        serde_json::to_vec(self).expect("Object serialization should never fail")
140    }
141
142    /// Compute the content-address (ObjectId) of this object.
143    pub fn id(&self) -> ObjectId {
144        ObjectId::hash(&self.canonical_bytes())
145    }
146
147    // -- Convenience constructors --
148
149    pub fn null() -> Self {
150        Object::Atom(Atom::Null)
151    }
152
153    pub fn bool(v: bool) -> Self {
154        Object::Atom(Atom::Bool(v))
155    }
156
157    pub fn int(v: i64) -> Self {
158        Object::Atom(Atom::Int(v))
159    }
160
161    pub fn float(v: f64) -> Self {
162        Object::Atom(Atom::Float(v))
163    }
164
165    /// An unsigned integer: `Int` when it fits `i64`, `UInt` above.
166    pub fn uint(v: u64) -> Self {
167        match i64::try_from(v) {
168            Ok(i) => Object::Atom(Atom::Int(i)),
169            Err(_) => Object::Atom(Atom::UInt(v)),
170        }
171    }
172
173    /// The atom for a JSON number, exactly: `Int` when it fits `i64`,
174    /// `UInt` above `i64::MAX`, `Float` otherwise. Falling back to `f64` for
175    /// every integer that missed `i64` stored u64 ids and hashes as different
176    /// numbers.
177    pub fn from_json_number(n: &serde_json::Number) -> Self {
178        if let Some(i) = n.as_i64() {
179            Object::int(i)
180        } else if let Some(u) = n.as_u64() {
181            Object::uint(u)
182        } else {
183            Object::float(n.as_f64().unwrap_or(f64::NAN))
184        }
185    }
186
187    pub fn string(v: impl Into<String>) -> Self {
188        Object::Atom(Atom::String(v.into()))
189    }
190
191    pub fn bytes(v: Vec<u8>) -> Self {
192        Object::Atom(Atom::Bytes(v))
193    }
194
195    pub fn map(entries: BTreeMap<String, ObjectId>) -> Self {
196        Object::Node(Node::Map(entries))
197    }
198
199    pub fn empty_map() -> Self {
200        Object::Node(Node::Map(BTreeMap::new()))
201    }
202
203    pub fn list(items: Vec<ObjectId>) -> Self {
204        Object::Node(Node::List(items))
205    }
206
207    pub fn set(items: Vec<ObjectId>) -> Self {
208        let mut sorted = items;
209        sorted.sort();
210        sorted.dedup();
211        Object::Node(Node::Set(sorted))
212    }
213}
214
215#[cfg(test)]
216mod tests {
217
218    #[test]
219    fn integers_have_one_representation() {
220        assert_eq!(Object::uint(5), Object::int(5));
221        assert_eq!(Object::uint(i64::MAX as u64), Object::int(i64::MAX));
222        assert_eq!(Object::uint(u64::MAX), Object::Atom(Atom::UInt(u64::MAX)));
223        let n = |v: serde_json::Value| match v {
224            serde_json::Value::Number(n) => Object::from_json_number(&n),
225            _ => unreachable!(),
226        };
227        assert_eq!(n(serde_json::json!(5u64)), Object::int(5));
228        assert_eq!(n(serde_json::json!(u64::MAX)), Object::uint(u64::MAX));
229        assert_eq!(n(serde_json::json!(-3)), Object::int(-3));
230        assert_eq!(n(serde_json::json!(0.5)), Object::float(0.5));
231    }
232
233    #[test]
234    fn existing_atoms_serialize_unchanged() {
235        // Object ids hash the serialized form: adding UInt must not move them.
236        assert_eq!(
237            String::from_utf8(Object::int(42).canonical_bytes()).unwrap(),
238            r#"{"kind":"Atom","type":"Int","value":42}"#
239        );
240        assert_eq!(
241            String::from_utf8(Object::uint(u64::MAX).canonical_bytes()).unwrap(),
242            r#"{"kind":"Atom","type":"UInt","value":18446744073709551615}"#
243        );
244    }
245
246    use super::*;
247
248    #[test]
249    fn test_content_addressing_deterministic() {
250        let obj1 = Object::string("hello");
251        let obj2 = Object::string("hello");
252        assert_eq!(
253            obj1.id(),
254            obj2.id(),
255            "identical objects must have the same ObjectId"
256        );
257    }
258
259    #[test]
260    fn test_different_content_different_id() {
261        let obj1 = Object::string("hello");
262        let obj2 = Object::string("world");
263        assert_ne!(
264            obj1.id(),
265            obj2.id(),
266            "different objects must have different ObjectIds"
267        );
268    }
269
270    #[test]
271    fn test_map_key_order_irrelevant() {
272        // BTreeMap sorts keys, so insertion order doesn't matter
273        let id_a = Object::string("a").id();
274        let id_b = Object::string("b").id();
275
276        let mut map1 = BTreeMap::new();
277        map1.insert("first".to_string(), id_a);
278        map1.insert("second".to_string(), id_b);
279
280        let mut map2 = BTreeMap::new();
281        map2.insert("second".to_string(), id_b);
282        map2.insert("first".to_string(), id_a);
283
284        let obj1 = Object::map(map1);
285        let obj2 = Object::map(map2);
286        assert_eq!(
287            obj1.id(),
288            obj2.id(),
289            "maps with same entries in different order must have same ObjectId"
290        );
291    }
292
293    #[test]
294    fn test_set_dedup_and_sort() {
295        let id_a = Object::string("a").id();
296        let id_b = Object::string("b").id();
297
298        let obj1 = Object::set(vec![id_a, id_b, id_a]); // duplicate
299        let obj2 = Object::set(vec![id_b, id_a]); // different order
300
301        assert_eq!(obj1.id(), obj2.id(), "sets must be deduplicated and sorted");
302    }
303
304    #[test]
305    fn test_object_id_display() {
306        let obj = Object::string("test");
307        let id = obj.id();
308        let display = format!("{}", id);
309        assert!(
310            display.starts_with("sg_"),
311            "ObjectId display should start with sg_ prefix"
312        );
313    }
314
315    #[test]
316    fn test_atom_variants() {
317        // Ensure all atom types produce distinct ObjectIds
318        let ids: Vec<ObjectId> = vec![
319            Object::null().id(),
320            Object::bool(true).id(),
321            Object::bool(false).id(),
322            Object::int(42).id(),
323            Object::float(std::f64::consts::PI).id(),
324            Object::string("test").id(),
325            Object::bytes(vec![1, 2, 3]).id(),
326        ];
327
328        for i in 0..ids.len() {
329            for j in (i + 1)..ids.len() {
330                assert_ne!(
331                    ids[i], ids[j],
332                    "different atom types/values must have different ObjectIds"
333                );
334            }
335        }
336    }
337}