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 NodeId
11);
12crate::agent::wire_id!(
13 EdgeId
16);
17
18impl NodeId {
19 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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum EdgeDir {
49 #[default]
51 Out,
52 In,
54 Both,
56}
57
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum GraphReturn {
62 #[default]
64 Nodes,
65 Edges,
67 Paths,
69 Triplets,
71}
72
73#[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 pub fn is_out(&self) -> bool {
87 matches!(self, EdgeDir::Out)
88 }
89}
90
91#[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub as_of: Option<u64>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub conversation: Option<String>,
131}
132
133impl GraphReturn {
134 pub fn is_nodes(&self) -> bool {
136 matches!(self, GraphReturn::Nodes)
137 }
138}
139
140#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub as_of: Option<u64>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub conversation: Option<String>,
163}
164
165#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
175pub enum SourceRef {
176 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 Kv { namespace: String, key: String },
192 Memory { id: String },
194}
195
196#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub source: Option<SourceRef>,
209}
210
211impl GraphNode {
212 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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub valid_from: Option<u64>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub valid_to: Option<u64>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub source: Option<SourceRef>,
256}
257
258impl GraphEdge {
259 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 pub fn with_source(mut self, source: SourceRef) -> Self {
280 self.source = Some(source);
281 self
282 }
283
284 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 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#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
304pub struct Path {
305 pub nodes: Vec<NodeId>,
306 pub edges: Vec<EdgeId>,
307}
308
309#[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#[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#[derive(Clone, Debug, Serialize, Deserialize)]
336#[non_exhaustive]
337pub enum GraphReply {
338 Ok(GraphResult),
339 Err(GraphError),
340}
341
342#[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 #[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
369pub 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 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 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 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 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}