Skip to main content

laser_wire/
graph.rs

1use crate::agent::{IdParseError, crockford_decode, crockford_encode};
2use crate::error::InvalidError;
3use crate::limits::MAX_GRAPH_NAME_BYTES;
4use crate::query::{Consistency, Filter, Value};
5use serde::de::{self, Visitor};
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use std::fmt;
8use std::str::FromStr;
9
10crate::agent::wire_id!(
11    /// A graph node's identity. Content-addressed (the hash of the node's label
12    /// and canonical value), so the same entity extracted from different messages
13    /// converges on one node. Minted SDK- or projector-side.
14    NodeId
15);
16crate::agent::wire_id!(
17    /// A graph edge's identity. Content-addressed over its endpoints and type, so
18    /// the same relationship is one edge however many times it is observed.
19    EdgeId
20);
21
22impl NodeId {
23    /// A content-addressed node id: the stable hash of the entity's `label` and
24    /// canonical `value`, so the same entity extracted from different records (or
25    /// upserted by different callers, in any SDK) converges on one node, which is
26    /// what makes a graph rather than disconnected pairs. The one canonical
27    /// [`content_id`](crate::hashing::content_id), so every SDK mints the same id
28    /// from the same segments (pinned by the golden vector below).
29    pub fn content(label: &str, value: &[u8]) -> Self {
30        Self::from_u128(crate::hashing::content_id(&[label.as_bytes(), &[0], value]))
31    }
32}
33
34impl EdgeId {
35    /// A content-addressed edge id over its endpoints and type, so the same
36    /// relationship observed any number of times is one edge. Idempotent upsert
37    /// keys off this id.
38    pub fn content(from: NodeId, edge_type: &str, to: NodeId) -> Self {
39        Self::from_u128(crate::hashing::content_id(&[
40            &from.to_bytes(),
41            &[0],
42            edge_type.as_bytes(),
43            &[0],
44            &to.to_bytes(),
45        ]))
46    }
47}
48
49/// Which way a hop follows edges from the current frontier.
50#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum EdgeDir {
53    /// Outgoing edges (from -> to). The default.
54    #[default]
55    Out,
56    /// Incoming edges (to -> from).
57    In,
58    /// Both directions.
59    Both,
60}
61
62/// What a graph query returns.
63#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum GraphReturn {
66    /// The reachable nodes. The default.
67    #[default]
68    Nodes,
69    /// The traversed edges.
70    Edges,
71    /// The full paths (node and edge id sequences).
72    Paths,
73    /// The triplets (source -> relationship -> target) along the traversal.
74    Triplets,
75}
76
77/// One traversal step: follow edges of an optional type in `dir`, up to `max`
78/// hops at this step.
79#[derive(Clone, Debug, Serialize, Deserialize)]
80pub struct Hop {
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub edge_type: Option<String>,
83    #[serde(default, skip_serializing_if = "EdgeDir::is_out")]
84    pub dir: EdgeDir,
85    pub max: u32,
86}
87
88impl EdgeDir {
89    /// Whether this is the default `Out` direction (omitted on the wire).
90    pub fn is_out(&self) -> bool {
91        matches!(self, EdgeDir::Out)
92    }
93}
94
95/// Where a traversal starts: explicit node ids, the nodes matching a predicate,
96/// or the nodes nearest an embedding (vector-seeded traversal).
97#[derive(Clone, Debug, Serialize, Deserialize)]
98pub enum GraphStart {
99    Ids(Vec<NodeId>),
100    Match(Filter),
101    Nearest { embedding: Vec<f32>, k: usize },
102}
103
104/// A graph traversal: start, hop spec, optional node and edge filters, and what
105/// to return. Reuses the query [`Filter`] predicate language, so there is one
106/// filter grammar across query and graph.
107#[derive(Clone, Debug, Serialize, Deserialize)]
108pub struct GraphQuery {
109    pub v: u32,
110    pub graph: String,
111    pub start: GraphStart,
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub traverse: Vec<Hop>,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub node_filter: Option<Filter>,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub edge_filter: Option<Filter>,
118    #[serde(default, skip_serializing_if = "GraphReturn::is_nodes")]
119    pub return_: GraphReturn,
120    pub limit: usize,
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub fork: Option<String>,
123    #[serde(default, skip_serializing_if = "Consistency::is_eventual")]
124    pub consistency: Consistency,
125    /// Valid-time "as of" read (epoch micros): keep only edges whose valid-time
126    /// window contains this instant. `None` traverses the current graph.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub as_of: Option<u64>,
129    /// Restrict the traversal to elements a given conversation asserted (the
130    /// text form of its `gen_ai.conversation.id`), matched against each element's
131    /// [`SourceRef`] conversation. `None` reads the whole graph. The conversation
132    /// lens: "show me only what this conversation put in the graph."
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub conversation: Option<String>,
135}
136
137impl GraphReturn {
138    /// Whether this is the default `Nodes` return (omitted on the wire).
139    pub fn is_nodes(&self) -> bool {
140        matches!(self, GraphReturn::Nodes)
141    }
142}
143
144/// A one-hop neighbor read: the cheap, common traversal. `depth` follows the same
145/// hop repeatedly.
146#[derive(Clone, Debug, Serialize, Deserialize)]
147pub struct GraphNeighbors {
148    pub v: u32,
149    pub graph: String,
150    pub node: NodeId,
151    #[serde(default, skip_serializing_if = "EdgeDir::is_out")]
152    pub dir: EdgeDir,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub edge_type: Option<String>,
155    pub depth: u32,
156    pub limit: usize,
157    /// Valid-time "as of" read (epoch micros): keep only edges whose valid-time
158    /// window contains this instant. `None` reads the current graph.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub as_of: Option<u64>,
161    /// Restrict the neighborhood to elements a given conversation asserted (the
162    /// text form of its `gen_ai.conversation.id`), matched against each element's
163    /// [`SourceRef`] conversation. `None` reads the whole graph. See
164    /// [`GraphQuery::conversation`].
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub conversation: Option<String>,
167}
168
169/// Where a graph element was last observed: the source record an extraction came
170/// from, so a reader can navigate back to its origin. On an edge this is the
171/// record that asserted the relationship (the meaningful provenance, kept
172/// last-writer since the edge is rewritten on each observation to maintain its
173/// validity window). On a node it is the first record the entity was seen in
174/// (first-writer, so a re-observed node's stored bytes stay stable). Excluded
175/// from the content-addressed id, so it never affects identity or idempotent
176/// upsert. The complete history is the source log, which the projector can
177/// replay. Absent on the wire when unknown.
178#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
179pub enum SourceRef {
180    /// A record on the message log, by numeric stream id, topic id, partition, and
181    /// offset. Ids, not names: the pointer is route-ready and survives a rename.
182    /// The conversation is the record's `gen_ai.conversation.id`, kept for the
183    /// conversation lens but excluded from the content-addressed id, so the same
184    /// element re-observed from another conversation keeps its identity. Omitted
185    /// on the wire when unset.
186    Message {
187        stream: u32,
188        topic: u32,
189        partition: u32,
190        offset: u64,
191        #[serde(default, skip_serializing_if = "Option::is_none")]
192        conversation: Option<String>,
193    },
194    /// A key in the managed key-value store.
195    Kv { namespace: String, key: String },
196    /// A managed memory item, by its id.
197    Memory { id: String },
198}
199
200/// One node: its id, labels, attributes, optional embedding, and optional source.
201#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
202pub struct GraphNode {
203    pub id: NodeId,
204    #[serde(default, skip_serializing_if = "Vec::is_empty")]
205    pub labels: Vec<String>,
206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
207    pub attrs: Vec<(String, Value)>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub embedding: Option<Vec<f32>>,
210    /// The source this node was first observed in, if known. See [`SourceRef`].
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub source: Option<SourceRef>,
213}
214
215impl GraphNode {
216    /// A node for the entity `value` labelled `label`. Its id is content-addressed
217    /// over the label and value (so re-observing the same entity converges on one
218    /// node), and the value is kept as a `value` attribute so a `label` or
219    /// attribute [`Match`](GraphStart::Match) start can find it. The ergonomic way
220    /// to build a node for [`GraphUpsert`] without hand-minting an id.
221    pub fn entity(label: impl Into<String>, value: impl Into<String>) -> Self {
222        let label = label.into();
223        let value = value.into();
224        let id = NodeId::content(&label, value.as_bytes());
225        Self {
226            id,
227            labels: vec![label],
228            attrs: vec![("value".to_owned(), Value::from(value))],
229            embedding: None,
230            source: None,
231        }
232    }
233}
234
235/// One edge: its id, endpoints, type, weight, attributes, and an optional
236/// valid-time window for bitemporal facts.
237#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
238pub struct GraphEdge {
239    pub id: EdgeId,
240    pub from: NodeId,
241    pub to: NodeId,
242    pub edge_type: String,
243    pub weight: f32,
244    #[serde(default, skip_serializing_if = "Vec::is_empty")]
245    pub attrs: Vec<(String, Value)>,
246    /// Valid-time start (epoch micros): when the relationship became true. `None`
247    /// is open-ended. The system-time axis (when observed) is the upsert's log
248    /// offset, so a fact can be superseded by closing `valid_to` and opening a new
249    /// edge rather than overwriting. Absent on the wire when unset.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub valid_from: Option<u64>,
252    /// Valid-time end (epoch micros): when the relationship stopped being true.
253    /// `None` is still valid.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub valid_to: Option<u64>,
256    /// The source that most recently asserted this relationship, if known. See
257    /// [`SourceRef`].
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub source: Option<SourceRef>,
260}
261
262impl GraphEdge {
263    /// An edge of `edge_type` from `from` to `to`, weight `1.0`. Its id is
264    /// content-addressed over the endpoints and type, so the same relationship is
265    /// one edge. The ergonomic way to relate two [`GraphNode`]s for an upsert.
266    pub fn relate(from: &GraphNode, edge_type: impl Into<String>, to: &GraphNode) -> Self {
267        let edge_type = edge_type.into();
268        Self {
269            id: EdgeId::content(from.id, &edge_type, to.id),
270            from: from.id,
271            to: to.id,
272            edge_type,
273            weight: 1.0,
274            attrs: Vec::new(),
275            valid_from: None,
276            valid_to: None,
277            source: None,
278        }
279    }
280
281    /// Set the source that asserted this relationship. See [`SourceRef`]. The
282    /// edge id is unchanged: provenance is metadata, not identity.
283    pub fn with_source(mut self, source: SourceRef) -> Self {
284        self.source = Some(source);
285        self
286    }
287
288    /// Set the valid-time window (epoch micros) on this edge, for a bitemporal
289    /// fact. Either bound may be `None` for open-ended. The edge id is unchanged:
290    /// validity is metadata on the relationship, not part of its identity, so
291    /// re-observing the same relationship with a new window updates the same edge.
292    pub fn valid(mut self, from: Option<u64>, to: Option<u64>) -> Self {
293        self.valid_from = from;
294        self.valid_to = to;
295        self
296    }
297
298    /// Whether this edge's valid-time window contains `at` (epoch micros). An
299    /// open bound is treated as unbounded, so an edge with no window always holds.
300    /// The half-open convention is `[valid_from, valid_to)`.
301    pub fn valid_at(&self, at: u64) -> bool {
302        self.valid_from.is_none_or(|from| at >= from) && self.valid_to.is_none_or(|to| at < to)
303    }
304}
305
306/// One path through the graph: parallel node and edge id sequences.
307#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
308pub struct Path {
309    pub nodes: Vec<NodeId>,
310    pub edges: Vec<EdgeId>,
311}
312
313/// The data a graph traversal returns. Which fields are populated depends on the
314/// query's [`GraphReturn`].
315#[derive(Clone, Debug, Default, Serialize, Deserialize)]
316pub struct GraphResult {
317    #[serde(default, skip_serializing_if = "Vec::is_empty")]
318    pub nodes: Vec<GraphNode>,
319    #[serde(default, skip_serializing_if = "Vec::is_empty")]
320    pub edges: Vec<GraphEdge>,
321    #[serde(default, skip_serializing_if = "Vec::is_empty")]
322    pub paths: Vec<Path>,
323}
324
325/// Upsert nodes and edges into a graph. The projector path: idempotent on
326/// content-addressed ids, so re-applying the same extraction is a no-op.
327#[derive(Clone, Debug, Default, Serialize, Deserialize)]
328pub struct GraphUpsert {
329    pub v: u32,
330    pub graph: String,
331    #[serde(default, skip_serializing_if = "Vec::is_empty")]
332    pub nodes: Vec<GraphNode>,
333    #[serde(default, skip_serializing_if = "Vec::is_empty")]
334    pub edges: Vec<GraphEdge>,
335}
336
337/// The result of a graph operation: `Ok` with the traversal data, or `Err` with
338/// a structured failure.
339#[derive(Clone, Debug, Serialize, Deserialize)]
340#[non_exhaustive]
341pub enum GraphReply {
342    Ok(GraphResult),
343    Err(GraphError),
344}
345
346/// Why a graph operation failed.
347#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
348#[non_exhaustive]
349pub enum GraphError {
350    #[error("graph not supported: {0}")]
351    Unsupported(String),
352    #[error("unauthorized: {0}")]
353    Unauthorized(String),
354    /// The request named a graph that fails [`validate_graph_name`].
355    #[error("invalid graph name: {0}")]
356    InvalidName(String),
357    #[error("graph not found: {0}")]
358    NotFound(String),
359    #[error("traversal too large: {what} is {size}, exceeds cap {cap}")]
360    TooLarge {
361        what: String,
362        size: usize,
363        cap: usize,
364    },
365    #[error("graph backend error: {0}")]
366    Backend(String),
367    #[error("unsupported graph op version (expected {expected}, got {got})")]
368    Version { expected: u32, got: u32 },
369}
370
371/// The canonical graph-name rule, shared by the SDK client edge and the
372/// serving plane: non-empty, at most [`MAX_GRAPH_NAME_BYTES`] bytes, no ASCII
373/// control characters.
374pub fn validate_graph_name(name: &str) -> Result<(), InvalidError> {
375    if name.is_empty() {
376        return Err(InvalidError::new("graph name must not be empty"));
377    }
378    if name.len() > MAX_GRAPH_NAME_BYTES {
379        return Err(InvalidError::new(format!(
380            "graph name is {}B, exceeds cap {MAX_GRAPH_NAME_BYTES}B",
381            name.len()
382        )));
383    }
384    if name.bytes().any(|byte| byte.is_ascii_control()) {
385        return Err(InvalidError::new(
386            "graph name must not contain ASCII control characters",
387        ));
388    }
389    Ok(())
390}
391
392#[cfg(all(test, feature = "cbor"))]
393mod tests {
394    use super::*;
395    use crate::codes::GRAPH_OP_VERSION;
396    use crate::framing::{decode_named, encode_named};
397    use crate::query::CmpOp;
398
399    #[test]
400    fn given_a_graph_query_when_round_tripped_then_should_preserve_traversal() {
401        let query = GraphQuery {
402            v: GRAPH_OP_VERSION,
403            graph: "knowledge".to_owned(),
404            start: GraphStart::Match(Filter::pred("label", CmpOp::Eq, "Person")),
405            traverse: vec![
406                Hop {
407                    edge_type: Some("works_at".to_owned()),
408                    dir: EdgeDir::Out,
409                    max: 1,
410                },
411                Hop {
412                    edge_type: Some("located_in".to_owned()),
413                    dir: EdgeDir::Out,
414                    max: 1,
415                },
416            ],
417            node_filter: None,
418            edge_filter: None,
419            return_: GraphReturn::Paths,
420            limit: 100,
421            fork: None,
422            consistency: Consistency::Eventual,
423            as_of: Some(1_900_000_000_000_000),
424            conversation: None,
425        };
426        let bytes = encode_named(&query).expect("serializes");
427        let back: GraphQuery = decode_named(&bytes).expect("deserializes");
428        assert_eq!(back.graph, "knowledge");
429        assert_eq!(back.traverse.len(), 2);
430        assert_eq!(back.return_, GraphReturn::Paths);
431        assert_eq!(back.as_of, Some(1_900_000_000_000_000));
432    }
433
434    #[test]
435    fn given_a_graph_result_when_round_tripped_then_should_preserve_nodes_and_edges() {
436        let reply = GraphReply::Ok(GraphResult {
437            nodes: vec![GraphNode {
438                id: NodeId::from_u128(1),
439                labels: vec!["Person".to_owned()],
440                attrs: vec![("name".to_owned(), Value::from("Alice"))],
441                embedding: None,
442                source: None,
443            }],
444            edges: vec![GraphEdge {
445                id: EdgeId::from_u128(2),
446                from: NodeId::from_u128(1),
447                to: NodeId::from_u128(3),
448                edge_type: "works_at".to_owned(),
449                weight: 1.0,
450                attrs: Vec::new(),
451                valid_from: None,
452                valid_to: None,
453                source: None,
454            }],
455            paths: Vec::new(),
456        });
457        let bytes = encode_named(&reply).expect("serializes");
458        let back: GraphReply = decode_named(&bytes).expect("deserializes");
459        let GraphReply::Ok(result) = back else {
460            panic!("expected Ok");
461        };
462        assert_eq!(result.nodes.len(), 1);
463        assert_eq!(result.edges[0].edge_type, "works_at");
464    }
465
466    #[test]
467    fn given_a_nearest_start_when_round_tripped_then_should_preserve_the_seed() {
468        let query = GraphQuery {
469            v: GRAPH_OP_VERSION,
470            graph: "knowledge".to_owned(),
471            start: GraphStart::Nearest {
472                embedding: vec![0.1, 0.2, 0.3],
473                k: 5,
474            },
475            traverse: Vec::new(),
476            node_filter: None,
477            edge_filter: None,
478            return_: GraphReturn::Nodes,
479            limit: 10,
480            fork: None,
481            consistency: Consistency::Eventual,
482            as_of: None,
483            conversation: None,
484        };
485        let bytes = encode_named(&query).expect("serializes");
486        let back: GraphQuery = decode_named(&bytes).expect("deserializes");
487        match back.start {
488            GraphStart::Nearest { embedding, k } => {
489                assert_eq!(embedding, vec![0.1, 0.2, 0.3]);
490                assert_eq!(k, 5);
491            }
492            other => panic!("expected Nearest, got {other:?}"),
493        }
494    }
495
496    #[test]
497    fn given_a_node_id_when_round_tripped_through_a_string_then_should_be_equal() {
498        let id = NodeId::from_u128(987_654_321);
499        let parsed: NodeId = id.to_string().parse().expect("a node id parses");
500        assert_eq!(parsed, id);
501    }
502
503    #[test]
504    fn given_the_same_entity_when_addressed_twice_then_should_converge_on_one_node_id() {
505        let a = NodeId::content("Person", b"Alice");
506        let b = NodeId::content("Person", b"Alice");
507        assert_eq!(a, b, "the same entity is one node");
508        // A different label or value is a different node.
509        assert_ne!(a, NodeId::content("Company", b"Alice"));
510        assert_ne!(a, NodeId::content("Person", b"Bob"));
511    }
512
513    #[test]
514    fn given_the_pinned_entity_when_addressed_then_should_match_the_golden_id() {
515        // The cross-SDK golden vector: the Person entity "Alice". Every SDK renders
516        // this NodeId identically, so a graph shared across languages converges.
517        assert_eq!(
518            NodeId::content("Person", b"Alice").to_string(),
519            "13NCEPHNVFHHGNK9GD3MT0W1AB"
520        );
521    }
522
523    #[test]
524    fn given_two_nodes_when_related_then_should_content_address_the_edge() {
525        let alice = GraphNode::entity("Person", "Alice");
526        let acme = GraphNode::entity("Company", "Acme");
527        let one = GraphEdge::relate(&alice, "works_at", &acme);
528        let two = GraphEdge::relate(&alice, "works_at", &acme);
529        assert_eq!(one.id, two.id, "the same relationship is one edge");
530        assert_eq!(one.from, alice.id);
531        assert_eq!(one.to, acme.id);
532        // The direction is part of the identity: the reverse edge is a different id.
533        assert_ne!(one.id, GraphEdge::relate(&acme, "works_at", &alice).id);
534    }
535
536    #[test]
537    fn given_an_edge_validity_window_when_checked_then_should_hold_only_inside_it() {
538        let alice = GraphNode::entity("User", "alice");
539        let pro = GraphNode::entity("Plan", "pro");
540        let edge = GraphEdge::relate(&alice, "on_plan", &pro).valid(Some(100), Some(200));
541        assert!(!edge.valid_at(99), "before the window");
542        assert!(edge.valid_at(100), "the lower bound is inclusive");
543        assert!(edge.valid_at(150), "inside the window");
544        assert!(!edge.valid_at(200), "the upper bound is exclusive");
545        let open = GraphEdge::relate(&alice, "on_plan", &pro);
546        assert!(open.valid_at(0) && open.valid_at(u64::MAX));
547        assert_eq!(edge.id, open.id, "validity is not part of edge identity");
548    }
549
550    #[test]
551    fn given_an_edge_without_validity_when_serialized_then_should_omit_the_window() {
552        let edge = GraphEdge::relate(
553            &GraphNode::entity("A", "x"),
554            "rel",
555            &GraphNode::entity("B", "y"),
556        );
557        let json = serde_json::to_string(&edge).expect("serializes");
558        assert!(
559            !json.contains("valid_from") && !json.contains("valid_to"),
560            "an unset window must be omitted so a pre-bitemporal edge is byte-identical: {json}"
561        );
562    }
563
564    #[test]
565    fn given_a_node_without_a_source_when_serialized_then_should_omit_it() {
566        let node = GraphNode::entity("Person", "Alice");
567        let json = serde_json::to_string(&node).expect("serializes");
568        assert!(
569            !json.contains("source"),
570            "an unknown source must be omitted so a pre-provenance node is byte-identical: {json}"
571        );
572    }
573
574    #[test]
575    fn given_a_node_with_a_source_when_round_tripped_then_should_preserve_it_and_keep_identity() {
576        let mut node = GraphNode::entity("Component", "cache");
577        node.source = Some(SourceRef::Message {
578            stream: 7,
579            topic: 2,
580            partition: 3,
581            offset: 4096,
582            conversation: None,
583        });
584        let bytes = encode_named(&node).expect("serializes");
585        let back: GraphNode = decode_named(&bytes).expect("deserializes");
586        assert_eq!(back.source, node.source);
587        assert_eq!(
588            back.id,
589            GraphNode::entity("Component", "cache").id,
590            "source is not part of node identity"
591        );
592    }
593
594    #[test]
595    fn given_an_edge_with_a_source_when_round_tripped_then_should_preserve_it_and_keep_identity() {
596        let from = GraphNode::entity("A", "x");
597        let to = GraphNode::entity("B", "y");
598        let edge = GraphEdge::relate(&from, "rel", &to).with_source(SourceRef::Kv {
599            namespace: "ns".to_owned(),
600            key: "k".to_owned(),
601        });
602        let bytes = encode_named(&edge).expect("serializes");
603        let back: GraphEdge = decode_named(&bytes).expect("deserializes");
604        assert_eq!(back.source, edge.source);
605        assert_eq!(
606            back.id,
607            GraphEdge::relate(&from, "rel", &to).id,
608            "source is not part of edge identity"
609        );
610    }
611
612    #[test]
613    fn given_a_source_without_a_conversation_when_serialized_then_should_omit_it() {
614        let source = SourceRef::Message {
615            stream: 1,
616            topic: 1,
617            partition: 0,
618            offset: 0,
619            conversation: None,
620        };
621        let json = serde_json::to_string(&source).expect("serializes");
622        assert!(
623            !json.contains("conversation"),
624            "an unset conversation must be omitted so a pre-conversation source stays byte-identical: {json}"
625        );
626    }
627
628    #[test]
629    fn given_a_source_with_a_conversation_when_round_tripped_then_should_preserve_it() {
630        let mut node = GraphNode::entity("Ticket", "7");
631        node.source = Some(SourceRef::Message {
632            stream: 4,
633            topic: 6,
634            partition: 2,
635            offset: 99,
636            conversation: Some("01KWM3K3XEP3NP5TN850J17YBP".to_owned()),
637        });
638        let bytes = encode_named(&node).expect("serializes");
639        let back: GraphNode = decode_named(&bytes).expect("deserializes");
640        assert_eq!(
641            back.source, node.source,
642            "the conversation survives the round trip"
643        );
644        assert_eq!(
645            back.id,
646            GraphNode::entity("Ticket", "7").id,
647            "the conversation is provenance, not identity"
648        );
649    }
650
651    #[test]
652    fn given_a_conversation_filter_on_a_traversal_when_round_tripped_then_should_preserve_it() {
653        let query = GraphQuery {
654            v: GRAPH_OP_VERSION,
655            graph: "knowledge".to_owned(),
656            start: GraphStart::Ids(vec![NodeId::from_u128(1)]),
657            traverse: Vec::new(),
658            node_filter: None,
659            edge_filter: None,
660            return_: GraphReturn::Nodes,
661            limit: 10,
662            fork: None,
663            consistency: Consistency::Eventual,
664            as_of: None,
665            conversation: Some("01KWM3K3XEP3NP5TN850J17YBP".to_owned()),
666        };
667        let back: GraphQuery =
668            decode_named(&encode_named(&query).expect("serializes")).expect("deserializes");
669        assert_eq!(
670            back.conversation.as_deref(),
671            Some("01KWM3K3XEP3NP5TN850J17YBP")
672        );
673        // The default (no filter) stays omitted on the wire.
674        let unfiltered = GraphQuery {
675            conversation: None,
676            ..query
677        };
678        let json = serde_json::to_string(&unfiltered).expect("serializes");
679        assert!(
680            !json.contains("conversation"),
681            "an unset filter is omitted: {json}"
682        );
683    }
684
685    #[test]
686    fn given_a_max_element_reply_with_source_when_encoded_then_should_fit_one_frame() {
687        use crate::limits::{MAX_FRAME_BYTES, MAX_GRAPH_RESULT_ELEMENTS};
688        let source = SourceRef::Message {
689            stream: u32::MAX,
690            topic: u32::MAX,
691            partition: u32::MAX,
692            offset: u64::MAX,
693            conversation: Some("7ZZZZZZZZZZZZZZZZZZZZZZZZZ".to_owned()),
694        };
695        let half = (MAX_GRAPH_RESULT_ELEMENTS / 2) as u128;
696        let nodes = (0..half)
697            .map(|i| {
698                let mut node = GraphNode::entity("Component", format!("entity-{i}"));
699                node.source = Some(source.clone());
700                node
701            })
702            .collect();
703        let edges = (0..half)
704            .map(|i| GraphEdge {
705                id: EdgeId::from_u128(i),
706                from: NodeId::from_u128(i),
707                to: NodeId::from_u128(i + 1),
708                edge_type: "relates_to".to_owned(),
709                weight: 1.0,
710                attrs: Vec::new(),
711                valid_from: None,
712                valid_to: None,
713                source: Some(source.clone()),
714            })
715            .collect();
716        let reply = GraphReply::Ok(GraphResult {
717            nodes,
718            edges,
719            paths: Vec::new(),
720        });
721        let encoded = encode_named(&reply).expect("serializes");
722        assert!(
723            encoded.len() < MAX_FRAME_BYTES,
724            "a full {MAX_GRAPH_RESULT_ELEMENTS}-element reply with source is {} bytes, over the frame cap {MAX_FRAME_BYTES}",
725            encoded.len()
726        );
727    }
728}