1use crate::agent::wire_id;
2use crate::error::InvalidError;
3use crate::limits::MAX_GRAPH_NAME_BYTES;
4use crate::query::{Consistency, Filter, Value};
5use serde::{Deserialize, Serialize};
6
7wire_id!(
8 NodeId
12);
13wire_id!(
14 EdgeId
17);
18
19impl NodeId {
20 pub fn content(label: &str, value: &[u8]) -> Self {
27 Self::from_u128(crate::hashing::content_id(&[label.as_bytes(), &[0], value]))
28 }
29}
30
31impl EdgeId {
32 pub fn content(from: NodeId, edge_type: &str, to: NodeId) -> Self {
36 Self::from_u128(crate::hashing::content_id(&[
37 &from.to_bytes(),
38 &[0],
39 edge_type.as_bytes(),
40 &[0],
41 &to.to_bytes(),
42 ]))
43 }
44}
45
46#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum EdgeDir {
50 #[default]
52 Out,
53 In,
55 Both,
57}
58
59#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum GraphReturn {
63 #[default]
65 Nodes,
66 Edges,
68 Paths,
70 Triplets,
72}
73
74#[derive(Clone, Debug, Serialize, Deserialize)]
77pub struct Hop {
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub edge_type: Option<String>,
80 #[serde(default, skip_serializing_if = "EdgeDir::is_out")]
81 pub dir: EdgeDir,
82 pub max: u32,
83}
84
85impl EdgeDir {
86 pub fn is_out(&self) -> bool {
88 matches!(self, EdgeDir::Out)
89 }
90}
91
92#[derive(Clone, Debug, Serialize, Deserialize)]
95pub enum GraphStart {
96 Ids(Vec<NodeId>),
97 Match(Filter),
98 Nearest { embedding: Vec<f32>, k: usize },
99}
100
101#[derive(Clone, Debug, Serialize, Deserialize)]
105pub struct GraphQuery {
106 pub v: u32,
107 pub graph: String,
108 pub start: GraphStart,
109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 pub traverse: Vec<Hop>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub node_filter: Option<Filter>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub edge_filter: Option<Filter>,
115 #[serde(default, skip_serializing_if = "GraphReturn::is_nodes")]
116 pub return_: GraphReturn,
117 pub limit: usize,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub fork: Option<String>,
120 #[serde(default, skip_serializing_if = "Consistency::is_eventual")]
121 pub consistency: Consistency,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub as_of: Option<u64>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub conversation: Option<String>,
132}
133
134impl GraphReturn {
135 pub fn is_nodes(&self) -> bool {
137 matches!(self, GraphReturn::Nodes)
138 }
139}
140
141#[derive(Clone, Debug, Serialize, Deserialize)]
144pub struct GraphNeighbors {
145 pub v: u32,
146 pub graph: String,
147 pub node: NodeId,
148 #[serde(default, skip_serializing_if = "EdgeDir::is_out")]
149 pub dir: EdgeDir,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub edge_type: Option<String>,
152 pub depth: u32,
153 pub limit: usize,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub as_of: Option<u64>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub conversation: Option<String>,
164}
165
166#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
176pub enum SourceRef {
177 Message {
184 stream: u32,
185 topic: u32,
186 partition: u32,
187 offset: u64,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 conversation: Option<String>,
190 },
191 Kv { namespace: String, key: String },
193 Memory { id: String },
195}
196
197#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
199pub struct GraphNode {
200 pub id: NodeId,
201 #[serde(default, skip_serializing_if = "Vec::is_empty")]
202 pub labels: Vec<String>,
203 #[serde(default, skip_serializing_if = "Vec::is_empty")]
204 pub attrs: Vec<(String, Value)>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub embedding: Option<Vec<f32>>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub source: Option<SourceRef>,
210}
211
212impl GraphNode {
213 pub fn entity(label: impl Into<String>, value: impl Into<String>) -> Self {
219 let label = label.into();
220 let value = value.into();
221 let id = NodeId::content(&label, value.as_bytes());
222 Self {
223 id,
224 labels: vec![label],
225 attrs: vec![("value".to_owned(), Value::from(value))],
226 embedding: None,
227 source: None,
228 }
229 }
230}
231
232#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
235pub struct GraphEdge {
236 pub id: EdgeId,
237 pub from: NodeId,
238 pub to: NodeId,
239 pub edge_type: String,
240 pub weight: f32,
241 #[serde(default, skip_serializing_if = "Vec::is_empty")]
242 pub attrs: Vec<(String, Value)>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub valid_from: Option<u64>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub valid_to: Option<u64>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub source: Option<SourceRef>,
257}
258
259impl GraphEdge {
260 pub fn relate(from: &GraphNode, edge_type: impl Into<String>, to: &GraphNode) -> Self {
264 let edge_type = edge_type.into();
265 Self {
266 id: EdgeId::content(from.id, &edge_type, to.id),
267 from: from.id,
268 to: to.id,
269 edge_type,
270 weight: 1.0,
271 attrs: Vec::new(),
272 valid_from: None,
273 valid_to: None,
274 source: None,
275 }
276 }
277
278 pub fn with_source(mut self, source: SourceRef) -> Self {
281 self.source = Some(source);
282 self
283 }
284
285 pub fn valid(mut self, from: Option<u64>, to: Option<u64>) -> Self {
290 self.valid_from = from;
291 self.valid_to = to;
292 self
293 }
294
295 pub fn valid_at(&self, at: u64) -> bool {
299 self.valid_from.is_none_or(|from| at >= from) && self.valid_to.is_none_or(|to| at < to)
300 }
301}
302
303#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
305pub struct Path {
306 pub nodes: Vec<NodeId>,
307 pub edges: Vec<EdgeId>,
308}
309
310#[derive(Clone, Debug, Default, Serialize, Deserialize)]
313pub struct GraphResult {
314 #[serde(default, skip_serializing_if = "Vec::is_empty")]
315 pub nodes: Vec<GraphNode>,
316 #[serde(default, skip_serializing_if = "Vec::is_empty")]
317 pub edges: Vec<GraphEdge>,
318 #[serde(default, skip_serializing_if = "Vec::is_empty")]
319 pub paths: Vec<Path>,
320}
321
322#[derive(Clone, Debug, Default, Serialize, Deserialize)]
325pub struct GraphUpsert {
326 pub v: u32,
327 pub graph: String,
328 #[serde(default, skip_serializing_if = "Vec::is_empty")]
329 pub nodes: Vec<GraphNode>,
330 #[serde(default, skip_serializing_if = "Vec::is_empty")]
331 pub edges: Vec<GraphEdge>,
332}
333
334#[derive(Clone, Debug, Serialize, Deserialize)]
337#[non_exhaustive]
338pub enum GraphReply {
339 Ok(GraphResult),
340 Err(GraphError),
341}
342
343#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
345#[non_exhaustive]
346pub enum GraphError {
347 #[error("graph not supported: {0}")]
348 Unsupported(String),
349 #[error("unauthorized: {0}")]
350 Unauthorized(String),
351 #[error("invalid graph name: {0}")]
353 InvalidName(String),
354 #[error("graph not found: {0}")]
355 NotFound(String),
356 #[error("traversal too large: {what} is {size}, exceeds cap {cap}")]
357 TooLarge {
358 what: String,
359 size: usize,
360 cap: usize,
361 },
362 #[error("graph backend error: {0}")]
363 Backend(String),
364 #[error("temporarily unavailable: {0}")]
365 Unavailable(String),
366 #[error("unsupported graph op version (expected {expected}, got {got})")]
367 Version { expected: u32, got: u32 },
368}
369
370pub fn validate_graph_name(name: &str) -> Result<(), InvalidError> {
374 if name.is_empty() {
375 return Err(InvalidError::new("graph name must not be empty"));
376 }
377 if name.len() > MAX_GRAPH_NAME_BYTES {
378 return Err(InvalidError::new(format!(
379 "graph name is {}B, exceeds cap {MAX_GRAPH_NAME_BYTES}B",
380 name.len()
381 )));
382 }
383 if name.bytes().any(|byte| byte.is_ascii_control()) {
384 return Err(InvalidError::new(
385 "graph name must not contain ASCII control characters",
386 ));
387 }
388 Ok(())
389}
390
391#[cfg(all(test, feature = "cbor"))]
392mod tests {
393 use super::*;
394 use crate::codes::GRAPH_OP_VERSION;
395 use crate::framing::{decode_named, encode_named};
396 use crate::query::CmpOp;
397
398 #[test]
399 fn given_a_graph_query_when_round_tripped_then_should_preserve_traversal() {
400 let query = GraphQuery {
401 v: GRAPH_OP_VERSION,
402 graph: "knowledge".to_owned(),
403 start: GraphStart::Match(Filter::pred("label", CmpOp::Eq, "Person")),
404 traverse: vec![
405 Hop {
406 edge_type: Some("works_at".to_owned()),
407 dir: EdgeDir::Out,
408 max: 1,
409 },
410 Hop {
411 edge_type: Some("located_in".to_owned()),
412 dir: EdgeDir::Out,
413 max: 1,
414 },
415 ],
416 node_filter: None,
417 edge_filter: None,
418 return_: GraphReturn::Paths,
419 limit: 100,
420 fork: None,
421 consistency: Consistency::Eventual,
422 as_of: Some(1_900_000_000_000_000),
423 conversation: None,
424 };
425 let bytes = encode_named(&query).expect("serializes");
426 let back: GraphQuery = decode_named(&bytes).expect("deserializes");
427 assert_eq!(back.graph, "knowledge");
428 assert_eq!(back.traverse.len(), 2);
429 assert_eq!(back.return_, GraphReturn::Paths);
430 assert_eq!(back.as_of, Some(1_900_000_000_000_000));
431 }
432
433 #[test]
434 fn given_a_graph_result_when_round_tripped_then_should_preserve_nodes_and_edges() {
435 let reply = GraphReply::Ok(GraphResult {
436 nodes: vec![GraphNode {
437 id: NodeId::from_u128(1),
438 labels: vec!["Person".to_owned()],
439 attrs: vec![("name".to_owned(), Value::from("Alice"))],
440 embedding: None,
441 source: None,
442 }],
443 edges: vec![GraphEdge {
444 id: EdgeId::from_u128(2),
445 from: NodeId::from_u128(1),
446 to: NodeId::from_u128(3),
447 edge_type: "works_at".to_owned(),
448 weight: 1.0,
449 attrs: Vec::new(),
450 valid_from: None,
451 valid_to: None,
452 source: None,
453 }],
454 paths: Vec::new(),
455 });
456 let bytes = encode_named(&reply).expect("serializes");
457 let back: GraphReply = decode_named(&bytes).expect("deserializes");
458 let GraphReply::Ok(result) = back else {
459 panic!("expected Ok");
460 };
461 assert_eq!(result.nodes.len(), 1);
462 assert_eq!(result.edges[0].edge_type, "works_at");
463 }
464
465 #[test]
466 fn given_a_nearest_start_when_round_tripped_then_should_preserve_the_seed() {
467 let query = GraphQuery {
468 v: GRAPH_OP_VERSION,
469 graph: "knowledge".to_owned(),
470 start: GraphStart::Nearest {
471 embedding: vec![0.1, 0.2, 0.3],
472 k: 5,
473 },
474 traverse: Vec::new(),
475 node_filter: None,
476 edge_filter: None,
477 return_: GraphReturn::Nodes,
478 limit: 10,
479 fork: None,
480 consistency: Consistency::Eventual,
481 as_of: None,
482 conversation: None,
483 };
484 let bytes = encode_named(&query).expect("serializes");
485 let back: GraphQuery = decode_named(&bytes).expect("deserializes");
486 match back.start {
487 GraphStart::Nearest { embedding, k } => {
488 assert_eq!(embedding, vec![0.1, 0.2, 0.3]);
489 assert_eq!(k, 5);
490 }
491 other => panic!("expected Nearest, got {other:?}"),
492 }
493 }
494
495 #[test]
496 fn given_a_node_id_when_round_tripped_through_a_string_then_should_be_equal() {
497 let id = NodeId::from_u128(987_654_321);
498 let parsed: NodeId = id.to_string().parse().expect("a node id parses");
499 assert_eq!(parsed, id);
500 }
501
502 #[test]
503 fn given_the_same_entity_when_addressed_twice_then_should_converge_on_one_node_id() {
504 let a = NodeId::content("Person", b"Alice");
505 let b = NodeId::content("Person", b"Alice");
506 assert_eq!(a, b, "the same entity is one node");
507 assert_ne!(a, NodeId::content("Company", b"Alice"));
509 assert_ne!(a, NodeId::content("Person", b"Bob"));
510 }
511
512 #[test]
513 fn given_the_pinned_entity_when_addressed_then_should_match_the_golden_id() {
514 assert_eq!(
517 NodeId::content("Person", b"Alice").to_string(),
518 "13NCEPHNVFHHGNK9GD3MT0W1AB"
519 );
520 }
521
522 #[test]
523 fn given_two_nodes_when_related_then_should_content_address_the_edge() {
524 let alice = GraphNode::entity("Person", "Alice");
525 let acme = GraphNode::entity("Company", "Acme");
526 let one = GraphEdge::relate(&alice, "works_at", &acme);
527 let two = GraphEdge::relate(&alice, "works_at", &acme);
528 assert_eq!(one.id, two.id, "the same relationship is one edge");
529 assert_eq!(one.from, alice.id);
530 assert_eq!(one.to, acme.id);
531 assert_ne!(one.id, GraphEdge::relate(&acme, "works_at", &alice).id);
533 }
534
535 #[test]
536 fn given_an_edge_validity_window_when_checked_then_should_hold_only_inside_it() {
537 let alice = GraphNode::entity("User", "alice");
538 let pool = GraphNode::entity("Pool", "eu-west");
539 let edge = GraphEdge::relate(&alice, "in_pool", &pool).valid(Some(100), Some(200));
540 assert!(!edge.valid_at(99), "before the window");
541 assert!(edge.valid_at(100), "the lower bound is inclusive");
542 assert!(edge.valid_at(150), "inside the window");
543 assert!(!edge.valid_at(200), "the upper bound is exclusive");
544 let open = GraphEdge::relate(&alice, "in_pool", &pool);
545 assert!(open.valid_at(0) && open.valid_at(u64::MAX));
546 assert_eq!(edge.id, open.id, "validity is not part of edge identity");
547 }
548
549 #[test]
550 fn given_an_edge_without_validity_when_serialized_then_should_omit_the_window() {
551 let edge = GraphEdge::relate(
552 &GraphNode::entity("A", "x"),
553 "rel",
554 &GraphNode::entity("B", "y"),
555 );
556 let json = serde_json::to_string(&edge).expect("serializes");
557 assert!(
558 !json.contains("valid_from") && !json.contains("valid_to"),
559 "an unset window must be omitted so a pre-bitemporal edge is byte-identical: {json}"
560 );
561 }
562
563 #[test]
564 fn given_a_node_without_a_source_when_serialized_then_should_omit_it() {
565 let node = GraphNode::entity("Person", "Alice");
566 let json = serde_json::to_string(&node).expect("serializes");
567 assert!(
568 !json.contains("source"),
569 "an unknown source must be omitted so a pre-provenance node is byte-identical: {json}"
570 );
571 }
572
573 #[test]
574 fn given_a_node_with_a_source_when_round_tripped_then_should_preserve_it_and_keep_identity() {
575 let mut node = GraphNode::entity("Component", "cache");
576 node.source = Some(SourceRef::Message {
577 stream: 7,
578 topic: 2,
579 partition: 3,
580 offset: 4096,
581 conversation: None,
582 });
583 let bytes = encode_named(&node).expect("serializes");
584 let back: GraphNode = decode_named(&bytes).expect("deserializes");
585 assert_eq!(back.source, node.source);
586 assert_eq!(
587 back.id,
588 GraphNode::entity("Component", "cache").id,
589 "source is not part of node identity"
590 );
591 }
592
593 #[test]
594 fn given_an_edge_with_a_source_when_round_tripped_then_should_preserve_it_and_keep_identity() {
595 let from = GraphNode::entity("A", "x");
596 let to = GraphNode::entity("B", "y");
597 let edge = GraphEdge::relate(&from, "rel", &to).with_source(SourceRef::Kv {
598 namespace: "ns".to_owned(),
599 key: "k".to_owned(),
600 });
601 let bytes = encode_named(&edge).expect("serializes");
602 let back: GraphEdge = decode_named(&bytes).expect("deserializes");
603 assert_eq!(back.source, edge.source);
604 assert_eq!(
605 back.id,
606 GraphEdge::relate(&from, "rel", &to).id,
607 "source is not part of edge identity"
608 );
609 }
610
611 #[test]
612 fn given_a_source_without_a_conversation_when_serialized_then_should_omit_it() {
613 let source = SourceRef::Message {
614 stream: 1,
615 topic: 1,
616 partition: 0,
617 offset: 0,
618 conversation: None,
619 };
620 let json = serde_json::to_string(&source).expect("serializes");
621 assert!(
622 !json.contains("conversation"),
623 "an unset conversation must be omitted so a pre-conversation source stays byte-identical: {json}"
624 );
625 }
626
627 #[test]
628 fn given_a_source_with_a_conversation_when_round_tripped_then_should_preserve_it() {
629 let mut node = GraphNode::entity("Ticket", "7");
630 node.source = Some(SourceRef::Message {
631 stream: 4,
632 topic: 6,
633 partition: 2,
634 offset: 99,
635 conversation: Some("01KWM3K3XEP3NP5TN850J17YBP".to_owned()),
636 });
637 let bytes = encode_named(&node).expect("serializes");
638 let back: GraphNode = decode_named(&bytes).expect("deserializes");
639 assert_eq!(
640 back.source, node.source,
641 "the conversation survives the round trip"
642 );
643 assert_eq!(
644 back.id,
645 GraphNode::entity("Ticket", "7").id,
646 "the conversation is provenance, not identity"
647 );
648 }
649
650 #[test]
651 fn given_a_conversation_filter_on_a_traversal_when_round_tripped_then_should_preserve_it() {
652 let query = GraphQuery {
653 v: GRAPH_OP_VERSION,
654 graph: "knowledge".to_owned(),
655 start: GraphStart::Ids(vec![NodeId::from_u128(1)]),
656 traverse: Vec::new(),
657 node_filter: None,
658 edge_filter: None,
659 return_: GraphReturn::Nodes,
660 limit: 10,
661 fork: None,
662 consistency: Consistency::Eventual,
663 as_of: None,
664 conversation: Some("01KWM3K3XEP3NP5TN850J17YBP".to_owned()),
665 };
666 let back: GraphQuery =
667 decode_named(&encode_named(&query).expect("serializes")).expect("deserializes");
668 assert_eq!(
669 back.conversation.as_deref(),
670 Some("01KWM3K3XEP3NP5TN850J17YBP")
671 );
672 let unfiltered = GraphQuery {
674 conversation: None,
675 ..query
676 };
677 let json = serde_json::to_string(&unfiltered).expect("serializes");
678 assert!(
679 !json.contains("conversation"),
680 "an unset filter is omitted: {json}"
681 );
682 }
683
684 #[test]
685 fn given_a_max_element_reply_with_source_when_encoded_then_should_fit_one_frame() {
686 use crate::limits::{MAX_FRAME_BYTES, MAX_GRAPH_RESULT_ELEMENTS};
687 let source = SourceRef::Message {
688 stream: u32::MAX,
689 topic: u32::MAX,
690 partition: u32::MAX,
691 offset: u64::MAX,
692 conversation: Some("7ZZZZZZZZZZZZZZZZZZZZZZZZZ".to_owned()),
693 };
694 let half = (MAX_GRAPH_RESULT_ELEMENTS / 2) as u128;
695 let nodes = (0..half)
696 .map(|i| {
697 let mut node = GraphNode::entity("Component", format!("entity-{i}"));
698 node.source = Some(source.clone());
699 node
700 })
701 .collect();
702 let edges = (0..half)
703 .map(|i| GraphEdge {
704 id: EdgeId::from_u128(i),
705 from: NodeId::from_u128(i),
706 to: NodeId::from_u128(i + 1),
707 edge_type: "relates_to".to_owned(),
708 weight: 1.0,
709 attrs: Vec::new(),
710 valid_from: None,
711 valid_to: None,
712 source: Some(source.clone()),
713 })
714 .collect();
715 let reply = GraphReply::Ok(GraphResult {
716 nodes,
717 edges,
718 paths: Vec::new(),
719 });
720 let encoded = encode_named(&reply).expect("serializes");
721 assert!(
722 encoded.len() < MAX_FRAME_BYTES,
723 "a full {MAX_GRAPH_RESULT_ELEMENTS}-element reply with source is {} bytes, over the frame cap {MAX_FRAME_BYTES}",
724 encoded.len()
725 );
726 }
727}