Skip to main content

argumentation_schemes/
instance.rs

1//! `SchemeInstance`: a scheme instantiated with concrete bindings.
2
3use crate::Error;
4use crate::critical::CriticalQuestion;
5use crate::scheme::SchemeSpec;
6use argumentation::aspic::Literal;
7use std::collections::HashMap;
8
9/// A critical question instantiated with concrete bindings.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct CriticalQuestionInstance {
12    /// Question number (from the parent scheme).
13    pub number: u32,
14    /// Human-readable text with `?slot` references resolved.
15    pub text: String,
16    /// The challenge type (from the parent CriticalQuestion).
17    pub challenge: crate::types::Challenge,
18    /// The literal that, if asserted, would undermine the original argument.
19    /// Always negated.
20    pub counter_literal: Literal,
21}
22
23/// A scheme instantiated with concrete bindings, ready for ASPIC+ integration.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct SchemeInstance {
26    /// The scheme this instance was created from.
27    pub scheme_name: String,
28    /// The resolved premise literals.
29    pub premises: Vec<Literal>,
30    /// The resolved conclusion literal.
31    pub conclusion: Literal,
32    /// Instantiated critical questions with resolved text and counter-literals.
33    pub critical_questions: Vec<CriticalQuestionInstance>,
34}
35
36/// Resolve a template string by replacing `?slot` references with bound values.
37///
38/// Bindings are processed in descending key-length order so that longer
39/// slot names are substituted before any shorter slot names that happen
40/// to be a prefix. Without this, a template `?threatener` containing
41/// the substring `?threat` would be corrupted by an earlier substitution
42/// of slot `threat`.
43///
44/// **Note on binding values:** substitution is multi-pass over the
45/// accumulated string, so a binding *value* that contains `?slot` syntax
46/// matching a later (shorter-key) binding will itself be substituted.
47/// Walton scheme bindings are concrete entity names (`"alice"`,
48/// `"darth_vader"`) so this never fires in practice, but consumers
49/// passing bindings from less-controlled sources (LLM output, free-text
50/// dialog) should sanitise `?` from values first.
51fn 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
61/// Instantiate a scheme with concrete bindings.
62///
63/// Every premise slot in the scheme must have a corresponding entry in
64/// `bindings`. Returns [`Error::MissingBinding`] if any required slot is
65/// unbound.
66///
67/// Also available as [`SchemeSpec::instantiate`] (which delegates here).
68pub fn instantiate(
69    scheme: &SchemeSpec,
70    bindings: &HashMap<String, String>,
71) -> Result<SchemeInstance, Error> {
72    // Validate all slots are bound. Iterate in declared order so the error
73    // message names the FIRST missing slot deterministically.
74    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    // Build premise literals: for each slot, create an atom encoding
84    // "this slot is filled by this value in this scheme instance."
85    // E.g., slot "expert" + binding "alice" → Literal::atom("expert_alice").
86    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    // Resolve conclusion template, respecting the is_negated flag.
96    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    // Instantiate critical questions.
104    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
127/// Build the counter-literal for a critical question.
128///
129/// The counter-literal is what would be asserted to undermine the scheme.
130/// Different challenge types target different aspects:
131/// - `PremiseTruth(slot)` negates the premise literal for that slot.
132/// - `SourceCredibility` negates `credible_<agent>` for the relevant agent.
133/// - Others negate a synthetic marker derived from the conclusion or scheme key.
134fn 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            // Slot priority: epistemic-source roles first, then generic
151            // agent roles, then adversarial-repurpose roles.
152            // expert > witness > source > agent > person > target > threatener.
153            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        // CQ1 challenges PremiseTruth("expert") → counter is ¬expert_alice,
265        // which is the contrary of the premise literal expert_alice.
266        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        // Forward-compat guard for the v0.1.0 fallback-chain fix: if a
282        // future scheme uses SourceCredibility alongside an `agent`-named
283        // slot (as argument_from_values and argument_from_commitment do),
284        // the counter-literal must resolve to credible_<agent_binding>,
285        // not the literal string "credible_source". Regressing this
286        // behaviour was the bug the fallback fix was written to prevent.
287        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        // The fallback chain must put `agent` ahead of `target` so that a
324        // scheme with BOTH slots resolves credibility to the agent (the
325        // person whose epistemic standing matters) rather than the target
326        // (the person being attacked). No current scheme has both slots;
327        // this test pins the ordering for the future case.
328        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        // Forward-compat guard for the Proportionality fallback chain:
366        // chain is target > threatener > agent > person, so a scheme
367        // that uses Proportionality without a `target` or `threatener`
368        // slot must still pick up an `agent` binding.
369        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        // Regression for the threat scheme: slots "threatener" and
408        // "threat" share a prefix. Without length-descending sort,
409        // ?threat would match inside ?threatener and corrupt it.
410        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        // Belt-and-braces regression: three slots whose names form a
429        // strict prefix chain (event ⊏ event_a ⊏ event_a_extended).
430        // Length-descending sort must substitute the longest first so
431        // none of the shorter names match inside the longer ones.
432        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}