Skip to main content

argumentation_schemes/
aif.rs

1//! AIF (Argument Interchange Format) — AIFdb JSON serialization.
2//!
3//! Supports round-tripping a [`crate::SchemeInstance`] through the
4//! community-standard AIFdb JSON format. See the crate README for the
5//! exact mapping between our types and AIF nodes/edges.
6
7use crate::Error;
8use serde::{Deserialize, Serialize};
9
10/// A single AIF node. The `type` field discriminates:
11///
12/// - `"I"` — information / claim (premise or conclusion literal).
13/// - `"RA"` — rule application (scheme instance).
14/// - `"CA"` — conflict / attack (critical question).
15/// - `"MA"` — mutual attack / preference (unused in v0.2.0).
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct AifNode {
18    /// Node identifier — unique within the document.
19    #[serde(rename = "nodeID")]
20    pub node_id: String,
21    /// Human-readable text. For I-nodes this is `literal.to_string()`;
22    /// for RA-nodes the scheme's canonical name; for CA-nodes the
23    /// instantiated critical-question text.
24    pub text: String,
25    /// Node type: "I" | "RA" | "CA" | "MA".
26    #[serde(rename = "type")]
27    pub node_type: String,
28    /// Scheme name — present on RA-nodes, absent (None → omitted) on others.
29    #[serde(skip_serializing_if = "Option::is_none", default)]
30    pub scheme: Option<String>,
31}
32
33/// A directed edge between two AIF nodes.
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35pub struct AifEdge {
36    /// Edge identifier — unique within the document.
37    #[serde(rename = "edgeID")]
38    pub edge_id: String,
39    /// Source node id.
40    #[serde(rename = "fromID")]
41    pub from_id: String,
42    /// Target node id.
43    #[serde(rename = "toID")]
44    pub to_id: String,
45}
46
47/// A full AIF document: nodes, edges, and two fields we emit as empty
48/// arrays for round-trip fidelity with AIFdb output.
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
50pub struct AifDocument {
51    /// The AIF node list.
52    pub nodes: Vec<AifNode>,
53    /// The AIF edge list.
54    pub edges: Vec<AifEdge>,
55    /// Dialogue locutions — emitted as empty, ignored on import.
56    #[serde(default)]
57    pub locutions: Vec<serde_json::Value>,
58    /// Dialogue participants — emitted as empty, ignored on import.
59    #[serde(default)]
60    pub participants: Vec<serde_json::Value>,
61}
62
63impl AifDocument {
64    /// Parse an AIF JSON string.
65    pub fn from_json(json: &str) -> Result<Self, Error> {
66        serde_json::from_str(json).map_err(|e| Error::AifParse(e.to_string()))
67    }
68
69    /// Serialize to a pretty-printed AIF JSON string.
70    pub fn to_json(&self) -> Result<String, Error> {
71        serde_json::to_string_pretty(self).map_err(|e| Error::AifParse(e.to_string()))
72    }
73}
74
75/// Export a [`crate::instance::SchemeInstance`] to an AIF document.
76///
77/// Mapping:
78/// - each premise literal → one I-node
79/// - the conclusion literal → one I-node
80/// - the scheme instance → one RA-node whose `scheme` field names the scheme
81/// - each critical question → one CA-node
82///
83/// Edges connect each premise I-node to the RA-node, the RA-node to
84/// the conclusion I-node, and each CA-node to the RA-node.
85///
86/// Node IDs are assigned as stringified sequential integers starting
87/// at 1 in a deterministic order (premises → conclusion → RA → CAs).
88pub fn instance_to_aif(instance: &crate::instance::SchemeInstance) -> AifDocument {
89    let mut nodes = Vec::new();
90    let mut edges = Vec::new();
91    let mut next_id = 1usize;
92
93    // Premises as I-nodes.
94    let premise_ids: Vec<String> = instance
95        .premises
96        .iter()
97        .map(|p| {
98            let id = next_id.to_string();
99            nodes.push(AifNode {
100                node_id: id.clone(),
101                text: p.to_string(),
102                node_type: "I".into(),
103                scheme: None,
104            });
105            next_id += 1;
106            id
107        })
108        .collect();
109
110    // Conclusion as I-node.
111    let conclusion_id = next_id.to_string();
112    nodes.push(AifNode {
113        node_id: conclusion_id.clone(),
114        text: instance.conclusion.to_string(),
115        node_type: "I".into(),
116        scheme: None,
117    });
118    next_id += 1;
119
120    // RA-node for the scheme instance.
121    let ra_id = next_id.to_string();
122    nodes.push(AifNode {
123        node_id: ra_id.clone(),
124        text: instance.scheme_name.clone(),
125        node_type: "RA".into(),
126        scheme: Some(instance.scheme_name.clone()),
127    });
128    next_id += 1;
129
130    // Edges: each premise → RA.
131    for pid in &premise_ids {
132        edges.push(AifEdge {
133            edge_id: edges.len().to_string(),
134            from_id: pid.clone(),
135            to_id: ra_id.clone(),
136        });
137    }
138    // RA → conclusion.
139    edges.push(AifEdge {
140        edge_id: edges.len().to_string(),
141        from_id: ra_id.clone(),
142        to_id: conclusion_id.clone(),
143    });
144
145    // CA-nodes for critical questions; each points at the RA.
146    for cq in &instance.critical_questions {
147        let ca_id = next_id.to_string();
148        nodes.push(AifNode {
149            node_id: ca_id.clone(),
150            text: cq.text.clone(),
151            node_type: "CA".into(),
152            scheme: None,
153        });
154        next_id += 1;
155        edges.push(AifEdge {
156            edge_id: edges.len().to_string(),
157            from_id: ca_id,
158            to_id: ra_id.clone(),
159        });
160    }
161
162    AifDocument {
163        nodes,
164        edges,
165        locutions: vec![],
166        participants: vec![],
167    }
168}
169
170/// Import an AIF document back into a [`crate::instance::SchemeInstance`].
171///
172/// Looks up the scheme by name in the provided [`crate::registry::CatalogRegistry`]
173/// and re-parses each I-node's text as a [`argumentation::aspic::Literal`] (leading `¬`
174/// marks negation). Premises come from I-nodes pointing at the
175/// RA-node; the conclusion comes from the I-node the RA-node points
176/// at; CA-nodes pointing at the RA contribute critical-question text.
177///
178/// **Not preserved through AIF.** Critical-question [`crate::types::Challenge`] tags
179/// and `counter_literal` values are not part of the AIF format. On
180/// import, `Challenge` is re-derived by positional matching against
181/// the catalog's scheme definition (with [`crate::types::Challenge::RuleValidity`]
182/// as fallback if the catalog has fewer CQs than the document); the
183/// `counter_literal` is a synthetic placeholder `¬aif_cq_<idx>`.
184/// Callers who need a faithful `counter_literal` should drop the
185/// import route and re-instantiate the scheme from the catalog with
186/// the original bindings.
187///
188/// Expects exactly one RA-node per document. Documents with multiple
189/// RA-nodes represent conjoined arguments and are rejected with
190/// [`Error::AifParse`] — this is not a silent truncation.
191pub fn aif_to_instance(
192    doc: &AifDocument,
193    registry: &crate::registry::CatalogRegistry,
194) -> Result<crate::instance::SchemeInstance, Error> {
195    let ra_nodes: Vec<&AifNode> =
196        doc.nodes.iter().filter(|n| n.node_type == "RA").collect();
197    let ra = match ra_nodes.as_slice() {
198        [] => return Err(Error::AifParse("no RA-node in document".into())),
199        [single] => *single,
200        _ => {
201            return Err(Error::AifParse(format!(
202                "multiple RA-nodes not supported in v0.2.0 (found {})",
203                ra_nodes.len()
204            )));
205        }
206    };
207    let scheme_name = ra
208        .scheme
209        .as_ref()
210        .ok_or_else(|| Error::AifParse("RA-node missing 'scheme' field".into()))?;
211
212    let scheme = registry
213        .by_name(scheme_name)
214        .ok_or_else(|| Error::AifUnknownScheme(scheme_name.clone()))?;
215
216    // Find edges: premise I-nodes point at RA; RA points at conclusion
217    // I-node; CA-nodes point at RA.
218    let in_edges: Vec<&AifEdge> = doc.edges.iter().filter(|e| e.to_id == ra.node_id).collect();
219    let out_edges: Vec<&AifEdge> =
220        doc.edges.iter().filter(|e| e.from_id == ra.node_id).collect();
221
222    let conclusion_id = match out_edges.as_slice() {
223        [] => return Err(Error::AifParse("RA has no outgoing edge to conclusion".into())),
224        [single] => single.to_id.clone(),
225        multiple => {
226            return Err(Error::AifParse(format!(
227                "RA has multiple outgoing edges ({}); AIFdb convention expects exactly one to the conclusion I-node",
228                multiple.len()
229            )));
230        }
231    };
232
233    let conclusion_node = doc
234        .nodes
235        .iter()
236        .find(|n| n.node_id == conclusion_id && n.node_type == "I")
237        .ok_or_else(|| {
238            Error::AifParse(format!("conclusion node '{}' not found", conclusion_id))
239        })?;
240    let conclusion = literal_from_text(&conclusion_node.text);
241
242    // Partition incoming edges: premises (I-nodes) vs. critical
243    // questions (CA-nodes).
244    let mut premises = Vec::new();
245    let mut cq_texts = Vec::new();
246    for edge in in_edges {
247        let src = doc
248            .nodes
249            .iter()
250            .find(|n| n.node_id == edge.from_id)
251            .ok_or_else(|| {
252                Error::AifParse(format!("edge references unknown node '{}'", edge.from_id))
253            })?;
254        match src.node_type.as_str() {
255            "I" => premises.push(literal_from_text(&src.text)),
256            "CA" => cq_texts.push(src.text.clone()),
257            other => {
258                return Err(Error::AifParse(format!(
259                    "unexpected incoming node type '{}' on RA-node",
260                    other
261                )));
262            }
263        }
264    }
265
266    // Re-derive CriticalQuestionInstance list. AIF doesn't carry the
267    // Challenge or counter_literal; re-instantiate by number-matching
268    // from the catalog scheme, using the text as a tiebreaker.
269    let critical_questions: Vec<crate::instance::CriticalQuestionInstance> = cq_texts
270        .iter()
271        .enumerate()
272        .map(|(idx, text)| crate::instance::CriticalQuestionInstance {
273            number: (idx + 1) as u32,
274            text: text.clone(),
275            challenge: scheme
276                .critical_questions
277                .get(idx)
278                .map(|cq| cq.challenge.clone())
279                .unwrap_or(crate::types::Challenge::RuleValidity),
280            counter_literal: argumentation::aspic::Literal::neg(format!("aif_cq_{}", idx)),
281        })
282        .collect();
283
284    Ok(crate::instance::SchemeInstance {
285        scheme_name: scheme_name.clone(),
286        premises,
287        conclusion,
288        critical_questions,
289    })
290}
291
292/// Parse a Literal from its `to_string()` rendering. Our `Literal::neg`
293/// renders with a leading `¬` (U+00AC); `Literal::atom` renders plain.
294///
295/// **Known limitation.** An atom whose name begins with `¬` (U+00AC) —
296/// e.g. `Literal::atom("¬foo")` — will be misclassified on import as
297/// `Literal::neg("foo")`. This is a fundamental round-trip ambiguity
298/// of the textual representation; no scheme in the default Walton
299/// catalog produces such atoms, but consumers who construct literals
300/// with free-text names should avoid leading `¬`. Similarly, the
301/// function `.trim()`s the remainder, so whitespace around atom names
302/// is not preserved.
303fn literal_from_text(text: &str) -> argumentation::aspic::Literal {
304    if let Some(stripped) = text.strip_prefix('¬') {
305        argumentation::aspic::Literal::neg(stripped.trim())
306    } else {
307        argumentation::aspic::Literal::atom(text.trim())
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn aif_node_round_trip_preserves_scheme_field() {
317        let n = AifNode {
318            node_id: "3".into(),
319            text: "Argument from Expert Opinion".into(),
320            node_type: "RA".into(),
321            scheme: Some("Argument from Expert Opinion".into()),
322        };
323        let json = serde_json::to_string(&n).unwrap();
324        let parsed: AifNode = serde_json::from_str(&json).unwrap();
325        assert_eq!(parsed, n);
326    }
327
328    #[test]
329    fn aif_node_without_scheme_omits_field() {
330        let n = AifNode {
331            node_id: "1".into(),
332            text: "alice is an expert".into(),
333            node_type: "I".into(),
334            scheme: None,
335        };
336        let json = serde_json::to_string(&n).unwrap();
337        assert!(!json.contains("\"scheme\""));
338    }
339
340    #[test]
341    fn aif_document_from_json_and_to_json_round_trip() {
342        let doc = AifDocument {
343            nodes: vec![AifNode {
344                node_id: "1".into(),
345                text: "claim".into(),
346                node_type: "I".into(),
347                scheme: None,
348            }],
349            edges: vec![],
350            locutions: vec![],
351            participants: vec![],
352        };
353        let json = doc.to_json().unwrap();
354        let parsed = AifDocument::from_json(&json).unwrap();
355        assert_eq!(parsed, doc);
356    }
357
358    #[test]
359    fn instance_to_aif_produces_premises_ra_conclusion_and_cas() {
360        use crate::catalog::default_catalog;
361        use std::collections::HashMap;
362
363        let catalog = default_catalog();
364        let scheme = catalog.by_key("argument_from_expert_opinion").unwrap();
365        let bindings: HashMap<String, String> = [
366            ("expert".to_string(), "alice".to_string()),
367            ("domain".to_string(), "military".to_string()),
368            ("claim".to_string(), "fortify_east".to_string()),
369        ]
370        .into_iter()
371        .collect();
372        let instance = scheme.instantiate(&bindings).unwrap();
373
374        let aif = instance_to_aif(&instance);
375
376        let i_count = aif.nodes.iter().filter(|n| n.node_type == "I").count();
377        let ra_count = aif.nodes.iter().filter(|n| n.node_type == "RA").count();
378        let ca_count = aif.nodes.iter().filter(|n| n.node_type == "CA").count();
379
380        assert_eq!(i_count, instance.premises.len() + 1, "one I per premise + one for conclusion");
381        assert_eq!(ra_count, 1, "exactly one RA for the scheme instance");
382        assert_eq!(ca_count, instance.critical_questions.len());
383
384        // Edges: premises→RA (N), RA→conclusion (1), CAs→RA (M).
385        let expected_edges =
386            instance.premises.len() + 1 + instance.critical_questions.len();
387        assert_eq!(aif.edges.len(), expected_edges);
388    }
389
390    #[test]
391    fn instance_to_aif_tags_ra_node_with_scheme_name() {
392        use crate::catalog::default_catalog;
393        use std::collections::HashMap;
394        let catalog = default_catalog();
395        let scheme = catalog.by_key("argument_from_expert_opinion").unwrap();
396        let bindings: HashMap<String, String> = [
397            ("expert".to_string(), "alice".to_string()),
398            ("domain".to_string(), "military".to_string()),
399            ("claim".to_string(), "fortify_east".to_string()),
400        ]
401        .into_iter()
402        .collect();
403        let instance = scheme.instantiate(&bindings).unwrap();
404
405        let aif = instance_to_aif(&instance);
406        let ra = aif.nodes.iter().find(|n| n.node_type == "RA").unwrap();
407        assert_eq!(ra.scheme.as_deref(), Some(instance.scheme_name.as_str()));
408    }
409
410    #[test]
411    fn aif_round_trip_preserves_instance_shape() {
412        use crate::catalog::default_catalog;
413        use crate::registry::CatalogRegistry;
414        use std::collections::HashMap;
415
416        let catalog = default_catalog();
417        let scheme = catalog.by_key("argument_from_expert_opinion").unwrap();
418        let bindings: HashMap<String, String> = [
419            ("expert".to_string(), "alice".to_string()),
420            ("domain".to_string(), "military".to_string()),
421            ("claim".to_string(), "fortify_east".to_string()),
422        ]
423        .into_iter()
424        .collect();
425        let original = scheme.instantiate(&bindings).unwrap();
426
427        let aif = instance_to_aif(&original);
428        let registry = CatalogRegistry::with_walton_catalog();
429        let recovered = aif_to_instance(&aif, &registry).unwrap();
430
431        assert_eq!(recovered.scheme_name, original.scheme_name);
432        assert_eq!(recovered.premises, original.premises);
433        assert_eq!(recovered.conclusion, original.conclusion);
434        assert_eq!(
435            recovered.critical_questions.len(),
436            original.critical_questions.len()
437        );
438        for (r, o) in recovered
439            .critical_questions
440            .iter()
441            .zip(original.critical_questions.iter())
442        {
443            assert_eq!(r.text, o.text);
444        }
445
446        // AIF does NOT preserve counter_literal values (not part of the
447        // format). Import writes a synthetic placeholder. Pin this
448        // non-preservation so a future change that implements proper
449        // preservation can't silently regress the docstring contract.
450        assert_ne!(
451            recovered.critical_questions[0].counter_literal,
452            original.critical_questions[0].counter_literal,
453            "counter_literal is expected to NOT round-trip through AIF",
454        );
455    }
456
457    #[test]
458    fn aif_to_instance_errors_on_unknown_scheme() {
459        use crate::registry::CatalogRegistry;
460        let doc = AifDocument {
461            nodes: vec![
462                AifNode {
463                    node_id: "1".into(),
464                    text: "some claim".into(),
465                    node_type: "I".into(),
466                    scheme: None,
467                },
468                AifNode {
469                    node_id: "2".into(),
470                    text: "Argument from Flapdoodle".into(),
471                    node_type: "RA".into(),
472                    scheme: Some("Argument from Flapdoodle".into()),
473                },
474            ],
475            edges: vec![AifEdge {
476                edge_id: "1".into(),
477                from_id: "2".into(),
478                to_id: "1".into(),
479            }],
480            locutions: vec![],
481            participants: vec![],
482        };
483        let registry = CatalogRegistry::with_walton_catalog();
484        let err = aif_to_instance(&doc, &registry).unwrap_err();
485        assert!(matches!(err, Error::AifUnknownScheme(_)));
486    }
487
488    #[test]
489    fn aif_to_instance_errors_on_missing_ra() {
490        use crate::registry::CatalogRegistry;
491        let doc = AifDocument {
492            nodes: vec![AifNode {
493                node_id: "1".into(),
494                text: "claim".into(),
495                node_type: "I".into(),
496                scheme: None,
497            }],
498            edges: vec![],
499            locutions: vec![],
500            participants: vec![],
501        };
502        let registry = CatalogRegistry::with_walton_catalog();
503        let err = aif_to_instance(&doc, &registry).unwrap_err();
504        assert!(matches!(err, Error::AifParse(_)));
505    }
506
507    #[test]
508    fn aif_to_instance_errors_on_multiple_ra_out_edges() {
509        use crate::registry::CatalogRegistry;
510        let doc = AifDocument {
511            nodes: vec![
512                AifNode {
513                    node_id: "1".into(),
514                    text: "expert_alice".into(),
515                    node_type: "I".into(),
516                    scheme: None,
517                },
518                AifNode {
519                    node_id: "2".into(),
520                    text: "conclusion_a".into(),
521                    node_type: "I".into(),
522                    scheme: None,
523                },
524                AifNode {
525                    node_id: "3".into(),
526                    text: "conclusion_b".into(),
527                    node_type: "I".into(),
528                    scheme: None,
529                },
530                AifNode {
531                    node_id: "4".into(),
532                    text: "Argument from Expert Opinion".into(),
533                    node_type: "RA".into(),
534                    scheme: Some("Argument from Expert Opinion".into()),
535                },
536            ],
537            edges: vec![
538                AifEdge { edge_id: "0".into(), from_id: "1".into(), to_id: "4".into() },
539                AifEdge { edge_id: "1".into(), from_id: "4".into(), to_id: "2".into() },
540                AifEdge { edge_id: "2".into(), from_id: "4".into(), to_id: "3".into() },
541            ],
542            locutions: vec![],
543            participants: vec![],
544        };
545        let registry = CatalogRegistry::with_walton_catalog();
546        let err = aif_to_instance(&doc, &registry).unwrap_err();
547        match err {
548            Error::AifParse(msg) => assert!(msg.contains("multiple outgoing edges")),
549            other => panic!("expected AifParse, got {:?}", other),
550        }
551    }
552
553    #[test]
554    fn aif_to_instance_errors_on_multiple_ra_nodes() {
555        use crate::registry::CatalogRegistry;
556        let doc = AifDocument {
557            nodes: vec![
558                AifNode {
559                    node_id: "1".into(),
560                    text: "claim".into(),
561                    node_type: "I".into(),
562                    scheme: None,
563                },
564                AifNode {
565                    node_id: "2".into(),
566                    text: "Argument from Expert Opinion".into(),
567                    node_type: "RA".into(),
568                    scheme: Some("Argument from Expert Opinion".into()),
569                },
570                AifNode {
571                    node_id: "3".into(),
572                    text: "Argument from Expert Opinion".into(),
573                    node_type: "RA".into(),
574                    scheme: Some("Argument from Expert Opinion".into()),
575                },
576            ],
577            edges: vec![],
578            locutions: vec![],
579            participants: vec![],
580        };
581        let registry = CatalogRegistry::with_walton_catalog();
582        let err = aif_to_instance(&doc, &registry).unwrap_err();
583        match err {
584            Error::AifParse(msg) => assert!(msg.contains("multiple RA-nodes")),
585            other => panic!("expected AifParse, got {:?}", other),
586        }
587    }
588}