Skip to main content

brain_edge/dto/
graph.rs

1//! Memory-graph edge verbs (link / unlink) + the typed-graph export
2//! (GRAPH_FETCH).
3
4// The DTOs below mirror the HTTP contract one-for-one: the JSON field names are
5// the API, and `tools/http_manifest.py` emits every one of them — with its type
6// and serde attributes — into `contract/http-routes.json`, which the three SDK
7// clients are checked against. A doc comment on each of ~312 fields would
8// restate the field name; the ones that carry meaning beyond their name have
9// one written below.
10#![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
20/// Parse an edge-kind name (snake or lower case) to its wire variant.
21fn 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
35/// Render an edge kind as its snake-case name.
36fn 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/// `POST /v1/links` body — create/overwrite a directed edge between two memories.
50#[derive(Debug, Deserialize)]
51pub struct LinkBody {
52    pub source: String,
53    pub target: String,
54    pub kind: String,
55    /// `[0, 1]` for most kinds; `[-1, 1]` for `contradicts`. Defaults to `1.0`.
56    #[serde(default = "default_weight")]
57    pub weight: f32,
58}
59
60fn default_weight() -> f32 {
61    1.0
62}
63
64impl LinkBody {
65    /// Parse into `(source, target, kind, weight)`.
66    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/// `POST /v1/links` response.
77#[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/// `DELETE /v1/links` body — remove a directed edge (idempotent).
101#[derive(Debug, Deserialize)]
102pub struct UnlinkBody {
103    pub source: String,
104    pub target: String,
105    pub kind: String,
106}
107
108impl UnlinkBody {
109    /// Parse into `(source, target, kind)`.
110    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/// `DELETE /v1/links` response.
120#[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
139// ---- typed-graph export (GET /v1/graph) ----------------------------------
140
141/// Default / max statements consumed off the spine per `GET /v1/graph` page.
142const GRAPH_DEFAULT_LIMIT: u32 = 200;
143const GRAPH_MAX_LIMIT: u32 = 500;
144
145/// Node-kind bytes (mirror the wire `GraphNodeKindWire`).
146const NODE_ENTITY: u8 = 0;
147const NODE_STATEMENT: u8 = 1;
148const NODE_MEMORY: u8 = 2;
149/// Edge-kind bytes (mirror the wire `GraphEdgeKindWire`).
150const EDGE_MENTIONS: u8 = 3;
151/// The eight memory↔memory builtin kinds occupy `4..=11`, one byte per kind,
152/// laid out as `EDGE_MEMORY_BASE + EdgeKindWire as u8` — so the byte alone
153/// identifies the link and `edge_kind_str` can name it after the shift.
154const EDGE_MEMORY_BASE: u8 = 4;
155const EDGE_MEMORY_LAST: u8 = EDGE_MEMORY_BASE + 7;
156
157/// Name a graph-export edge-kind byte. `None` for a byte this build doesn't
158/// know, which the caller renders as `unknown` rather than guessing.
159fn 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
172/// The `EdgeKindWire` behind a memory↔memory export byte, if it is one.
173fn 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
189/// Render a 16-byte node id per its kind: memory ids are 128-bit decimals
190/// (matching `/v1/memories` + `/v1/recall`); entity/statement ids are UUIDs.
191fn 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/// `GET /v1/graph` query parameters. Default layer is the concept map
200/// (entities + Relation/Fact edges); the toggles add value-object statement
201/// nodes and source-memory provenance nodes.
202#[derive(Debug, Deserialize)]
203pub struct GraphFetchQuery {
204    /// Page size (statements off the spine), clamped to `1..=500` (default 200).
205    #[serde(default)]
206    pub limit: Option<u32>,
207    /// Opaque keyset cursor (hex) from a previous page's `next_cursor`.
208    #[serde(default)]
209    pub cursor: Option<String>,
210    /// Emit value-object statement nodes + `has_statement` edges.
211    #[serde(default)]
212    pub include_statements: Option<bool>,
213    /// Emit source memory nodes + `mentions` edges.
214    #[serde(default)]
215    pub include_memories: Option<bool>,
216    /// Emit the stored memory↔memory edges (`similar_to`, `followed_by`, …)
217    /// between the page's memory nodes. Requires `include_memories`; asking
218    /// for it alone is rejected up front rather than by the server, so the
219    /// caller gets a 400 naming the missing layer instead of a wire error.
220    #[serde(default)]
221    pub include_memory_edges: Option<bool>,
222    /// Include tombstoned statements/relations (default false).
223    #[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/// One graph node. `kind`: `entity` | `statement` | `memory`.
259#[derive(Debug, Serialize)]
260pub struct GraphNodeDto {
261    /// Entity/statement UUID or memory decimal id, per `kind`.
262    pub id: String,
263    pub kind: &'static str,
264    pub label: String,
265    /// Entity type qname (e.g. `brain:Person`); empty for non-entity nodes.
266    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/// One graph edge. `kind` is `relation` | `fact` | `has_statement` |
287/// `mentions` for the typed-graph projections, or one of the eight
288/// memory↔memory link names (`caused` | `followed_by` | `derived_from` |
289/// `similar_to` | `contradicts` | `supports` | `references` | `part_of`).
290#[derive(Debug, Serialize)]
291pub struct GraphEdgeDto {
292    pub from_id: String,
293    pub to_id: String,
294    pub kind: &'static str,
295    /// Predicate / relation-type label; empty for `mentions`. For a
296    /// memory↔memory edge it repeats the kind name, so a renderer can label
297    /// every edge uniformly off `label`.
298    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        // `mentions` runs memory→entity; the eight builtin kinds run
305        // memory→memory; everything else is entity/statement→entity/statement.
306        // The id-space matters: memory ids render as 128-bit decimals, the
307        // rest as UUIDs, so a mis-inferred end would print an unmatchable id.
308        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/// `GET /v1/graph` response: a page of nodes + edges plus the resume cursor.
325/// Nodes/edges may repeat across pages (completeness, not disjointness) —
326/// dedup by id. `next_cursor` is omitted when the export is exhausted.
327#[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            // Both ends are memories → 128-bit decimals, never UUIDs.
428            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"); // 128-bit decimal, not UUID
465    }
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"); // memory → decimal
480        assert!(e.to_id.contains('-')); // entity → uuid
481    }
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}