Skip to main content

kiromi_ai_memory/
graph.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2//! Plan 10 multi-hop traversal types — `NodeRef`, `EdgeKind`, `Graph`,
3//! `TraverseOpts`. The actual `Memory::traverse` implementation lives on
4//! [`crate::Memory`] (see `handle.rs`).
5//!
6//! `Graph` is purposefully simple — `Vec<GraphNode>` and `Vec<GraphEdge>` —
7//! so callers can render it without a graph dependency. Plan 10 keeps this
8//! Rust-only; Swift consumers go through `Memory::traverse_json` to get
9//! `serde_json::to_string(&graph)`.
10
11use std::collections::BTreeSet;
12
13use serde::{Deserialize, Serialize};
14
15use crate::memory::MemoryRef;
16use crate::partition::PartitionPath;
17use crate::summary::{SummaryRef, SummarySubject};
18
19/// One node in a traversal graph.
20///
21/// Three kinds: a memory, a summary, or a partition. Memory and Summary
22/// carry the full ref; partitions carry just the path. Future fields
23/// (cached labels, attrs) will land on `GraphNode` rather than here.
24#[non_exhaustive]
25#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
26#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
27pub enum NodeRef {
28    /// A memory.
29    Memory(MemoryRef),
30    /// A summary.
31    Summary(SummaryRef),
32    /// A partition. The tenant root is represented as the
33    /// [`crate::partition::tenant_root_path`] sentinel.
34    Partition(PartitionPath),
35}
36
37/// One edge in a traversal graph. `EdgeKind` distinguishes the relation —
38/// link, summary input, parent partition, partition contains.
39#[non_exhaustive]
40#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
41#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
42pub enum EdgeKind {
43    /// `memory ↔ memory` explicit link.
44    Link,
45    /// `summary → input` — the summary cites this input. The variant
46    /// records the summary's subject so renderers can group "the summary
47    /// of partition X cites memory Y" intelligently.
48    SummaryInput {
49        /// Subject of the citing summary.
50        from_subject: SummarySubject,
51    },
52    /// `child → parent` partition edge (or `memory → partition`).
53    ParentPartition,
54    /// `parent → child` partition edge (or `partition → memory`).
55    PartitionContains,
56}
57
58impl EdgeKind {
59    /// Discriminant tag for [`TraverseOpts::include_kinds`] filtering.
60    #[must_use]
61    pub fn tag(&self) -> EdgeKindTag {
62        match self {
63            EdgeKind::Link => EdgeKindTag::Link,
64            EdgeKind::SummaryInput { .. } => EdgeKindTag::SummaryInput,
65            EdgeKind::ParentPartition => EdgeKindTag::ParentPartition,
66            EdgeKind::PartitionContains => EdgeKindTag::PartitionContains,
67        }
68    }
69}
70
71/// Discriminant-only enum used as a `BTreeSet` key in [`TraverseOpts`].
72#[non_exhaustive]
73#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum EdgeKindTag {
76    /// `memory ↔ memory` link.
77    Link,
78    /// `summary → input` citation.
79    SummaryInput,
80    /// `child → parent` partition.
81    ParentPartition,
82    /// `parent → child` partition.
83    PartitionContains,
84}
85
86impl EdgeKindTag {
87    /// Every variant. Used as the default for [`TraverseOpts::include_kinds`].
88    #[must_use]
89    pub fn all() -> BTreeSet<EdgeKindTag> {
90        let mut s = BTreeSet::new();
91        s.insert(EdgeKindTag::Link);
92        s.insert(EdgeKindTag::SummaryInput);
93        s.insert(EdgeKindTag::ParentPartition);
94        s.insert(EdgeKindTag::PartitionContains);
95        s
96    }
97}
98
99/// One edge.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct GraphEdge {
102    /// Source node.
103    pub from: NodeRef,
104    /// Destination node.
105    pub to: NodeRef,
106    /// Edge kind.
107    pub kind: EdgeKind,
108}
109
110/// One node — currently a thin wrapper around `NodeRef` so future fields
111/// (cached labels, attributes) can land additively.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct GraphNode {
114    /// The node's ref.
115    pub r#ref: NodeRef,
116}
117
118/// A traversal result. Nodes are de-duplicated; edges may include duplicates
119/// when both directions of a link were traversed.
120#[derive(Debug, Clone, Default, Serialize, Deserialize)]
121pub struct Graph {
122    /// Nodes in the graph.
123    pub nodes: Vec<GraphNode>,
124    /// Edges in the graph.
125    pub edges: Vec<GraphEdge>,
126}
127
128/// Options for [`crate::Memory::traverse`].
129#[non_exhaustive]
130#[derive(Debug, Clone, Serialize, Deserialize)]
131#[serde(default)]
132pub struct TraverseOpts {
133    /// Which edge kinds to follow. Defaults to every kind via
134    /// [`EdgeKindTag::all`].
135    pub include_kinds: BTreeSet<EdgeKindTag>,
136    /// Hard cap on the total number of nodes in the result. Default 256.
137    pub max_nodes: u32,
138    /// When false, parent / contains edges are never followed even if
139    /// `include_kinds` lists them. Convenience for "memory + link only"
140    /// queries that don't want the partition tree noise. Default `true`.
141    pub include_partition_edges: bool,
142}
143
144impl Default for TraverseOpts {
145    fn default() -> Self {
146        Self {
147            include_kinds: EdgeKindTag::all(),
148            max_nodes: 256,
149            include_partition_edges: true,
150        }
151    }
152}
153
154impl TraverseOpts {
155    /// Whether the supplied edge tag is permitted by the current opts.
156    #[must_use]
157    pub fn permits(&self, tag: EdgeKindTag) -> bool {
158        if !self.include_kinds.contains(&tag) {
159            return false;
160        }
161        if !self.include_partition_edges
162            && matches!(
163                tag,
164                EdgeKindTag::ParentPartition | EdgeKindTag::PartitionContains
165            )
166        {
167            return false;
168        }
169        true
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn default_opts_permit_everything() {
179        let o = TraverseOpts::default();
180        assert!(o.permits(EdgeKindTag::Link));
181        assert!(o.permits(EdgeKindTag::SummaryInput));
182        assert!(o.permits(EdgeKindTag::ParentPartition));
183        assert!(o.permits(EdgeKindTag::PartitionContains));
184    }
185
186    #[test]
187    fn include_partition_edges_off_blocks_partition_tags() {
188        let o = TraverseOpts {
189            include_partition_edges: false,
190            ..TraverseOpts::default()
191        };
192        assert!(o.permits(EdgeKindTag::Link));
193        assert!(!o.permits(EdgeKindTag::ParentPartition));
194        assert!(!o.permits(EdgeKindTag::PartitionContains));
195    }
196
197    #[test]
198    fn graph_serdes() {
199        let g = Graph::default();
200        let s = serde_json::to_string(&g).unwrap();
201        let _: Graph = serde_json::from_str(&s).unwrap();
202    }
203}