Skip to main content

ipfrs_tensorlogic/
proof_tree_export.rs

1//! Proof tree exporter — renders verified proof trees to multiple output formats.
2//!
3//! Supported formats:
4//! - [`ExportFormat::Dot`] — Graphviz DOT language
5//! - [`ExportFormat::Json`] — compact JSON array via serde_json
6//! - [`ExportFormat::IndentedText`] — indented ASCII tree (2 spaces per depth)
7//! - [`ExportFormat::EdgeList`] — `from_id -> to_id` one per line
8
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11
12// ---------------------------------------------------------------------------
13// Data types
14// ---------------------------------------------------------------------------
15
16/// A single node in the exported proof tree.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct ExportNode {
19    /// Unique identifier for this node.
20    pub node_id: u64,
21    /// Identifier of the rule applied at this node.
22    pub rule_id: String,
23    /// The goal (predicate / formula) proved at this node.
24    pub goal: String,
25    /// Depth of this node from the root (root = 0).
26    pub depth: usize,
27    /// Ordered list of child node IDs.
28    pub children: Vec<u64>,
29}
30
31// ---------------------------------------------------------------------------
32// Export format
33// ---------------------------------------------------------------------------
34
35/// Output format for [`ProofTreeExporter`].
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum ExportFormat {
38    /// Graphviz DOT language.
39    Dot,
40    /// Compact JSON array.
41    Json,
42    /// Indented ASCII tree (2 spaces per depth level).
43    IndentedText,
44    /// One `from_id -> to_id` edge per line.
45    EdgeList,
46}
47
48// ---------------------------------------------------------------------------
49// Export configuration
50// ---------------------------------------------------------------------------
51
52/// Configuration for [`ProofTreeExporter`].
53#[derive(Debug, Clone)]
54pub struct ExportConfig {
55    /// Target output format.
56    pub format: ExportFormat,
57    /// Maximum depth to include; deeper nodes are excluded when set.
58    pub max_depth: Option<usize>,
59    /// Whether to include the rule ID in rendered labels (default: `true`).
60    pub include_rule_ids: bool,
61    /// Maximum label length in characters; longer labels are truncated (default: 40).
62    pub label_max_len: usize,
63}
64
65impl Default for ExportConfig {
66    fn default() -> Self {
67        Self {
68            format: ExportFormat::IndentedText,
69            max_depth: None,
70            include_rule_ids: true,
71            label_max_len: 40,
72        }
73    }
74}
75
76impl ExportConfig {
77    /// Create a new config with all defaults plus the given format.
78    pub fn new(format: ExportFormat) -> Self {
79        Self {
80            format,
81            ..Self::default()
82        }
83    }
84}
85
86// ---------------------------------------------------------------------------
87// Exporter
88// ---------------------------------------------------------------------------
89
90/// Exports verified proof trees to multiple formats.
91pub struct ProofTreeExporter {
92    /// Export configuration.
93    pub config: ExportConfig,
94}
95
96impl ProofTreeExporter {
97    /// Create a new exporter with the given configuration.
98    pub fn new(config: ExportConfig) -> Self {
99        Self { config }
100    }
101
102    // -----------------------------------------------------------------------
103    // Public API
104    // -----------------------------------------------------------------------
105
106    /// Export `nodes` to the string format described by `self.config`.
107    ///
108    /// Returns `"(empty)"` when `nodes` is empty.
109    pub fn export(&self, nodes: &[ExportNode]) -> String {
110        if nodes.is_empty() {
111            return "(empty)".to_string();
112        }
113
114        // Check whether any nodes survive the depth filter before dispatching.
115        let would_be_empty = match self.config.max_depth {
116            None => false,
117            Some(max) => !nodes.iter().any(|n| n.depth <= max),
118        };
119        if would_be_empty {
120            return "(empty)".to_string();
121        }
122
123        // The format methods each apply the depth filter internally.
124        match self.config.format {
125            ExportFormat::Dot => self.to_dot(nodes),
126            ExportFormat::Json => self.to_json(nodes),
127            ExportFormat::IndentedText => self.to_indented_text(nodes),
128            ExportFormat::EdgeList => self.to_edge_list(nodes),
129        }
130    }
131
132    /// Render nodes as Graphviz DOT.
133    pub fn to_dot(&self, nodes: &[ExportNode]) -> String {
134        let filtered_owned = self.owned_filtered(nodes);
135        let nodes: &[ExportNode] = &filtered_owned;
136
137        let mut out = String::from("digraph proof {\n");
138
139        for node in nodes {
140            let label = self.make_label(node);
141            // Escape quotes inside the label for DOT safety.
142            let escaped = label.replace('\\', "\\\\").replace('"', "\\\"");
143            out.push_str(&format!("    {} [label=\"{}\"];\n", node.node_id, escaped));
144        }
145
146        // Build a set of node ids present in the filtered slice so we only
147        // emit edges between present nodes.
148        let id_set: HashSet<u64> = nodes.iter().map(|n| n.node_id).collect();
149
150        for node in nodes {
151            for &child_id in &node.children {
152                if id_set.contains(&child_id) {
153                    out.push_str(&format!("    {} -> {};\n", node.node_id, child_id));
154                }
155            }
156        }
157
158        out.push('}');
159        out
160    }
161
162    /// Render nodes as a compact JSON array.
163    pub fn to_json(&self, nodes: &[ExportNode]) -> String {
164        let filtered_owned = self.owned_filtered(nodes);
165        // serde_json::to_string returns Result; fall back to a minimal
166        // representation if serialization fails (should never happen here).
167        serde_json::to_string(&filtered_owned).unwrap_or_else(|_| "[]".to_string())
168    }
169
170    /// Render nodes as an indented ASCII tree (DFS from root).
171    pub fn to_indented_text(&self, nodes: &[ExportNode]) -> String {
172        let filtered_owned = self.owned_filtered(nodes);
173        let nodes: &[ExportNode] = &filtered_owned;
174
175        if nodes.is_empty() {
176            return String::new();
177        }
178
179        let root_id = Self::find_root(nodes);
180        let id_map: HashMap<u64, &ExportNode> = nodes.iter().map(|n| (n.node_id, n)).collect();
181
182        let mut out = String::new();
183        let mut stack: Vec<u64> = vec![root_id];
184
185        while let Some(id) = stack.pop() {
186            if let Some(node) = id_map.get(&id) {
187                let indent = " ".repeat(2 * node.depth);
188                if self.config.include_rule_ids {
189                    out.push_str(&format!(
190                        "{}{} [rule: {}]\n",
191                        indent,
192                        self.truncate(&node.goal),
193                        node.rule_id
194                    ));
195                } else {
196                    out.push_str(&format!("{}{}\n", indent, self.truncate(&node.goal)));
197                }
198                // Push children in reverse order so they are popped in order.
199                for &child_id in node.children.iter().rev() {
200                    stack.push(child_id);
201                }
202            }
203        }
204
205        // Remove trailing newline for consistent output.
206        if out.ends_with('\n') {
207            out.pop();
208        }
209        out
210    }
211
212    /// Render nodes as a flat edge list (`parent_id -> child_id`).
213    pub fn to_edge_list(&self, nodes: &[ExportNode]) -> String {
214        let filtered_owned = self.owned_filtered(nodes);
215        let nodes: &[ExportNode] = &filtered_owned;
216
217        let id_set: HashSet<u64> = nodes.iter().map(|n| n.node_id).collect();
218
219        let mut lines: Vec<String> = Vec::new();
220        for node in nodes {
221            for &child_id in &node.children {
222                if id_set.contains(&child_id) {
223                    lines.push(format!("{} -> {}", node.node_id, child_id));
224                }
225            }
226        }
227
228        lines.join("\n")
229    }
230
231    /// Number of nodes that pass the `max_depth` filter.
232    pub fn node_count(&self, nodes: &[ExportNode]) -> usize {
233        self.owned_filtered(nodes).len()
234    }
235
236    /// Total number of child edges among nodes that pass the `max_depth` filter.
237    pub fn edge_count(&self, nodes: &[ExportNode]) -> usize {
238        let filtered = self.owned_filtered(nodes);
239        let id_set: HashSet<u64> = filtered.iter().map(|n| n.node_id).collect();
240        filtered
241            .iter()
242            .flat_map(|n| n.children.iter())
243            .filter(|&&child_id| id_set.contains(&child_id))
244            .count()
245    }
246
247    // -----------------------------------------------------------------------
248    // Helpers
249    // -----------------------------------------------------------------------
250
251    /// Return owned clones of nodes that pass the `max_depth` filter.
252    fn owned_filtered(&self, nodes: &[ExportNode]) -> Vec<ExportNode> {
253        match self.config.max_depth {
254            None => nodes.to_vec(),
255            Some(max) => nodes.iter().filter(|n| n.depth <= max).cloned().collect(),
256        }
257    }
258
259    /// Build the display label for a node.
260    fn make_label(&self, node: &ExportNode) -> String {
261        if self.config.include_rule_ids {
262            let combined = format!("{} ({})", node.goal, node.rule_id);
263            self.truncate(&combined)
264        } else {
265            self.truncate(&node.goal)
266        }
267    }
268
269    /// Truncate a string to at most `label_max_len` characters, appending `…`
270    /// when truncation occurs.
271    fn truncate(&self, s: &str) -> String {
272        let max = self.config.label_max_len;
273        if s.chars().count() <= max {
274            s.to_string()
275        } else {
276            let truncated: String = s.chars().take(max.saturating_sub(1)).collect();
277            format!("{}…", truncated)
278        }
279    }
280
281    /// Find the root node: the node whose `node_id` does not appear in any
282    /// other node's `children` list.  Falls back to `nodes[0]` when every
283    /// node appears as a child (cyclic / degenerate tree).
284    fn find_root(nodes: &[ExportNode]) -> u64 {
285        let all_children: HashSet<u64> = nodes
286            .iter()
287            .flat_map(|n| n.children.iter().copied())
288            .collect();
289
290        nodes
291            .iter()
292            .find(|n| !all_children.contains(&n.node_id))
293            .map(|n| n.node_id)
294            .unwrap_or(nodes[0].node_id)
295    }
296}
297
298// ---------------------------------------------------------------------------
299// Tests
300// ---------------------------------------------------------------------------
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    // ------------------------------------------------------------------
307    // Helpers for building test fixtures
308    // ------------------------------------------------------------------
309
310    fn leaf(id: u64, rule: &str, goal: &str, depth: usize) -> ExportNode {
311        ExportNode {
312            node_id: id,
313            rule_id: rule.to_string(),
314            goal: goal.to_string(),
315            depth,
316            children: vec![],
317        }
318    }
319
320    fn parent_node(
321        id: u64,
322        rule: &str,
323        goal: &str,
324        depth: usize,
325        children: Vec<u64>,
326    ) -> ExportNode {
327        ExportNode {
328            node_id: id,
329            rule_id: rule.to_string(),
330            goal: goal.to_string(),
331            depth,
332            children,
333        }
334    }
335
336    /// Single-node tree.
337    fn single() -> Vec<ExportNode> {
338        vec![leaf(1, "R1", "fact(a)", 0)]
339    }
340
341    /// Two-level tree: root → child.
342    fn two_level() -> Vec<ExportNode> {
343        vec![
344            parent_node(1, "R1", "root_goal", 0, vec![2]),
345            leaf(2, "R2", "child_goal", 1),
346        ]
347    }
348
349    /// Three-level tree: root → child → grandchild.
350    fn three_level() -> Vec<ExportNode> {
351        vec![
352            parent_node(1, "R1", "root_goal", 0, vec![2]),
353            parent_node(2, "R2", "child_goal", 1, vec![3]),
354            leaf(3, "R3", "grandchild_goal", 2),
355        ]
356    }
357
358    // ------------------------------------------------------------------
359    // 1. new() with config
360    // ------------------------------------------------------------------
361    #[test]
362    fn test_new_with_config() {
363        let cfg = ExportConfig::new(ExportFormat::Dot);
364        let exporter = ProofTreeExporter::new(cfg);
365        assert_eq!(exporter.config.format, ExportFormat::Dot);
366        assert!(exporter.config.include_rule_ids);
367        assert_eq!(exporter.config.label_max_len, 40);
368        assert!(exporter.config.max_depth.is_none());
369    }
370
371    // ------------------------------------------------------------------
372    // 2. export() returns "(empty)" for empty input
373    // ------------------------------------------------------------------
374    #[test]
375    fn test_export_empty_returns_empty_sentinel() {
376        let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::Dot));
377        assert_eq!(exporter.export(&[]), "(empty)");
378    }
379
380    // ------------------------------------------------------------------
381    // 3. to_dot: single node, no edges
382    // ------------------------------------------------------------------
383    #[test]
384    fn test_to_dot_single_node_no_edges() {
385        let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::Dot));
386        let nodes = single();
387        let dot = exporter.to_dot(&nodes);
388        assert!(dot.contains("digraph proof {"));
389        assert!(dot.contains("1 [label="));
390        // No edge lines
391        assert!(!dot.contains("->"));
392    }
393
394    // ------------------------------------------------------------------
395    // 4. to_dot: parent → child edge present
396    // ------------------------------------------------------------------
397    #[test]
398    fn test_to_dot_edge_present() {
399        let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::Dot));
400        let nodes = two_level();
401        let dot = exporter.to_dot(&nodes);
402        assert!(dot.contains("1 -> 2;"));
403    }
404
405    // ------------------------------------------------------------------
406    // 5. to_dot: label truncated at label_max_len
407    // ------------------------------------------------------------------
408    #[test]
409    fn test_to_dot_label_truncated() {
410        let mut cfg = ExportConfig::new(ExportFormat::Dot);
411        cfg.label_max_len = 10;
412        cfg.include_rule_ids = false;
413        let exporter = ProofTreeExporter::new(cfg);
414        let nodes = vec![leaf(1, "R1", "a_very_long_goal_that_exceeds_limit", 0)];
415        let dot = exporter.to_dot(&nodes);
416        // Label should be truncated (≤ 10 chars + ellipsis marker inside quotes)
417        // The raw goal is 35 chars; we expect truncation.
418        assert!(dot.contains('…') || dot.contains("a_very_lo"));
419        // Original full string should not appear
420        assert!(!dot.contains("a_very_long_goal_that_exceeds_limit"));
421    }
422
423    // ------------------------------------------------------------------
424    // 6. to_dot: rule_id omitted when include_rule_ids=false
425    // ------------------------------------------------------------------
426    #[test]
427    fn test_to_dot_no_rule_ids() {
428        let mut cfg = ExportConfig::new(ExportFormat::Dot);
429        cfg.include_rule_ids = false;
430        let exporter = ProofTreeExporter::new(cfg);
431        let nodes = single();
432        let dot = exporter.to_dot(&nodes);
433        assert!(!dot.contains("R1"));
434    }
435
436    // ------------------------------------------------------------------
437    // 7. to_json: round-trip via serde_json::from_str
438    // ------------------------------------------------------------------
439    #[test]
440    fn test_to_json_round_trip() {
441        let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::Json));
442        let nodes = two_level();
443        let json = exporter.to_json(&nodes);
444        let recovered: Vec<ExportNode> = serde_json::from_str(&json).expect("valid JSON");
445        assert_eq!(recovered.len(), 2);
446        assert_eq!(recovered[0].node_id, 1);
447        assert_eq!(recovered[1].node_id, 2);
448    }
449
450    // ------------------------------------------------------------------
451    // 8. to_json: node_id and goal present in output string
452    // ------------------------------------------------------------------
453    #[test]
454    fn test_to_json_contains_fields() {
455        let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::Json));
456        let nodes = single();
457        let json = exporter.to_json(&nodes);
458        assert!(json.contains("node_id"));
459        assert!(json.contains("goal"));
460        assert!(json.contains("fact(a)"));
461    }
462
463    // ------------------------------------------------------------------
464    // 9. to_indented_text: root at indent 0
465    // ------------------------------------------------------------------
466    #[test]
467    fn test_to_indented_text_root_no_indent() {
468        let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::IndentedText));
469        let nodes = two_level();
470        let text = exporter.to_indented_text(&nodes);
471        // First line must start with the root goal (no leading spaces)
472        let first_line = text.lines().next().expect("at least one line");
473        assert!(!first_line.starts_with(' '));
474        assert!(first_line.contains("root_goal"));
475    }
476
477    // ------------------------------------------------------------------
478    // 10. to_indented_text: child at indent 2
479    // ------------------------------------------------------------------
480    #[test]
481    fn test_to_indented_text_child_indent_2() {
482        let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::IndentedText));
483        let nodes = two_level();
484        let text = exporter.to_indented_text(&nodes);
485        let child_line = text
486            .lines()
487            .find(|l| l.contains("child_goal"))
488            .expect("child line present");
489        assert!(
490            child_line.starts_with("  "),
491            "expected 2-space indent, got: {:?}",
492            child_line
493        );
494    }
495
496    // ------------------------------------------------------------------
497    // 11. to_indented_text: grandchild at indent 4
498    // ------------------------------------------------------------------
499    #[test]
500    fn test_to_indented_text_grandchild_indent_4() {
501        let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::IndentedText));
502        let nodes = three_level();
503        let text = exporter.to_indented_text(&nodes);
504        let gc_line = text
505            .lines()
506            .find(|l| l.contains("grandchild_goal"))
507            .expect("grandchild line present");
508        assert!(
509            gc_line.starts_with("    "),
510            "expected 4-space indent, got: {:?}",
511            gc_line
512        );
513    }
514
515    // ------------------------------------------------------------------
516    // 12. to_edge_list: "X -> Y" format
517    // ------------------------------------------------------------------
518    #[test]
519    fn test_to_edge_list_format() {
520        let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::EdgeList));
521        let nodes = two_level();
522        let list = exporter.to_edge_list(&nodes);
523        assert!(list.contains("1 -> 2"), "output was: {}", list);
524    }
525
526    // ------------------------------------------------------------------
527    // 13. to_edge_list: empty for no-child nodes
528    // ------------------------------------------------------------------
529    #[test]
530    fn test_to_edge_list_empty_for_leaf_only() {
531        let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::EdgeList));
532        let nodes = single();
533        let list = exporter.to_edge_list(&nodes);
534        assert!(list.is_empty(), "expected empty edge list, got: {:?}", list);
535    }
536
537    // ------------------------------------------------------------------
538    // 14. max_depth filters out deep nodes
539    // ------------------------------------------------------------------
540    #[test]
541    fn test_max_depth_filters_deep_nodes() {
542        let mut cfg = ExportConfig::new(ExportFormat::IndentedText);
543        cfg.max_depth = Some(1);
544        let exporter = ProofTreeExporter::new(cfg);
545        let nodes = three_level(); // depths 0, 1, 2
546        let text = exporter.to_indented_text(&nodes);
547        assert!(
548            !text.contains("grandchild_goal"),
549            "depth-2 node should be filtered"
550        );
551        assert!(text.contains("child_goal"), "depth-1 node should remain");
552    }
553
554    // ------------------------------------------------------------------
555    // 15. node_count respects max_depth
556    // ------------------------------------------------------------------
557    #[test]
558    fn test_node_count_with_max_depth() {
559        let mut cfg = ExportConfig::new(ExportFormat::Dot);
560        cfg.max_depth = Some(1);
561        let exporter = ProofTreeExporter::new(cfg);
562        let nodes = three_level(); // 3 nodes at depths 0, 1, 2
563        assert_eq!(exporter.node_count(&nodes), 2); // only depths 0 and 1
564    }
565
566    // ------------------------------------------------------------------
567    // 16. edge_count correct
568    // ------------------------------------------------------------------
569    #[test]
570    fn test_edge_count_correct() {
571        let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::Dot));
572        let nodes = three_level(); // 2 edges: 1→2 and 2→3
573        assert_eq!(exporter.edge_count(&nodes), 2);
574    }
575
576    // ------------------------------------------------------------------
577    // Bonus: edge_count with max_depth cuts the deeper edge
578    // ------------------------------------------------------------------
579    #[test]
580    fn test_edge_count_with_max_depth() {
581        let mut cfg = ExportConfig::new(ExportFormat::Dot);
582        cfg.max_depth = Some(1);
583        let exporter = ProofTreeExporter::new(cfg);
584        let nodes = three_level();
585        // Node 3 (depth 2) filtered out → only 1→2 edge remains
586        assert_eq!(exporter.edge_count(&nodes), 1);
587    }
588
589    // ------------------------------------------------------------------
590    // Bonus: export dispatches to edge_list format correctly
591    // ------------------------------------------------------------------
592    #[test]
593    fn test_export_dispatches_edge_list() {
594        let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::EdgeList));
595        let result = exporter.export(&two_level());
596        assert!(result.contains("->"));
597    }
598
599    // ------------------------------------------------------------------
600    // Bonus: export dispatches to json format correctly
601    // ------------------------------------------------------------------
602    #[test]
603    fn test_export_dispatches_json() {
604        let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::Json));
605        let result = exporter.export(&single());
606        assert!(result.starts_with('['));
607    }
608}