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 NodeId
15);
16crate::agent::wire_id!(
17 EdgeId
20);
21
22impl NodeId {
23 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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum EdgeDir {
53 #[default]
55 Out,
56 In,
58 Both,
60}
61
62#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum GraphReturn {
66 #[default]
68 Nodes,
69 Edges,
71 Paths,
73 Triplets,
75}
76
77#[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 pub fn is_out(&self) -> bool {
91 matches!(self, EdgeDir::Out)
92 }
93}
94
95#[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub as_of: Option<u64>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub conversation: Option<String>,
135}
136
137impl GraphReturn {
138 pub fn is_nodes(&self) -> bool {
140 matches!(self, GraphReturn::Nodes)
141 }
142}
143
144#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub as_of: Option<u64>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub conversation: Option<String>,
167}
168
169#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
179pub enum SourceRef {
180 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 Kv { namespace: String, key: String },
196 Memory { id: String },
198}
199
200#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub source: Option<SourceRef>,
213}
214
215impl GraphNode {
216 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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub valid_from: Option<u64>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub valid_to: Option<u64>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub source: Option<SourceRef>,
260}
261
262impl GraphEdge {
263 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 pub fn with_source(mut self, source: SourceRef) -> Self {
284 self.source = Some(source);
285 self
286 }
287
288 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 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#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
308pub struct Path {
309 pub nodes: Vec<NodeId>,
310 pub edges: Vec<EdgeId>,
311}
312
313#[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#[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#[derive(Clone, Debug, Serialize, Deserialize)]
340#[non_exhaustive]
341pub enum GraphReply {
342 Ok(GraphResult),
343 Err(GraphError),
344}
345
346#[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 #[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("temporarily unavailable: {0}")]
368 Unavailable(String),
369 #[error("unsupported graph op version (expected {expected}, got {got})")]
370 Version { expected: u32, got: u32 },
371}
372
373pub fn validate_graph_name(name: &str) -> Result<(), InvalidError> {
377 if name.is_empty() {
378 return Err(InvalidError::new("graph name must not be empty"));
379 }
380 if name.len() > MAX_GRAPH_NAME_BYTES {
381 return Err(InvalidError::new(format!(
382 "graph name is {}B, exceeds cap {MAX_GRAPH_NAME_BYTES}B",
383 name.len()
384 )));
385 }
386 if name.bytes().any(|byte| byte.is_ascii_control()) {
387 return Err(InvalidError::new(
388 "graph name must not contain ASCII control characters",
389 ));
390 }
391 Ok(())
392}
393
394#[cfg(all(test, feature = "cbor"))]
395mod tests {
396 use super::*;
397 use crate::codes::GRAPH_OP_VERSION;
398 use crate::framing::{decode_named, encode_named};
399 use crate::query::CmpOp;
400
401 #[test]
402 fn given_a_graph_query_when_round_tripped_then_should_preserve_traversal() {
403 let query = GraphQuery {
404 v: GRAPH_OP_VERSION,
405 graph: "knowledge".to_owned(),
406 start: GraphStart::Match(Filter::pred("label", CmpOp::Eq, "Person")),
407 traverse: vec![
408 Hop {
409 edge_type: Some("works_at".to_owned()),
410 dir: EdgeDir::Out,
411 max: 1,
412 },
413 Hop {
414 edge_type: Some("located_in".to_owned()),
415 dir: EdgeDir::Out,
416 max: 1,
417 },
418 ],
419 node_filter: None,
420 edge_filter: None,
421 return_: GraphReturn::Paths,
422 limit: 100,
423 fork: None,
424 consistency: Consistency::Eventual,
425 as_of: Some(1_900_000_000_000_000),
426 conversation: None,
427 };
428 let bytes = encode_named(&query).expect("serializes");
429 let back: GraphQuery = decode_named(&bytes).expect("deserializes");
430 assert_eq!(back.graph, "knowledge");
431 assert_eq!(back.traverse.len(), 2);
432 assert_eq!(back.return_, GraphReturn::Paths);
433 assert_eq!(back.as_of, Some(1_900_000_000_000_000));
434 }
435
436 #[test]
437 fn given_a_graph_result_when_round_tripped_then_should_preserve_nodes_and_edges() {
438 let reply = GraphReply::Ok(GraphResult {
439 nodes: vec![GraphNode {
440 id: NodeId::from_u128(1),
441 labels: vec!["Person".to_owned()],
442 attrs: vec![("name".to_owned(), Value::from("Alice"))],
443 embedding: None,
444 source: None,
445 }],
446 edges: vec![GraphEdge {
447 id: EdgeId::from_u128(2),
448 from: NodeId::from_u128(1),
449 to: NodeId::from_u128(3),
450 edge_type: "works_at".to_owned(),
451 weight: 1.0,
452 attrs: Vec::new(),
453 valid_from: None,
454 valid_to: None,
455 source: None,
456 }],
457 paths: Vec::new(),
458 });
459 let bytes = encode_named(&reply).expect("serializes");
460 let back: GraphReply = decode_named(&bytes).expect("deserializes");
461 let GraphReply::Ok(result) = back else {
462 panic!("expected Ok");
463 };
464 assert_eq!(result.nodes.len(), 1);
465 assert_eq!(result.edges[0].edge_type, "works_at");
466 }
467
468 #[test]
469 fn given_a_nearest_start_when_round_tripped_then_should_preserve_the_seed() {
470 let query = GraphQuery {
471 v: GRAPH_OP_VERSION,
472 graph: "knowledge".to_owned(),
473 start: GraphStart::Nearest {
474 embedding: vec![0.1, 0.2, 0.3],
475 k: 5,
476 },
477 traverse: Vec::new(),
478 node_filter: None,
479 edge_filter: None,
480 return_: GraphReturn::Nodes,
481 limit: 10,
482 fork: None,
483 consistency: Consistency::Eventual,
484 as_of: None,
485 conversation: None,
486 };
487 let bytes = encode_named(&query).expect("serializes");
488 let back: GraphQuery = decode_named(&bytes).expect("deserializes");
489 match back.start {
490 GraphStart::Nearest { embedding, k } => {
491 assert_eq!(embedding, vec![0.1, 0.2, 0.3]);
492 assert_eq!(k, 5);
493 }
494 other => panic!("expected Nearest, got {other:?}"),
495 }
496 }
497
498 #[test]
499 fn given_a_node_id_when_round_tripped_through_a_string_then_should_be_equal() {
500 let id = NodeId::from_u128(987_654_321);
501 let parsed: NodeId = id.to_string().parse().expect("a node id parses");
502 assert_eq!(parsed, id);
503 }
504
505 #[test]
506 fn given_the_same_entity_when_addressed_twice_then_should_converge_on_one_node_id() {
507 let a = NodeId::content("Person", b"Alice");
508 let b = NodeId::content("Person", b"Alice");
509 assert_eq!(a, b, "the same entity is one node");
510 assert_ne!(a, NodeId::content("Company", b"Alice"));
512 assert_ne!(a, NodeId::content("Person", b"Bob"));
513 }
514
515 #[test]
516 fn given_the_pinned_entity_when_addressed_then_should_match_the_golden_id() {
517 assert_eq!(
520 NodeId::content("Person", b"Alice").to_string(),
521 "13NCEPHNVFHHGNK9GD3MT0W1AB"
522 );
523 }
524
525 #[test]
526 fn given_two_nodes_when_related_then_should_content_address_the_edge() {
527 let alice = GraphNode::entity("Person", "Alice");
528 let acme = GraphNode::entity("Company", "Acme");
529 let one = GraphEdge::relate(&alice, "works_at", &acme);
530 let two = GraphEdge::relate(&alice, "works_at", &acme);
531 assert_eq!(one.id, two.id, "the same relationship is one edge");
532 assert_eq!(one.from, alice.id);
533 assert_eq!(one.to, acme.id);
534 assert_ne!(one.id, GraphEdge::relate(&acme, "works_at", &alice).id);
536 }
537
538 #[test]
539 fn given_an_edge_validity_window_when_checked_then_should_hold_only_inside_it() {
540 let alice = GraphNode::entity("User", "alice");
541 let pro = GraphNode::entity("Plan", "pro");
542 let edge = GraphEdge::relate(&alice, "on_plan", &pro).valid(Some(100), Some(200));
543 assert!(!edge.valid_at(99), "before the window");
544 assert!(edge.valid_at(100), "the lower bound is inclusive");
545 assert!(edge.valid_at(150), "inside the window");
546 assert!(!edge.valid_at(200), "the upper bound is exclusive");
547 let open = GraphEdge::relate(&alice, "on_plan", &pro);
548 assert!(open.valid_at(0) && open.valid_at(u64::MAX));
549 assert_eq!(edge.id, open.id, "validity is not part of edge identity");
550 }
551
552 #[test]
553 fn given_an_edge_without_validity_when_serialized_then_should_omit_the_window() {
554 let edge = GraphEdge::relate(
555 &GraphNode::entity("A", "x"),
556 "rel",
557 &GraphNode::entity("B", "y"),
558 );
559 let json = serde_json::to_string(&edge).expect("serializes");
560 assert!(
561 !json.contains("valid_from") && !json.contains("valid_to"),
562 "an unset window must be omitted so a pre-bitemporal edge is byte-identical: {json}"
563 );
564 }
565
566 #[test]
567 fn given_a_node_without_a_source_when_serialized_then_should_omit_it() {
568 let node = GraphNode::entity("Person", "Alice");
569 let json = serde_json::to_string(&node).expect("serializes");
570 assert!(
571 !json.contains("source"),
572 "an unknown source must be omitted so a pre-provenance node is byte-identical: {json}"
573 );
574 }
575
576 #[test]
577 fn given_a_node_with_a_source_when_round_tripped_then_should_preserve_it_and_keep_identity() {
578 let mut node = GraphNode::entity("Component", "cache");
579 node.source = Some(SourceRef::Message {
580 stream: 7,
581 topic: 2,
582 partition: 3,
583 offset: 4096,
584 conversation: None,
585 });
586 let bytes = encode_named(&node).expect("serializes");
587 let back: GraphNode = decode_named(&bytes).expect("deserializes");
588 assert_eq!(back.source, node.source);
589 assert_eq!(
590 back.id,
591 GraphNode::entity("Component", "cache").id,
592 "source is not part of node identity"
593 );
594 }
595
596 #[test]
597 fn given_an_edge_with_a_source_when_round_tripped_then_should_preserve_it_and_keep_identity() {
598 let from = GraphNode::entity("A", "x");
599 let to = GraphNode::entity("B", "y");
600 let edge = GraphEdge::relate(&from, "rel", &to).with_source(SourceRef::Kv {
601 namespace: "ns".to_owned(),
602 key: "k".to_owned(),
603 });
604 let bytes = encode_named(&edge).expect("serializes");
605 let back: GraphEdge = decode_named(&bytes).expect("deserializes");
606 assert_eq!(back.source, edge.source);
607 assert_eq!(
608 back.id,
609 GraphEdge::relate(&from, "rel", &to).id,
610 "source is not part of edge identity"
611 );
612 }
613
614 #[test]
615 fn given_a_source_without_a_conversation_when_serialized_then_should_omit_it() {
616 let source = SourceRef::Message {
617 stream: 1,
618 topic: 1,
619 partition: 0,
620 offset: 0,
621 conversation: None,
622 };
623 let json = serde_json::to_string(&source).expect("serializes");
624 assert!(
625 !json.contains("conversation"),
626 "an unset conversation must be omitted so a pre-conversation source stays byte-identical: {json}"
627 );
628 }
629
630 #[test]
631 fn given_a_source_with_a_conversation_when_round_tripped_then_should_preserve_it() {
632 let mut node = GraphNode::entity("Ticket", "7");
633 node.source = Some(SourceRef::Message {
634 stream: 4,
635 topic: 6,
636 partition: 2,
637 offset: 99,
638 conversation: Some("01KWM3K3XEP3NP5TN850J17YBP".to_owned()),
639 });
640 let bytes = encode_named(&node).expect("serializes");
641 let back: GraphNode = decode_named(&bytes).expect("deserializes");
642 assert_eq!(
643 back.source, node.source,
644 "the conversation survives the round trip"
645 );
646 assert_eq!(
647 back.id,
648 GraphNode::entity("Ticket", "7").id,
649 "the conversation is provenance, not identity"
650 );
651 }
652
653 #[test]
654 fn given_a_conversation_filter_on_a_traversal_when_round_tripped_then_should_preserve_it() {
655 let query = GraphQuery {
656 v: GRAPH_OP_VERSION,
657 graph: "knowledge".to_owned(),
658 start: GraphStart::Ids(vec![NodeId::from_u128(1)]),
659 traverse: Vec::new(),
660 node_filter: None,
661 edge_filter: None,
662 return_: GraphReturn::Nodes,
663 limit: 10,
664 fork: None,
665 consistency: Consistency::Eventual,
666 as_of: None,
667 conversation: Some("01KWM3K3XEP3NP5TN850J17YBP".to_owned()),
668 };
669 let back: GraphQuery =
670 decode_named(&encode_named(&query).expect("serializes")).expect("deserializes");
671 assert_eq!(
672 back.conversation.as_deref(),
673 Some("01KWM3K3XEP3NP5TN850J17YBP")
674 );
675 let unfiltered = GraphQuery {
677 conversation: None,
678 ..query
679 };
680 let json = serde_json::to_string(&unfiltered).expect("serializes");
681 assert!(
682 !json.contains("conversation"),
683 "an unset filter is omitted: {json}"
684 );
685 }
686
687 #[test]
688 fn given_a_max_element_reply_with_source_when_encoded_then_should_fit_one_frame() {
689 use crate::limits::{MAX_FRAME_BYTES, MAX_GRAPH_RESULT_ELEMENTS};
690 let source = SourceRef::Message {
691 stream: u32::MAX,
692 topic: u32::MAX,
693 partition: u32::MAX,
694 offset: u64::MAX,
695 conversation: Some("7ZZZZZZZZZZZZZZZZZZZZZZZZZ".to_owned()),
696 };
697 let half = (MAX_GRAPH_RESULT_ELEMENTS / 2) as u128;
698 let nodes = (0..half)
699 .map(|i| {
700 let mut node = GraphNode::entity("Component", format!("entity-{i}"));
701 node.source = Some(source.clone());
702 node
703 })
704 .collect();
705 let edges = (0..half)
706 .map(|i| GraphEdge {
707 id: EdgeId::from_u128(i),
708 from: NodeId::from_u128(i),
709 to: NodeId::from_u128(i + 1),
710 edge_type: "relates_to".to_owned(),
711 weight: 1.0,
712 attrs: Vec::new(),
713 valid_from: None,
714 valid_to: None,
715 source: Some(source.clone()),
716 })
717 .collect();
718 let reply = GraphReply::Ok(GraphResult {
719 nodes,
720 edges,
721 paths: Vec::new(),
722 });
723 let encoded = encode_named(&reply).expect("serializes");
724 assert!(
725 encoded.len() < MAX_FRAME_BYTES,
726 "a full {MAX_GRAPH_RESULT_ELEMENTS}-element reply with source is {} bytes, over the frame cap {MAX_FRAME_BYTES}",
727 encoded.len()
728 );
729 }
730}