Skip to main content

rto_graph/
model.rs

1// roteiro:ignore-file — defines the Marker node and its category names, so it
2// names the intent-debt vocabulary; not real debt in this repo.
3//! In-memory graph domain types: nodes, edges, and the [`FactSet`] that groups
4//! the facts extracted from a single source blob.
5//!
6//! Node and edge *kinds* are open sets: known variants have stable string
7//! tokens, and any other token round-trips through [`NodeKind::Other`] /
8//! [`EdgeKind::Other`] so new extractors can introduce kinds without a schema
9//! change. Nodes are addressed by a deterministic natural [`Node::key`]; edges
10//! reference their endpoints by that key, and the store resolves keys to row
11//! ids on insert.
12
13use serde::{Deserialize, Deserializer, Serialize, Serializer};
14
15use crate::Provenance;
16
17/// The kind of a graph node.
18///
19/// Known kinds have stable tokens (`fn`, `struct`, …); unrecognised tokens are
20/// preserved verbatim in [`NodeKind::Other`].
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum NodeKind {
23    /// A function or method.
24    Fn,
25    /// A struct type.
26    Struct,
27    /// An enum type.
28    Enum,
29    /// A trait / interface.
30    Trait,
31    /// A module or namespace.
32    Module,
33    /// A source file.
34    File,
35    /// An Architecture Decision Record.
36    Adr,
37    /// A section within an ADR.
38    AdrSection,
39    /// A blueprint document.
40    Blueprint,
41    /// A free-form documentation artifact.
42    Doc,
43    /// An intent-debt marker (a `TODO`/`FIXME`/stub/deferred-work finding).
44    Marker,
45    /// Any kind not covered above, kept verbatim.
46    Other(String),
47}
48
49impl NodeKind {
50    /// The stable string token for this kind, as stored in the database.
51    #[must_use]
52    pub fn as_str(&self) -> &str {
53        match self {
54            Self::Fn => "fn",
55            Self::Struct => "struct",
56            Self::Enum => "enum",
57            Self::Trait => "trait",
58            Self::Module => "module",
59            Self::File => "file",
60            Self::Adr => "adr",
61            Self::AdrSection => "adr_section",
62            Self::Blueprint => "blueprint",
63            Self::Doc => "doc",
64            Self::Marker => "marker",
65            Self::Other(s) => s,
66        }
67    }
68
69    /// Parse a kind from its string token. Unknown tokens become
70    /// [`NodeKind::Other`], so this is infallible.
71    #[must_use]
72    pub fn from_token(s: &str) -> Self {
73        match s {
74            "fn" => Self::Fn,
75            "struct" => Self::Struct,
76            "enum" => Self::Enum,
77            "trait" => Self::Trait,
78            "module" => Self::Module,
79            "file" => Self::File,
80            "adr" => Self::Adr,
81            "adr_section" => Self::AdrSection,
82            "blueprint" => Self::Blueprint,
83            "doc" => Self::Doc,
84            "marker" => Self::Marker,
85            other => Self::Other(other.to_owned()),
86        }
87    }
88}
89
90/// The kind of a graph edge (the relationship it records).
91///
92/// Known kinds have stable tokens; unrecognised tokens are preserved in
93/// [`EdgeKind::Other`].
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum EdgeKind {
96    /// Source calls target.
97    Calls,
98    /// Source imports target.
99    Imports,
100    /// Source defines target.
101    Defines,
102    /// Source contains target (structural nesting).
103    Contains,
104    /// Source references target (unspecified use).
105    References,
106    /// Source supersedes target (e.g. a later ADR).
107    Supersedes,
108    /// Target is authored by / documented in source.
109    AuthoredBy,
110    /// Target is inferred from source.
111    InferredFrom,
112    /// Source and target are semantically related (inferred by similarity).
113    Related,
114    /// Any kind not covered above, kept verbatim.
115    Other(String),
116}
117
118impl EdgeKind {
119    /// The stable string token for this kind, as stored in the database.
120    #[must_use]
121    pub fn as_str(&self) -> &str {
122        match self {
123            Self::Calls => "calls",
124            Self::Imports => "imports",
125            Self::Defines => "defines",
126            Self::Contains => "contains",
127            Self::References => "references",
128            Self::Supersedes => "supersedes",
129            Self::AuthoredBy => "authored_by",
130            Self::InferredFrom => "inferred_from",
131            Self::Related => "related",
132            Self::Other(s) => s,
133        }
134    }
135
136    /// Parse a kind from its string token. Unknown tokens become
137    /// [`EdgeKind::Other`], so this is infallible.
138    #[must_use]
139    pub fn from_token(s: &str) -> Self {
140        match s {
141            "calls" => Self::Calls,
142            "imports" => Self::Imports,
143            "defines" => Self::Defines,
144            "contains" => Self::Contains,
145            "references" => Self::References,
146            "supersedes" => Self::Supersedes,
147            "authored_by" => Self::AuthoredBy,
148            "inferred_from" => Self::InferredFrom,
149            "related" => Self::Related,
150            other => Self::Other(other.to_owned()),
151        }
152    }
153}
154
155// Kinds (de)serialize as their bare string token so on-disk fact sets and the
156// database agree on representation.
157impl Serialize for NodeKind {
158    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
159        serializer.serialize_str(self.as_str())
160    }
161}
162
163impl<'de> Deserialize<'de> for NodeKind {
164    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
165        let s = String::deserialize(deserializer)?;
166        Ok(Self::from_token(&s))
167    }
168}
169
170impl Serialize for EdgeKind {
171    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
172        serializer.serialize_str(self.as_str())
173    }
174}
175
176impl<'de> Deserialize<'de> for EdgeKind {
177    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
178        let s = String::deserialize(deserializer)?;
179        Ok(Self::from_token(&s))
180    }
181}
182
183/// A byte-offset range within a source blob (`start..end`).
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
185pub struct Span {
186    /// Inclusive start byte offset.
187    pub start: u32,
188    /// Exclusive end byte offset.
189    pub end: u32,
190}
191
192impl Span {
193    /// Construct a span from a start and end byte offset.
194    #[must_use]
195    pub fn new(start: u32, end: u32) -> Self {
196        Self { start, end }
197    }
198}
199
200/// A node in the knowledge graph.
201///
202/// [`Node::key`] is the deterministic natural identity used for upserts (e.g.
203/// `sym:rust:src/lib.rs#Store`); the database row id is an internal detail.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205pub struct Node {
206    /// Deterministic, unique natural key.
207    pub key: String,
208    /// The kind of thing this node represents.
209    pub kind: NodeKind,
210    /// Human-facing name (need not be unique).
211    pub name: String,
212    /// Repository-relative source path, if any.
213    pub path: Option<String>,
214    /// Language token (e.g. `rust`), if applicable.
215    pub lang: Option<String>,
216    /// Git blob hash this node was extracted from, if applicable.
217    pub blob_hash: Option<String>,
218    /// Byte span within the source blob, if applicable.
219    pub span: Option<Span>,
220    /// Which layer produced this node: [`Provenance::Derived`] (tree-sitter
221    /// extraction), [`Provenance::Authored`] (ADR/blueprint/lat sections), or
222    /// [`Provenance::Inferred`] (Graphify import). Lets layer-scoped operations —
223    /// e.g. a derived-only incremental `sync` — target one layer without
224    /// disturbing the others.
225    ///
226    /// Defaults to `Derived` when absent — correct for a legacy *extraction*
227    /// object-cache entry (derived-only) written before this field existed. A
228    /// legacy persisted *import* layer also deserializes its nodes as `Derived`,
229    /// but the store repairs that on load (an import node is never derived), so
230    /// the default never mislabels a durable import layer.
231    #[serde(default)]
232    pub provenance: Provenance,
233    /// Arbitrary structured metadata.
234    #[serde(default)]
235    pub meta: serde_json::Value,
236}
237
238impl Node {
239    /// Construct a node with the given key, kind, and name; all optional fields
240    /// unset, `meta` null, and provenance [`Provenance::Derived`] (extraction is
241    /// the common case — authored/inferred producers call [`Node::with_provenance`]).
242    #[must_use]
243    pub fn new(key: impl Into<String>, kind: NodeKind, name: impl Into<String>) -> Self {
244        Self {
245            key: key.into(),
246            kind,
247            name: name.into(),
248            path: None,
249            lang: None,
250            blob_hash: None,
251            span: None,
252            provenance: Provenance::Derived,
253            meta: serde_json::Value::Null,
254        }
255    }
256
257    /// Set the producing layer, returning the node (builder style). Used by the
258    /// authored (ADR/blueprint/lat) and inferred (Graphify) producers.
259    #[must_use]
260    pub fn with_provenance(mut self, provenance: Provenance) -> Self {
261        self.provenance = provenance;
262        self
263    }
264}
265
266/// An edge in the knowledge graph, connecting two nodes by their keys.
267///
268/// The [`Provenance`] invariant is enforced on insert: `confidence` is present
269/// if and only if the provenance is [`Provenance::Inferred`].
270#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
271pub struct Edge {
272    /// Natural key of the source node.
273    pub src: String,
274    /// Natural key of the destination node.
275    pub dst: String,
276    /// The relationship this edge records.
277    pub kind: EdgeKind,
278    /// How this edge was produced.
279    pub provenance: Provenance,
280    /// Confidence score in `0.0..=1.0`; `Some` iff `provenance` is inferred.
281    pub confidence: Option<f64>,
282    /// Where the fact came from (e.g. `blob#span`, or an ADR id).
283    pub src_ref: Option<String>,
284}
285
286impl Edge {
287    /// Construct a `derived` edge (no confidence).
288    #[must_use]
289    pub fn derived(src: impl Into<String>, dst: impl Into<String>, kind: EdgeKind) -> Self {
290        Self {
291            src: src.into(),
292            dst: dst.into(),
293            kind,
294            provenance: Provenance::Derived,
295            confidence: None,
296            src_ref: None,
297        }
298    }
299
300    /// Construct an `authored` edge (no confidence).
301    #[must_use]
302    pub fn authored(src: impl Into<String>, dst: impl Into<String>, kind: EdgeKind) -> Self {
303        Self {
304            src: src.into(),
305            dst: dst.into(),
306            kind,
307            provenance: Provenance::Authored,
308            confidence: None,
309            src_ref: None,
310        }
311    }
312
313    /// Construct an `inferred` edge carrying a confidence score.
314    #[must_use]
315    pub fn inferred(
316        src: impl Into<String>,
317        dst: impl Into<String>,
318        kind: EdgeKind,
319        confidence: f64,
320    ) -> Self {
321        Self {
322            src: src.into(),
323            dst: dst.into(),
324            kind,
325            provenance: Provenance::Inferred,
326            confidence: Some(confidence),
327            src_ref: None,
328        }
329    }
330
331    /// Whether this edge is valid for storage: a confidence score is present
332    /// exactly when the edge is inferred, and any present score is a finite
333    /// value in `0.0..=1.0` (rejecting NaN, infinities, and out-of-range).
334    #[must_use]
335    pub fn is_valid(&self) -> bool {
336        let inferred = matches!(self.provenance, Provenance::Inferred);
337        match self.confidence {
338            Some(c) => inferred && (0.0..=1.0).contains(&c),
339            None => !inferred,
340        }
341    }
342}
343
344/// The set of nodes and edges extracted from a single source blob (or otherwise
345/// assembled together). Applying a fact set to a [`crate::Store`] is atomic.
346#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
347pub struct FactSet {
348    /// Nodes to upsert.
349    pub nodes: Vec<Node>,
350    /// Edges to insert (endpoints must resolve to nodes in this set or already
351    /// present in the store).
352    pub edges: Vec<Edge>,
353}
354
355impl FactSet {
356    /// An empty fact set.
357    #[must_use]
358    pub fn new() -> Self {
359        Self::default()
360    }
361
362    /// Add a node, returning `self` for chaining.
363    #[must_use]
364    pub fn with_node(mut self, node: Node) -> Self {
365        self.nodes.push(node);
366        self
367    }
368
369    /// Add an edge, returning `self` for chaining.
370    #[must_use]
371    pub fn with_edge(mut self, edge: Edge) -> Self {
372        self.edges.push(edge);
373        self
374    }
375
376    /// Whether the fact set has no nodes and no edges.
377    #[must_use]
378    pub fn is_empty(&self) -> bool {
379        self.nodes.is_empty() && self.edges.is_empty()
380    }
381}
382
383/// Direction of traversal when querying a node's neighbours.
384#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385pub enum Direction {
386    /// Follow edges where the node is the source.
387    Outgoing,
388    /// Follow edges where the node is the destination.
389    Incoming,
390    /// Follow edges in either direction.
391    Both,
392}
393
394#[cfg(test)]
395mod tests {
396    use super::{Edge, EdgeKind, FactSet, Node, NodeKind};
397    use crate::Provenance;
398
399    #[test]
400    fn node_kind_tokens_round_trip() {
401        let kinds = [
402            NodeKind::Fn,
403            NodeKind::Struct,
404            NodeKind::Enum,
405            NodeKind::Trait,
406            NodeKind::Module,
407            NodeKind::File,
408            NodeKind::Adr,
409            NodeKind::AdrSection,
410            NodeKind::Blueprint,
411            NodeKind::Doc,
412            NodeKind::Marker,
413            NodeKind::Other("weird".to_owned()),
414        ];
415        for k in kinds {
416            assert_eq!(NodeKind::from_token(k.as_str()), k);
417        }
418    }
419
420    #[test]
421    fn edge_kind_tokens_round_trip() {
422        let kinds = [
423            EdgeKind::Calls,
424            EdgeKind::Imports,
425            EdgeKind::Defines,
426            EdgeKind::Contains,
427            EdgeKind::References,
428            EdgeKind::Supersedes,
429            EdgeKind::AuthoredBy,
430            EdgeKind::InferredFrom,
431            EdgeKind::Other("weird".to_owned()),
432        ];
433        for k in kinds {
434            assert_eq!(EdgeKind::from_token(k.as_str()), k);
435        }
436    }
437
438    #[test]
439    fn kinds_serialize_as_bare_tokens() {
440        assert_eq!(
441            serde_json::to_string(&NodeKind::AdrSection).unwrap(),
442            "\"adr_section\""
443        );
444        assert_eq!(
445            serde_json::to_string(&EdgeKind::AuthoredBy).unwrap(),
446            "\"authored_by\""
447        );
448        let k: NodeKind = serde_json::from_str("\"struct\"").unwrap();
449        assert_eq!(k, NodeKind::Struct);
450    }
451
452    #[test]
453    fn edge_validity_tracks_provenance() {
454        assert!(Edge::derived("a", "b", EdgeKind::Calls).is_valid());
455        assert!(Edge::authored("a", "b", EdgeKind::AuthoredBy).is_valid());
456        assert!(Edge::inferred("a", "b", EdgeKind::References, 0.5).is_valid());
457        // Boundary values are valid.
458        assert!(Edge::inferred("a", "b", EdgeKind::References, 0.0).is_valid());
459        assert!(Edge::inferred("a", "b", EdgeKind::References, 1.0).is_valid());
460
461        let inferred = Edge::inferred("a", "b", EdgeKind::References, 0.5);
462        // Non-inferred edge carrying confidence is invalid.
463        let bad = Edge {
464            provenance: Provenance::Derived,
465            confidence: Some(0.9),
466            ..Edge::derived("a", "b", EdgeKind::Calls)
467        };
468        assert!(!bad.is_valid());
469        // Inferred edge without confidence is invalid.
470        assert!(
471            !Edge {
472                confidence: None,
473                ..inferred.clone()
474            }
475            .is_valid()
476        );
477        // Out-of-range and non-finite confidences are invalid.
478        for c in [-0.1, 1.1, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
479            assert!(
480                !Edge {
481                    confidence: Some(c),
482                    ..inferred.clone()
483                }
484                .is_valid(),
485                "confidence {c} should be rejected"
486            );
487        }
488    }
489
490    #[test]
491    fn factset_builders() {
492        let fs = FactSet::new()
493            .with_node(Node::new("a", NodeKind::Fn, "a"))
494            .with_edge(Edge::derived("a", "a", EdgeKind::Calls));
495        assert_eq!(fs.nodes.len(), 1);
496        assert_eq!(fs.edges.len(), 1);
497        assert!(!fs.is_empty());
498        assert!(FactSet::new().is_empty());
499    }
500
501    #[test]
502    fn node_provenance_defaults_and_builder() {
503        // A legacy cached fact set (serialized before nodes carried provenance)
504        // must still deserialize — the field defaults to Derived, the correct
505        // value for the derived-only extraction cache. This is what keeps existing
506        // `.git/roteiro` object caches loadable after the schema change.
507        let legacy = r#"{"key":"k","kind":"fn","name":"n","path":null,"lang":null,"blob_hash":null,"span":null,"meta":null}"#;
508        let node: Node = serde_json::from_str(legacy).expect("legacy node deserializes");
509        assert_eq!(node.provenance, Provenance::Derived);
510        // New nodes default to Derived; the builder sets the authored/inferred layer.
511        assert_eq!(
512            Node::new("k", NodeKind::Fn, "n").provenance,
513            Provenance::Derived
514        );
515        assert_eq!(
516            Node::new("k", NodeKind::Adr, "n")
517                .with_provenance(Provenance::Authored)
518                .provenance,
519            Provenance::Authored
520        );
521    }
522}