Skip to main content

rto_spec/
import.rs

1//! Importers that map an external tool's graph into Roteiro's provenance model.
2//!
3//! Currently: **Graphify** (a `NetworkX` node-link JSON graph). Per ADR-0001,
4//! Graphify's doc/media/concept knowledge is imported as `inferred` facts, while
5//! its code-structure (AST) nodes and edges are **dropped** in favour of
6//! Roteiro's own more precise derivation. Each import returns a
7//! [`ImportReport`] so the migration is auditable.
8
9use std::collections::{BTreeMap, HashSet};
10
11use rto_graph::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance};
12use serde::Deserialize;
13
14/// `src_ref` stamped on every edge imported from Graphify, so it can be told
15/// apart from other `inferred` edges (e.g. the embedding layer's).
16pub const GRAPHIFY_REF: &str = "import:graphify";
17
18/// Errors raised while importing.
19///
20/// Open to extension: one variant today is a statement about how little the
21/// importer currently validates, not a claim that nothing else can go wrong.
22#[non_exhaustive]
23#[derive(Debug, thiserror::Error)]
24pub enum ImportError {
25    /// The source JSON could not be parsed.
26    #[error("invalid graphify json: {0}")]
27    Json(#[from] serde_json::Error),
28}
29
30/// The result of importing a Graphify graph: the facts to apply and a report.
31#[derive(Debug, Clone)]
32pub struct GraphifyImport {
33    /// Nodes and `inferred` edges to apply to the store.
34    pub facts: FactSet,
35    /// A summary of what was imported vs. dropped.
36    pub report: ImportReport,
37}
38
39/// An auditable summary of a Graphify import.
40#[derive(Debug, Clone, Default, serde::Serialize)]
41pub struct ImportReport {
42    /// Total nodes in the source.
43    pub nodes_total: usize,
44    /// Doc/media/concept nodes imported.
45    pub nodes_imported: usize,
46    /// Code (AST) nodes dropped in favour of re-derivation.
47    pub nodes_dropped_code: usize,
48    /// Imported node count by Graphify `file_type`.
49    pub nodes_by_type: BTreeMap<String, usize>,
50    /// Total links in the source.
51    pub links_total: usize,
52    /// Semantic/inferred edges imported.
53    pub edges_imported: usize,
54    /// AST (code-structure) edges dropped in favour of re-derivation.
55    pub edges_dropped_ast: usize,
56    /// Semantic edges skipped because an endpoint was a dropped code node.
57    pub edges_skipped_dangling: usize,
58    /// Total hyperedges in the source.
59    pub hyperedges_total: usize,
60    /// Hyperedges imported as grouping nodes.
61    pub hyperedges_imported: usize,
62}
63
64// --- Graphify's NetworkX node-link schema (only the fields we use) ---
65
66#[derive(Deserialize)]
67struct GraphifyGraph {
68    #[serde(default)]
69    nodes: Vec<GNode>,
70    #[serde(default)]
71    links: Vec<GLink>,
72    #[serde(default)]
73    hyperedges: Vec<GHyper>,
74}
75
76#[derive(Deserialize)]
77struct GNode {
78    id: String,
79    #[serde(default)]
80    label: String,
81    #[serde(default)]
82    file_type: String,
83    #[serde(default)]
84    source_file: Option<String>,
85    #[serde(rename = "_origin", default)]
86    origin: String,
87    #[serde(default)]
88    community_name: Option<String>,
89}
90
91#[derive(Deserialize)]
92struct GLink {
93    source: String,
94    target: String,
95    #[serde(default)]
96    relation: String,
97    #[serde(default)]
98    confidence: String,
99    #[serde(default)]
100    confidence_score: Option<f64>,
101    #[serde(rename = "_origin", default)]
102    origin: String,
103}
104
105#[derive(Deserialize)]
106struct GHyper {
107    id: String,
108    #[serde(default)]
109    label: String,
110    #[serde(default)]
111    nodes: Vec<String>,
112    #[serde(default)]
113    confidence_score: Option<f64>,
114}
115
116/// A Graphify node is *code structure* (dropped, re-derived) when its file type
117/// is `code`. Everything else (document/concept/rationale/image) is imported.
118fn is_code_node(n: &GNode) -> bool {
119    n.file_type == "code"
120}
121
122/// A link is a *semantic/inferred* relationship (imported) rather than plain
123/// code-structure (dropped) when it did **not** come from the AST, **or** it is
124/// explicitly marked `INFERRED` confidence (a fuzzy suggestion is worth keeping
125/// even if Graphify tagged its origin as `ast`).
126fn is_semantic_link(l: &GLink) -> bool {
127    l.origin != "ast" || l.confidence.eq_ignore_ascii_case("inferred")
128}
129
130/// Map a Graphify `file_type` to a Roteiro node kind. An unset type is treated
131/// as a plain document (mapping to the real [`NodeKind::Doc`], not an `Other`
132/// token that would collide with `Doc`'s stable token on round-trip).
133fn node_kind(file_type: &str) -> NodeKind {
134    match file_type {
135        "document" | "" => NodeKind::Doc,
136        other => NodeKind::Other(other.to_owned()),
137    }
138}
139
140/// Map a Graphify relation to a Roteiro edge kind.
141fn edge_kind(relation: &str) -> EdgeKind {
142    match relation {
143        "conceptually_related_to" | "semantically_similar_to" | "" => EdgeKind::Related,
144        "references" | "rationale_for" => EdgeKind::References,
145        other => EdgeKind::Other(other.to_owned()),
146    }
147}
148
149/// The Roteiro node key for a Graphify node id.
150fn key(id: &str) -> String {
151    format!("graphify:{id}")
152}
153
154/// The Roteiro node key for a Graphify **hyperedge** group id, in a distinct
155/// namespace so a hyperedge can never collide with (and clobber) a regular node
156/// that happens to share its id.
157fn group_key(id: &str) -> String {
158    format!("graphify:group:{id}")
159}
160
161/// Confidence in `0.0..=1.0` for an imported edge (defaulting mid-scale).
162fn confidence(score: Option<f64>) -> f64 {
163    score.unwrap_or(0.5).clamp(0.0, 1.0)
164}
165
166/// Import a Graphify node-link JSON graph into Roteiro facts.
167///
168/// Doc/concept/rationale/image nodes become nodes keyed `graphify:<id>`;
169/// semantic/inferred links between two imported nodes become `inferred` edges
170/// (stamped [`GRAPHIFY_REF`]); hyperedges become grouping nodes with `related`
171/// edges to their imported members. Code/AST nodes and edges are dropped.
172///
173/// # Errors
174/// Returns [`ImportError::Json`] if `json` is not a valid Graphify graph.
175pub fn import_graphify(json: &str) -> Result<GraphifyImport, ImportError> {
176    let graph: GraphifyGraph = serde_json::from_str(json)?;
177    let mut report = ImportReport {
178        nodes_total: graph.nodes.len(),
179        links_total: graph.links.len(),
180        hyperedges_total: graph.hyperedges.len(),
181        ..ImportReport::default()
182    };
183    let mut facts = FactSet::new();
184    let mut imported: HashSet<String> = HashSet::new();
185
186    // Nodes.
187    for n in &graph.nodes {
188        if is_code_node(n) {
189            report.nodes_dropped_code += 1;
190            continue;
191        }
192        let node_key = key(&n.id);
193        let name = if n.label.is_empty() {
194            n.id.clone()
195        } else {
196            n.label.clone()
197        };
198        let mut node = Node::new(node_key.clone(), node_kind(&n.file_type), name)
199            .with_provenance(Provenance::Inferred);
200        node.path.clone_from(&n.source_file);
201        node.meta = serde_json::json!({
202            "graphify_id": n.id,
203            "file_type": n.file_type,
204            "origin": n.origin,
205            "community": n.community_name,
206        });
207        facts.nodes.push(node);
208        imported.insert(node_key);
209        *report
210            .nodes_by_type
211            .entry(if n.file_type.is_empty() {
212                "unknown".to_owned()
213            } else {
214                n.file_type.clone()
215            })
216            .or_default() += 1;
217        report.nodes_imported += 1;
218    }
219
220    // Links.
221    for l in &graph.links {
222        if !is_semantic_link(l) {
223            report.edges_dropped_ast += 1;
224            continue;
225        }
226        let (src, dst) = (key(&l.source), key(&l.target));
227        if !imported.contains(&src) || !imported.contains(&dst) {
228            // A semantic edge that touches a dropped code node.
229            report.edges_skipped_dangling += 1;
230            continue;
231        }
232        let mut edge = Edge::inferred(
233            src,
234            dst,
235            edge_kind(&l.relation),
236            confidence(l.confidence_score),
237        );
238        edge.src_ref = Some(GRAPHIFY_REF.to_owned());
239        facts.edges.push(edge);
240        report.edges_imported += 1;
241    }
242
243    // Hyperedges → a grouping node + `related` edges to imported members.
244    for h in &graph.hyperedges {
245        let members: Vec<String> = h
246            .nodes
247            .iter()
248            .map(|m| key(m))
249            .filter(|m| imported.contains(m))
250            .collect();
251        if members.is_empty() {
252            continue;
253        }
254        let gkey = group_key(&h.id);
255        let name = if h.label.is_empty() {
256            h.id.clone()
257        } else {
258            h.label.clone()
259        };
260        let mut group = Node::new(gkey.clone(), NodeKind::Other("group".to_owned()), name)
261            .with_provenance(Provenance::Inferred);
262        group.meta = serde_json::json!({ "graphify_id": h.id, "kind": "hyperedge" });
263        facts.nodes.push(group);
264        for member in members {
265            let mut edge = Edge::inferred(
266                gkey.clone(),
267                member,
268                EdgeKind::Related,
269                confidence(h.confidence_score),
270            );
271            edge.src_ref = Some(GRAPHIFY_REF.to_owned());
272            facts.edges.push(edge);
273        }
274        report.hyperedges_imported += 1;
275    }
276
277    Ok(GraphifyImport { facts, report })
278}
279
280#[cfg(test)]
281mod tests {
282    use super::{GRAPHIFY_REF, import_graphify};
283    use rto_graph::{EdgeKind, NodeKind, Provenance};
284
285    // A miniature Graphify graph exercising each rule.
286    const SAMPLE: &str = r#"{
287      "directed": false, "multigraph": false,
288      "nodes": [
289        {"id": "adr59", "label": "ADR-0059", "file_type": "concept", "source_file": "docs/adr/0059.md", "_origin": "semantic", "community_name": "adrs"},
290        {"id": "doc1", "label": "Design note", "file_type": "document", "source_file": "docs/design.md", "_origin": "semantic"},
291        {"id": "codeA", "label": "fn a", "file_type": "code", "source_file": "src/a.rs", "_origin": "ast"}
292      ],
293      "links": [
294        {"source": "adr59", "target": "doc1", "relation": "conceptually_related_to", "confidence": "INFERRED", "confidence_score": 0.82, "_origin": "semantic"},
295        {"source": "codeA", "target": "doc1", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "_origin": "ast"},
296        {"source": "adr59", "target": "codeA", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "_origin": "semantic"}
297      ],
298      "hyperedges": [
299        {"id": "grp1", "label": "ADR cluster", "nodes": ["adr59", "doc1", "codeA"], "confidence_score": 0.9}
300      ]
301    }"#;
302
303    #[test]
304    fn imports_docs_and_semantic_edges_drops_code() {
305        let out = import_graphify(SAMPLE).expect("import");
306        let r = &out.report;
307
308        // Two doc/concept nodes imported; one code node dropped.
309        assert_eq!(r.nodes_total, 3);
310        assert_eq!(r.nodes_imported, 2);
311        assert_eq!(r.nodes_dropped_code, 1);
312        assert_eq!(r.nodes_by_type.get("concept"), Some(&1));
313        assert_eq!(r.nodes_by_type.get("document"), Some(&1));
314
315        // Edges: adr59→doc1 (semantic) imported; codeA→doc1 (ast) dropped;
316        // adr59→codeA (semantic but code endpoint) skipped as dangling.
317        assert_eq!(r.edges_imported, 1);
318        assert_eq!(r.edges_dropped_ast, 1);
319        assert_eq!(r.edges_skipped_dangling, 1);
320
321        // The hyperedge keeps only its two imported members.
322        assert_eq!(r.hyperedges_imported, 1);
323
324        // The imported semantic edge is inferred, related-kind, correct
325        // confidence, and stamped with the graphify src_ref.
326        let e = out
327            .facts
328            .edges
329            .iter()
330            .find(|e| e.src == "graphify:adr59" && e.dst == "graphify:doc1")
331            .expect("semantic edge");
332        assert_eq!(e.provenance, Provenance::Inferred);
333        assert_eq!(e.kind, EdgeKind::Related);
334        assert_eq!(e.confidence, Some(0.82));
335        assert_eq!(e.src_ref.as_deref(), Some(GRAPHIFY_REF));
336
337        // The concept node carries its path and a Doc/Other kind.
338        let n = out
339            .facts
340            .nodes
341            .iter()
342            .find(|n| n.key == "graphify:adr59")
343            .expect("concept node");
344        assert_eq!(n.kind, NodeKind::Other("concept".to_owned()));
345        assert_eq!(n.path.as_deref(), Some("docs/adr/0059.md"));
346        assert_eq!(n.meta["graphify_id"], "adr59");
347        // Graphify nodes are the inferred layer (heuristic import).
348        assert_eq!(n.provenance, Provenance::Inferred);
349
350        // A `document` node maps to NodeKind::Doc.
351        let d = out
352            .facts
353            .nodes
354            .iter()
355            .find(|n| n.key == "graphify:doc1")
356            .expect("doc node");
357        assert_eq!(d.kind, NodeKind::Doc);
358
359        // Every applied fact is valid for the store (invariants hold).
360        for edge in &out.facts.edges {
361            assert!(edge.is_valid());
362        }
363    }
364
365    #[test]
366    fn hyperedge_group_links_only_imported_members() {
367        let out = import_graphify(SAMPLE).expect("import");
368        // The group lives in a distinct `graphify:group:` namespace.
369        let group = out
370            .facts
371            .nodes
372            .iter()
373            .find(|n| n.key == "graphify:group:grp1")
374            .expect("group node");
375        assert_eq!(group.kind, NodeKind::Other("group".to_owned()));
376        // Group → adr59 and doc1 (imported), not codeA (dropped).
377        let group_edges: Vec<_> = out
378            .facts
379            .edges
380            .iter()
381            .filter(|e| e.src == "graphify:group:grp1")
382            .map(|e| e.dst.as_str())
383            .collect();
384        assert_eq!(group_edges.len(), 2);
385        assert!(group_edges.contains(&"graphify:adr59"));
386        assert!(group_edges.contains(&"graphify:doc1"));
387        assert!(!group_edges.contains(&"graphify:codeA"));
388    }
389
390    #[test]
391    fn group_id_colliding_with_a_node_id_does_not_clobber() {
392        // A node and a hyperedge share the id "x": the group must land under
393        // `graphify:group:x`, leaving the real node `graphify:x` intact.
394        let json = r#"{
395          "nodes": [
396            {"id": "x", "label": "real node", "file_type": "document", "_origin": "semantic"},
397            {"id": "y", "label": "other", "file_type": "concept", "_origin": "semantic"}
398          ],
399          "links": [],
400          "hyperedges": [
401            {"id": "x", "label": "group named x", "nodes": ["y"], "confidence_score": 0.9}
402          ]
403        }"#;
404        let out = import_graphify(json).expect("import");
405        let real = out
406            .facts
407            .nodes
408            .iter()
409            .find(|n| n.key == "graphify:x")
410            .expect("real node survives");
411        assert_eq!(real.name, "real node");
412        let group = out
413            .facts
414            .nodes
415            .iter()
416            .find(|n| n.key == "graphify:group:x")
417            .expect("group in its own namespace");
418        assert_eq!(group.name, "group named x");
419    }
420
421    #[test]
422    fn invalid_json_errors() {
423        assert!(import_graphify("not json").is_err());
424    }
425}