1use crate::Error;
4use crate::critical::CriticalQuestion;
5use crate::scheme::SchemeSpec;
6use argumentation::aspic::Literal;
7use std::collections::HashMap;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct CriticalQuestionInstance {
12 pub number: u32,
14 pub text: String,
16 pub challenge: crate::types::Challenge,
18 pub counter_literal: Literal,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct SchemeInstance {
26 pub scheme_name: String,
28 pub premises: Vec<Literal>,
30 pub conclusion: Literal,
32 pub critical_questions: Vec<CriticalQuestionInstance>,
34}
35
36fn resolve_template(template: &str, bindings: &HashMap<String, String>) -> String {
52 let mut sorted: Vec<(&String, &String)> = bindings.iter().collect();
53 sorted.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
54 let mut result = template.to_string();
55 for (key, val) in sorted {
56 result = result.replace(&format!("?{}", key), val);
57 }
58 result
59}
60
61pub fn instantiate(
69 scheme: &SchemeSpec,
70 bindings: &HashMap<String, String>,
71) -> Result<SchemeInstance, Error> {
72 for slot in &scheme.premises {
75 if !bindings.contains_key(&slot.name) {
76 return Err(Error::MissingBinding {
77 scheme: scheme.name.clone(),
78 slot: slot.name.clone(),
79 });
80 }
81 }
82
83 let premises: Vec<Literal> = scheme
87 .premises
88 .iter()
89 .map(|slot| {
90 let val = &bindings[&slot.name];
91 Literal::atom(format!("{}_{}", slot.name, val))
92 })
93 .collect();
94
95 let conclusion_name = resolve_template(&scheme.conclusion.literal_template, bindings);
97 let conclusion = if scheme.conclusion.is_negated {
98 Literal::neg(&conclusion_name)
99 } else {
100 Literal::atom(&conclusion_name)
101 };
102
103 let critical_questions = scheme
105 .critical_questions
106 .iter()
107 .map(|cq| {
108 let text = resolve_template(&cq.text, bindings);
109 let counter_literal = build_counter_literal(cq, bindings, scheme, &conclusion_name);
110 CriticalQuestionInstance {
111 number: cq.number,
112 text,
113 challenge: cq.challenge.clone(),
114 counter_literal,
115 }
116 })
117 .collect();
118
119 Ok(SchemeInstance {
120 scheme_name: scheme.name.clone(),
121 premises,
122 conclusion,
123 critical_questions,
124 })
125}
126
127fn build_counter_literal(
135 cq: &CriticalQuestion,
136 bindings: &HashMap<String, String>,
137 scheme: &SchemeSpec,
138 conclusion_name: &str,
139) -> Literal {
140 use crate::types::Challenge;
141 match &cq.challenge {
142 Challenge::PremiseTruth(slot_name) => {
143 let val = bindings
144 .get(slot_name)
145 .map(|s| s.as_str())
146 .unwrap_or("unknown");
147 Literal::neg(format!("{}_{}", slot_name, val))
148 }
149 Challenge::SourceCredibility => {
150 let agent = bindings
154 .get("expert")
155 .or_else(|| bindings.get("witness"))
156 .or_else(|| bindings.get("source"))
157 .or_else(|| bindings.get("agent"))
158 .or_else(|| bindings.get("person"))
159 .or_else(|| bindings.get("target"))
160 .or_else(|| bindings.get("threatener"))
161 .map(|s| s.as_str())
162 .unwrap_or("source");
163 Literal::neg(format!("credible_{}", agent))
164 }
165 Challenge::RuleValidity => Literal::neg(format!("valid_rule_{}", scheme.key())),
166 Challenge::ConflictingAuthority => {
167 Literal::neg(format!("consensus_on_{}", conclusion_name))
168 }
169 Challenge::AlternativeCause => Literal::neg(format!("sole_cause_{}", conclusion_name)),
170 Challenge::UnseenConsequences => {
171 Literal::neg(format!("all_consequences_considered_{}", conclusion_name))
172 }
173 Challenge::Proportionality => {
174 let target = bindings
175 .get("target")
176 .or_else(|| bindings.get("threatener"))
177 .or_else(|| bindings.get("agent"))
178 .or_else(|| bindings.get("person"))
179 .map(|s| s.as_str())
180 .unwrap_or("target");
181 Literal::neg(format!("proportionate_attack_{}", target))
182 }
183 Challenge::DisanalogyClaim => Literal::neg(format!("analogy_holds_{}", conclusion_name)),
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::critical::CriticalQuestion;
191 use crate::scheme::{ConclusionTemplate, PremiseSlot, SchemeMetadata, SchemeSpec};
192 use crate::types::*;
193
194 fn expert_opinion_scheme() -> SchemeSpec {
195 SchemeSpec {
196 id: SchemeId(1),
197 name: "Argument from Expert Opinion".into(),
198 category: SchemeCategory::Epistemic,
199 premises: vec![
200 PremiseSlot::new("expert", "The claimed expert", SlotRole::Agent),
201 PremiseSlot::new("domain", "Field of expertise", SlotRole::Domain),
202 PremiseSlot::new("claim", "The asserted proposition", SlotRole::Proposition),
203 ],
204 conclusion: ConclusionTemplate::positive("?claim is plausibly true", "?claim"),
205 critical_questions: vec![
206 CriticalQuestion::new(
207 1,
208 "Is ?expert an expert in ?domain?",
209 Challenge::PremiseTruth("expert".into()),
210 ),
211 CriticalQuestion::new(2, "Is ?expert credible?", Challenge::SourceCredibility),
212 ],
213 metadata: SchemeMetadata {
214 citation: "Walton 2008 p.14".into(),
215 domain_tags: vec!["epistemic".into()],
216 presumptive: true,
217 strength: SchemeStrength::Moderate,
218 },
219 }
220 }
221
222 fn full_bindings() -> HashMap<String, String> {
223 [
224 ("expert".to_string(), "alice".to_string()),
225 ("domain".to_string(), "military".to_string()),
226 ("claim".to_string(), "fortify_east".to_string()),
227 ]
228 .into_iter()
229 .collect()
230 }
231
232 #[test]
233 fn instantiate_produces_three_premises_and_positive_conclusion() {
234 let scheme = expert_opinion_scheme();
235 let instance = instantiate(&scheme, &full_bindings()).unwrap();
236 assert_eq!(instance.premises.len(), 3);
237 assert_eq!(instance.conclusion, Literal::atom("fortify_east"));
238 }
239
240 #[test]
241 fn instantiate_resolves_critical_question_text() {
242 let scheme = expert_opinion_scheme();
243 let instance = instantiate(&scheme, &full_bindings()).unwrap();
244 assert!(instance.critical_questions[0].text.contains("alice"));
245 assert!(instance.critical_questions[0].text.contains("military"));
246 }
247
248 #[test]
249 fn instantiate_fails_on_missing_binding() {
250 let scheme = expert_opinion_scheme();
251 let mut bindings = full_bindings();
252 bindings.remove("domain");
253 let err = instantiate(&scheme, &bindings).unwrap_err();
254 match err {
255 Error::MissingBinding { slot, .. } => assert_eq!(slot, "domain"),
256 other => panic!("expected MissingBinding, got {:?}", other),
257 }
258 }
259
260 #[test]
261 fn counter_literals_for_premise_truth_match_premise_encoding() {
262 let scheme = expert_opinion_scheme();
263 let instance = instantiate(&scheme, &full_bindings()).unwrap();
264 let cq1 = &instance.critical_questions[0];
267 assert_eq!(cq1.counter_literal, Literal::neg("expert_alice"));
268 assert!(cq1.counter_literal.is_contrary_of(&instance.premises[0]));
269 }
270
271 #[test]
272 fn counter_literal_for_source_credibility_uses_agent_binding() {
273 let scheme = expert_opinion_scheme();
274 let instance = instantiate(&scheme, &full_bindings()).unwrap();
275 let cq2 = &instance.critical_questions[1];
276 assert_eq!(cq2.counter_literal, Literal::neg("credible_alice"));
277 }
278
279 #[test]
280 fn source_credibility_falls_back_to_agent_slot_when_no_epistemic_role_bound() {
281 let scheme = SchemeSpec {
288 id: SchemeId(9999),
289 name: "synthetic agent credibility".into(),
290 category: SchemeCategory::Practical,
291 premises: vec![
292 PremiseSlot::new("agent", "the actor", SlotRole::Agent),
293 PremiseSlot::new("action", "what they did", SlotRole::Action),
294 ],
295 conclusion: ConclusionTemplate::positive("?agent did ?action", "did_?action"),
296 critical_questions: vec![CriticalQuestion::new(
297 1,
298 "Is ?agent credible?",
299 Challenge::SourceCredibility,
300 )],
301 metadata: SchemeMetadata {
302 citation: "synthetic".into(),
303 domain_tags: vec![],
304 presumptive: true,
305 strength: SchemeStrength::Moderate,
306 },
307 };
308 let bindings: HashMap<String, String> = [
309 ("agent".to_string(), "alice".to_string()),
310 ("action".to_string(), "sign_treaty".to_string()),
311 ]
312 .into_iter()
313 .collect();
314 let instance = instantiate(&scheme, &bindings).unwrap();
315 assert_eq!(
316 instance.critical_questions[0].counter_literal,
317 Literal::neg("credible_alice"),
318 );
319 }
320
321 #[test]
322 fn source_credibility_prefers_agent_over_target_when_both_are_bound() {
323 let scheme = SchemeSpec {
329 id: SchemeId(9998),
330 name: "synthetic agent over target".into(),
331 category: SchemeCategory::Practical,
332 premises: vec![
333 PremiseSlot::new("agent", "the actor", SlotRole::Agent),
334 PremiseSlot::new("target", "the recipient", SlotRole::Agent),
335 ],
336 conclusion: ConclusionTemplate::positive("outcome", "outcome"),
337 critical_questions: vec![CriticalQuestion::new(
338 1,
339 "Is ?agent credible?",
340 Challenge::SourceCredibility,
341 )],
342 metadata: SchemeMetadata {
343 citation: "synthetic".into(),
344 domain_tags: vec![],
345 presumptive: true,
346 strength: SchemeStrength::Moderate,
347 },
348 };
349 let bindings: HashMap<String, String> = [
350 ("agent".to_string(), "alice".to_string()),
351 ("target".to_string(), "bob".to_string()),
352 ]
353 .into_iter()
354 .collect();
355 let instance = instantiate(&scheme, &bindings).unwrap();
356 assert_eq!(
357 instance.critical_questions[0].counter_literal,
358 Literal::neg("credible_alice"),
359 "agent should win over target in the SourceCredibility chain"
360 );
361 }
362
363 #[test]
364 fn proportionality_falls_back_to_agent_slot() {
365 let scheme = SchemeSpec {
370 id: SchemeId(9997),
371 name: "synthetic proportionality".into(),
372 category: SchemeCategory::Practical,
373 premises: vec![PremiseSlot::new("agent", "the actor", SlotRole::Agent)],
374 conclusion: ConclusionTemplate::positive("did_it", "did_it"),
375 critical_questions: vec![CriticalQuestion::new(
376 1,
377 "Is the reaction to ?agent proportionate?",
378 Challenge::Proportionality,
379 )],
380 metadata: SchemeMetadata {
381 citation: "synthetic".into(),
382 domain_tags: vec![],
383 presumptive: true,
384 strength: SchemeStrength::Moderate,
385 },
386 };
387 let bindings: HashMap<String, String> = [("agent".to_string(), "alice".to_string())]
388 .into_iter()
389 .collect();
390 let instance = instantiate(&scheme, &bindings).unwrap();
391 assert_eq!(
392 instance.critical_questions[0].counter_literal,
393 Literal::neg("proportionate_attack_alice"),
394 );
395 }
396
397 #[test]
398 fn negated_conclusion_template_produces_negated_literal() {
399 let mut scheme = expert_opinion_scheme();
400 scheme.conclusion = ConclusionTemplate::negated("¬?claim", "?claim");
401 let instance = instantiate(&scheme, &full_bindings()).unwrap();
402 assert_eq!(instance.conclusion, Literal::neg("fortify_east"));
403 }
404
405 #[test]
406 fn resolve_template_handles_prefix_overlapping_slot_names() {
407 let bindings: HashMap<String, String> = [
411 ("threatener".to_string(), "darth_vader".to_string()),
412 ("threat".to_string(), "destroy_planet".to_string()),
413 ("demand".to_string(), "join_dark_side".to_string()),
414 ]
415 .into_iter()
416 .collect();
417
418 let template = "Does ?threatener carry out ?threat to force ?demand?";
419 let resolved = resolve_template(template, &bindings);
420 assert_eq!(
421 resolved,
422 "Does darth_vader carry out destroy_planet to force join_dark_side?"
423 );
424 }
425
426 #[test]
427 fn resolve_template_handles_three_way_prefix_overlap() {
428 let bindings: HashMap<String, String> = [
433 ("event".to_string(), "trigger".to_string()),
434 ("event_a".to_string(), "alpha".to_string()),
435 ("event_a_extended".to_string(), "alpha_plus".to_string()),
436 ]
437 .into_iter()
438 .collect();
439
440 let template = "saw ?event then ?event_a then ?event_a_extended";
441 let resolved = resolve_template(template, &bindings);
442 assert_eq!(resolved, "saw trigger then alpha then alpha_plus");
443 }
444
445 #[test]
446 fn schemespec_instantiate_method_delegates_to_free_function() {
447 let scheme = expert_opinion_scheme();
448 let via_method = scheme.instantiate(&full_bindings()).unwrap();
449 let via_free_fn = instantiate(&scheme, &full_bindings()).unwrap();
450 assert_eq!(via_method.premises, via_free_fn.premises);
451 assert_eq!(via_method.conclusion, via_free_fn.conclusion);
452 }
453}