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("unsupported graph op version (expected {expected}, got {got})")]
368 Version { expected: u32, got: u32 },
369}
370
371pub 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 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 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 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 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}