1#![allow(missing_docs)]
11
12use brain_db_sdk::wire::types::{
13 EdgeKindWire, GraphEdge, GraphFetchRequest, GraphFetchResponseFrame, GraphNode, LinkResponse,
14 UnlinkResponse, WireMemoryId,
15};
16use serde::{Deserialize, Serialize};
17
18use super::{hex_decode, hex_encode, mem_id_decimal, parse_memory_id, uuid_string};
19
20fn parse_edge_kind(s: &str) -> Result<EdgeKindWire, String> {
22 Ok(match s.trim().to_ascii_lowercase().as_str() {
23 "caused" => EdgeKindWire::Caused,
24 "followed_by" | "followedby" => EdgeKindWire::FollowedBy,
25 "derived_from" | "derivedfrom" => EdgeKindWire::DerivedFrom,
26 "similar_to" | "similarto" => EdgeKindWire::SimilarTo,
27 "contradicts" => EdgeKindWire::Contradicts,
28 "supports" => EdgeKindWire::Supports,
29 "references" => EdgeKindWire::References,
30 "part_of" | "partof" => EdgeKindWire::PartOf,
31 other => return Err(format!("unknown edge kind `{other}`")),
32 })
33}
34
35fn edge_kind_str(k: EdgeKindWire) -> &'static str {
37 match k {
38 EdgeKindWire::Caused => "caused",
39 EdgeKindWire::FollowedBy => "followed_by",
40 EdgeKindWire::DerivedFrom => "derived_from",
41 EdgeKindWire::SimilarTo => "similar_to",
42 EdgeKindWire::Contradicts => "contradicts",
43 EdgeKindWire::Supports => "supports",
44 EdgeKindWire::References => "references",
45 EdgeKindWire::PartOf => "part_of",
46 }
47}
48
49#[derive(Debug, Deserialize)]
51pub struct LinkBody {
52 pub source: String,
53 pub target: String,
54 pub kind: String,
55 #[serde(default = "default_weight")]
57 pub weight: f32,
58}
59
60fn default_weight() -> f32 {
61 1.0
62}
63
64impl LinkBody {
65 pub fn parts(&self) -> Result<(WireMemoryId, WireMemoryId, EdgeKindWire, f32), String> {
67 Ok((
68 parse_memory_id(&self.source)?,
69 parse_memory_id(&self.target)?,
70 parse_edge_kind(&self.kind)?,
71 self.weight,
72 ))
73 }
74}
75
76#[derive(Debug, Serialize)]
78pub struct LinkResponseDto {
79 pub source: String,
80 pub target: String,
81 pub kind: &'static str,
82 pub weight: f32,
83 pub created_at_unix_nanos: u64,
84 pub already_existed: bool,
85}
86
87impl From<LinkResponse> for LinkResponseDto {
88 fn from(r: LinkResponse) -> Self {
89 Self {
90 source: r.source.to_string(),
91 target: r.target.to_string(),
92 kind: edge_kind_str(r.kind),
93 weight: r.weight,
94 created_at_unix_nanos: r.created_at_unix_nanos,
95 already_existed: r.already_existed,
96 }
97 }
98}
99
100#[derive(Debug, Deserialize)]
102pub struct UnlinkBody {
103 pub source: String,
104 pub target: String,
105 pub kind: String,
106}
107
108impl UnlinkBody {
109 pub fn parts(&self) -> Result<(WireMemoryId, WireMemoryId, EdgeKindWire), String> {
111 Ok((
112 parse_memory_id(&self.source)?,
113 parse_memory_id(&self.target)?,
114 parse_edge_kind(&self.kind)?,
115 ))
116 }
117}
118
119#[derive(Debug, Serialize)]
121pub struct UnlinkResponseDto {
122 pub source: String,
123 pub target: String,
124 pub kind: &'static str,
125 pub removed: bool,
126}
127
128impl From<UnlinkResponse> for UnlinkResponseDto {
129 fn from(r: UnlinkResponse) -> Self {
130 Self {
131 source: r.source.to_string(),
132 target: r.target.to_string(),
133 kind: edge_kind_str(r.kind),
134 removed: r.removed,
135 }
136 }
137}
138
139const GRAPH_DEFAULT_LIMIT: u32 = 200;
143const GRAPH_MAX_LIMIT: u32 = 500;
144
145const NODE_ENTITY: u8 = 0;
147const NODE_STATEMENT: u8 = 1;
148const NODE_MEMORY: u8 = 2;
149const EDGE_MENTIONS: u8 = 3;
151const EDGE_MEMORY_BASE: u8 = 4;
155const EDGE_MEMORY_LAST: u8 = EDGE_MEMORY_BASE + 7;
156
157fn graph_edge_kind_str(kind: u8) -> Option<&'static str> {
160 Some(match kind {
161 0 => "relation",
162 1 => "fact",
163 2 => "has_statement",
164 EDGE_MENTIONS => "mentions",
165 EDGE_MEMORY_BASE..=EDGE_MEMORY_LAST => {
166 return memory_edge_kind(kind).map(edge_kind_str);
167 }
168 _ => return None,
169 })
170}
171
172fn memory_edge_kind(kind: u8) -> Option<EdgeKindWire> {
174 if !(EDGE_MEMORY_BASE..=EDGE_MEMORY_LAST).contains(&kind) {
175 return None;
176 }
177 Some(match kind - EDGE_MEMORY_BASE {
178 0 => EdgeKindWire::Caused,
179 1 => EdgeKindWire::FollowedBy,
180 2 => EdgeKindWire::DerivedFrom,
181 3 => EdgeKindWire::SimilarTo,
182 4 => EdgeKindWire::Contradicts,
183 5 => EdgeKindWire::Supports,
184 6 => EdgeKindWire::References,
185 _ => EdgeKindWire::PartOf,
186 })
187}
188
189fn node_id_str(id: &[u8; 16], kind: u8) -> String {
192 if kind == NODE_MEMORY {
193 mem_id_decimal(id)
194 } else {
195 uuid_string(id)
196 }
197}
198
199#[derive(Debug, Deserialize)]
203pub struct GraphFetchQuery {
204 #[serde(default)]
206 pub limit: Option<u32>,
207 #[serde(default)]
209 pub cursor: Option<String>,
210 #[serde(default)]
212 pub include_statements: Option<bool>,
213 #[serde(default)]
215 pub include_memories: Option<bool>,
216 #[serde(default)]
221 pub include_memory_edges: Option<bool>,
222 #[serde(default)]
224 pub include_tombstoned: Option<bool>,
225}
226
227impl GraphFetchQuery {
228 pub fn to_request(&self) -> Result<GraphFetchRequest, String> {
229 let limit = self
230 .limit
231 .unwrap_or(GRAPH_DEFAULT_LIMIT)
232 .clamp(1, GRAPH_MAX_LIMIT);
233 let cursor = match &self.cursor {
234 Some(c) if !c.is_empty() => hex_decode(c)?,
235 _ => Vec::new(),
236 };
237 let include_memories = self.include_memories.unwrap_or(false);
238 let include_memory_edges = self.include_memory_edges.unwrap_or(false);
239 if include_memory_edges && !include_memories {
240 return Err(
241 "include_memory_edges requires include_memories: memory↔memory edges hang off \
242 memory nodes, and without that layer their endpoints would not be in the page"
243 .to_string(),
244 );
245 }
246 Ok(GraphFetchRequest {
247 limit,
248 cursor,
249 include_statements: self.include_statements.unwrap_or(false),
250 include_memories,
251 include_memory_edges,
252 include_tombstoned: self.include_tombstoned.unwrap_or(false),
253 act_as: None,
254 })
255 }
256}
257
258#[derive(Debug, Serialize)]
260pub struct GraphNodeDto {
261 pub id: String,
263 pub kind: &'static str,
264 pub label: String,
265 pub type_qname: String,
267}
268
269impl From<GraphNode> for GraphNodeDto {
270 fn from(n: GraphNode) -> Self {
271 let kind = match n.kind {
272 NODE_ENTITY => "entity",
273 NODE_STATEMENT => "statement",
274 NODE_MEMORY => "memory",
275 _ => "unknown",
276 };
277 Self {
278 id: node_id_str(&n.id, n.kind),
279 kind,
280 label: n.label,
281 type_qname: n.type_qname,
282 }
283 }
284}
285
286#[derive(Debug, Serialize)]
291pub struct GraphEdgeDto {
292 pub from_id: String,
293 pub to_id: String,
294 pub kind: &'static str,
295 pub label: String,
299}
300
301impl From<GraphEdge> for GraphEdgeDto {
302 fn from(e: GraphEdge) -> Self {
303 let kind = graph_edge_kind_str(e.kind).unwrap_or("unknown");
304 let (from_kind, to_kind) = if e.kind == EDGE_MENTIONS {
309 (NODE_MEMORY, NODE_ENTITY)
310 } else if memory_edge_kind(e.kind).is_some() {
311 (NODE_MEMORY, NODE_MEMORY)
312 } else {
313 (NODE_ENTITY, NODE_ENTITY)
314 };
315 Self {
316 from_id: node_id_str(&e.from_id, from_kind),
317 to_id: node_id_str(&e.to_id, to_kind),
318 kind,
319 label: e.label,
320 }
321 }
322}
323
324#[derive(Debug, Serialize)]
328pub struct GraphPageDto {
329 pub nodes: Vec<GraphNodeDto>,
330 pub edges: Vec<GraphEdgeDto>,
331 #[serde(skip_serializing_if = "Option::is_none")]
332 pub next_cursor: Option<String>,
333}
334
335impl GraphPageDto {
336 pub fn from_frames(frames: Vec<GraphFetchResponseFrame>) -> Self {
337 let next = frames
338 .last()
339 .map(|f| f.next_cursor.clone())
340 .filter(|c| !c.is_empty())
341 .map(|c| hex_encode(&c));
342 let mut nodes = Vec::new();
343 let mut edges = Vec::new();
344 for f in frames {
345 nodes.extend(f.nodes.into_iter().map(GraphNodeDto::from));
346 edges.extend(f.edges.into_iter().map(GraphEdgeDto::from));
347 }
348 Self {
349 nodes,
350 edges,
351 next_cursor: next,
352 }
353 }
354}
355
356#[cfg(test)]
357mod graph_tests {
358 use super::*;
359
360 #[test]
361 fn query_clamps_limit_and_defaults_layers() {
362 let q = GraphFetchQuery {
363 limit: Some(9999),
364 cursor: None,
365 include_statements: None,
366 include_memories: Some(true),
367 include_memory_edges: None,
368 include_tombstoned: None,
369 };
370 let r = q.to_request().unwrap();
371 assert_eq!(r.limit, GRAPH_MAX_LIMIT);
372 assert!(!r.include_statements);
373 assert!(r.include_memories);
374 assert!(!r.include_memory_edges);
375 assert_eq!(r.cursor, [] as [u8; 0]);
376 }
377
378 #[test]
379 fn memory_edges_require_the_memory_layer() {
380 let alone = GraphFetchQuery {
381 limit: None,
382 cursor: None,
383 include_statements: None,
384 include_memories: None,
385 include_memory_edges: Some(true),
386 include_tombstoned: None,
387 };
388 let err = alone.to_request().unwrap_err();
389 assert!(err.contains("include_memories"), "{err}");
390
391 let together = GraphFetchQuery {
392 limit: None,
393 cursor: None,
394 include_statements: None,
395 include_memories: Some(true),
396 include_memory_edges: Some(true),
397 include_tombstoned: None,
398 };
399 let r = together.to_request().unwrap();
400 assert!(r.include_memories && r.include_memory_edges);
401 }
402
403 #[test]
404 fn memory_edge_bytes_name_each_kind_and_read_both_ends_as_memories() {
405 let mut a = [0u8; 16];
406 a[15] = 11;
407 let mut b = [0u8; 16];
408 b[15] = 12;
409 let expected = [
410 (4u8, "caused"),
411 (5, "followed_by"),
412 (6, "derived_from"),
413 (7, "similar_to"),
414 (8, "contradicts"),
415 (9, "supports"),
416 (10, "references"),
417 (11, "part_of"),
418 ];
419 for (byte, name) in expected {
420 let e = GraphEdgeDto::from(GraphEdge {
421 from_id: a,
422 to_id: b,
423 kind: byte,
424 label: name.to_string(),
425 });
426 assert_eq!(e.kind, name, "byte {byte}");
427 assert_eq!(e.from_id, "11");
429 assert_eq!(e.to_id, "12");
430 }
431 }
432
433 #[test]
434 fn unknown_edge_byte_is_not_guessed() {
435 let e = GraphEdgeDto::from(GraphEdge {
436 from_id: [0u8; 16],
437 to_id: [0u8; 16],
438 kind: 99,
439 label: String::new(),
440 });
441 assert_eq!(e.kind, "unknown");
442 }
443
444 #[test]
445 fn entity_node_is_uuid_memory_node_is_decimal() {
446 let mut id = [0u8; 16];
447 id[15] = 5;
448 let ent = GraphNodeDto::from(GraphNode {
449 id,
450 kind: NODE_ENTITY,
451 label: "Sarah Chen".into(),
452 type_qname: "brain:Person".into(),
453 });
454 assert_eq!(ent.kind, "entity");
455 assert_eq!(ent.id, "00000000-0000-0000-0000-000000000005");
456
457 let mem = GraphNodeDto::from(GraphNode {
458 id,
459 kind: NODE_MEMORY,
460 label: "a memory".into(),
461 type_qname: String::new(),
462 });
463 assert_eq!(mem.kind, "memory");
464 assert_eq!(mem.id, "5"); }
466
467 #[test]
468 fn mentions_edge_source_is_decimal_memory() {
469 let mut mem = [0u8; 16];
470 mem[15] = 7;
471 let ent = [0x11u8; 16];
472 let e = GraphEdgeDto::from(GraphEdge {
473 from_id: mem,
474 to_id: ent,
475 kind: EDGE_MENTIONS,
476 label: String::new(),
477 });
478 assert_eq!(e.kind, "mentions");
479 assert_eq!(e.from_id, "7"); assert!(e.to_id.contains('-')); }
482
483 #[test]
484 fn relation_edge_both_ends_uuid() {
485 let a = [0x01u8; 16];
486 let b = [0x02u8; 16];
487 let e = GraphEdgeDto::from(GraphEdge {
488 from_id: a,
489 to_id: b,
490 kind: 0,
491 label: "brain:works_at".into(),
492 });
493 assert_eq!(e.kind, "relation");
494 assert!(e.from_id.contains('-') && e.to_id.contains('-'));
495 }
496}