1use crate::Error;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct AifNode {
18 #[serde(rename = "nodeID")]
20 pub node_id: String,
21 pub text: String,
25 #[serde(rename = "type")]
27 pub node_type: String,
28 #[serde(skip_serializing_if = "Option::is_none", default)]
30 pub scheme: Option<String>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35pub struct AifEdge {
36 #[serde(rename = "edgeID")]
38 pub edge_id: String,
39 #[serde(rename = "fromID")]
41 pub from_id: String,
42 #[serde(rename = "toID")]
44 pub to_id: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
50pub struct AifDocument {
51 pub nodes: Vec<AifNode>,
53 pub edges: Vec<AifEdge>,
55 #[serde(default)]
57 pub locutions: Vec<serde_json::Value>,
58 #[serde(default)]
60 pub participants: Vec<serde_json::Value>,
61}
62
63impl AifDocument {
64 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 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
75pub 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 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 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 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 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 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 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
170pub 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 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 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 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
292fn 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 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, ®istry).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 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, ®istry).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, ®istry).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, ®istry).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, ®istry).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}