Skip to main content

laser_wire/
graph.rs

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