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