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    ///
335    /// # `external-inferred` is **not** inferred for this purpose
336    ///
337    /// The test names [`Provenance::Inferred`] exactly, so an imported edge —
338    /// including one at [`Provenance::ExternalInferred`] — must carry **no**
339    /// confidence. A score is a number *this graph computed*; OKF records none
340    /// for a relationship, so an import has nothing to adopt and putting one
341    /// there would fabricate a precision nobody measured. Reaching for
342    /// `Provenance::tier()` here would quietly require the opposite, and the
343    /// store's own `CHECK` (migration 14) would then reject what this accepts.
344    #[must_use]
345    pub fn is_valid(&self) -> bool {
346        let inferred = matches!(self.provenance, Provenance::Inferred);
347        match self.confidence {
348            Some(c) => inferred && (0.0..=1.0).contains(&c),
349            None => !inferred,
350        }
351    }
352}
353
354/// The set of nodes and edges extracted from a single source blob (or otherwise
355/// assembled together). Applying a fact set to a [`crate::Store`] is atomic.
356#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
357pub struct FactSet {
358    /// Nodes to upsert.
359    pub nodes: Vec<Node>,
360    /// Edges to insert (endpoints must resolve to nodes in this set or already
361    /// present in the store).
362    pub edges: Vec<Edge>,
363}
364
365impl FactSet {
366    /// An empty fact set.
367    #[must_use]
368    pub fn new() -> Self {
369        Self::default()
370    }
371
372    /// Add a node, returning `self` for chaining.
373    #[must_use]
374    pub fn with_node(mut self, node: Node) -> Self {
375        self.nodes.push(node);
376        self
377    }
378
379    /// Add an edge, returning `self` for chaining.
380    #[must_use]
381    pub fn with_edge(mut self, edge: Edge) -> Self {
382        self.edges.push(edge);
383        self
384    }
385
386    /// Whether the fact set has no nodes and no edges.
387    #[must_use]
388    pub fn is_empty(&self) -> bool {
389        self.nodes.is_empty() && self.edges.is_empty()
390    }
391}
392
393/// Direction of traversal when querying a node's neighbours.
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
395pub enum Direction {
396    /// Follow edges where the node is the source.
397    Outgoing,
398    /// Follow edges where the node is the destination.
399    Incoming,
400    /// Follow edges in either direction.
401    Both,
402}
403
404#[cfg(test)]
405mod tests {
406    use super::{Edge, EdgeKind, FactSet, Node, NodeKind};
407    use crate::Provenance;
408
409    #[test]
410    fn node_kind_tokens_round_trip() {
411        let kinds = [
412            NodeKind::Fn,
413            NodeKind::Struct,
414            NodeKind::Enum,
415            NodeKind::Trait,
416            NodeKind::Module,
417            NodeKind::File,
418            NodeKind::Adr,
419            NodeKind::AdrSection,
420            NodeKind::Blueprint,
421            NodeKind::Doc,
422            NodeKind::Marker,
423            NodeKind::Other("weird".to_owned()),
424        ];
425        for k in kinds {
426            assert_eq!(NodeKind::from_token(k.as_str()), k);
427        }
428    }
429
430    #[test]
431    fn edge_kind_tokens_round_trip() {
432        let kinds = [
433            EdgeKind::Calls,
434            EdgeKind::Imports,
435            EdgeKind::Defines,
436            EdgeKind::Contains,
437            EdgeKind::References,
438            EdgeKind::Supersedes,
439            EdgeKind::AuthoredBy,
440            EdgeKind::InferredFrom,
441            EdgeKind::Other("weird".to_owned()),
442        ];
443        for k in kinds {
444            assert_eq!(EdgeKind::from_token(k.as_str()), k);
445        }
446    }
447
448    #[test]
449    fn kinds_serialize_as_bare_tokens() {
450        assert_eq!(
451            serde_json::to_string(&NodeKind::AdrSection).unwrap(),
452            "\"adr_section\""
453        );
454        assert_eq!(
455            serde_json::to_string(&EdgeKind::AuthoredBy).unwrap(),
456            "\"authored_by\""
457        );
458        let k: NodeKind = serde_json::from_str("\"struct\"").unwrap();
459        assert_eq!(k, NodeKind::Struct);
460    }
461
462    #[test]
463    fn edge_validity_tracks_provenance() {
464        assert!(Edge::derived("a", "b", EdgeKind::Calls).is_valid());
465        assert!(Edge::authored("a", "b", EdgeKind::AuthoredBy).is_valid());
466        assert!(Edge::inferred("a", "b", EdgeKind::References, 0.5).is_valid());
467        // Boundary values are valid.
468        assert!(Edge::inferred("a", "b", EdgeKind::References, 0.0).is_valid());
469        assert!(Edge::inferred("a", "b", EdgeKind::References, 1.0).is_valid());
470
471        let inferred = Edge::inferred("a", "b", EdgeKind::References, 0.5);
472        // Non-inferred edge carrying confidence is invalid.
473        let bad = Edge {
474            provenance: Provenance::Derived,
475            confidence: Some(0.9),
476            ..Edge::derived("a", "b", EdgeKind::Calls)
477        };
478        assert!(!bad.is_valid());
479        // Inferred edge without confidence is invalid.
480        assert!(
481            !Edge {
482                confidence: None,
483                ..inferred.clone()
484            }
485            .is_valid()
486        );
487        // Out-of-range and non-finite confidences are invalid.
488        for c in [-0.1, 1.1, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
489            assert!(
490                !Edge {
491                    confidence: Some(c),
492                    ..inferred.clone()
493                }
494                .is_valid(),
495                "confidence {c} should be rejected"
496            );
497        }
498    }
499
500    #[test]
501    fn factset_builders() {
502        let fs = FactSet::new()
503            .with_node(Node::new("a", NodeKind::Fn, "a"))
504            .with_edge(Edge::derived("a", "a", EdgeKind::Calls));
505        assert_eq!(fs.nodes.len(), 1);
506        assert_eq!(fs.edges.len(), 1);
507        assert!(!fs.is_empty());
508        assert!(FactSet::new().is_empty());
509    }
510
511    #[test]
512    fn node_provenance_defaults_and_builder() {
513        // A legacy cached fact set (serialized before nodes carried provenance)
514        // must still deserialize — the field defaults to Derived, the correct
515        // value for the derived-only extraction cache. This is what keeps existing
516        // `.git/roteiro` object caches loadable after the schema change.
517        let legacy = r#"{"key":"k","kind":"fn","name":"n","path":null,"lang":null,"blob_hash":null,"span":null,"meta":null}"#;
518        let node: Node = serde_json::from_str(legacy).expect("legacy node deserializes");
519        assert_eq!(node.provenance, Provenance::Derived);
520        // New nodes default to Derived; the builder sets the authored/inferred layer.
521        assert_eq!(
522            Node::new("k", NodeKind::Fn, "n").provenance,
523            Provenance::Derived
524        );
525        assert_eq!(
526            Node::new("k", NodeKind::Adr, "n")
527                .with_provenance(Provenance::Authored)
528                .provenance,
529            Provenance::Authored
530        );
531    }
532}