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}