Skip to main content

argumentation/aspic/
defeat.rs

1//! `StructuredSystem`: the top-level ASPIC+ entry point.
2//!
3//! Holds a knowledge base, a rule set, and a rule preference ordering,
4//! then constructs arguments, computes attacks, resolves defeats via
5//! the last-link principle, and emits a Dung AF.
6
7use super::argument::{Argument, ArgumentId, Origin, construct_arguments};
8use super::attacks::{Attack, AttackKind, compute_attacks};
9use super::kb::KnowledgeBase;
10use super::language::Literal;
11use super::rules::{Rule, RuleId};
12use crate::framework::ArgumentationFramework;
13use std::collections::HashSet;
14
15/// Defeat resolution ordering for ASPIC+.
16///
17/// Per M&P 2014 §3.5, two orderings are defined:
18///
19/// - [`DefeatOrdering::LastLink`] (default): compares arguments at the
20///   last defeasible rule or, when both rule frontiers are empty, at the
21///   last-premise frontier. Appropriate for legal and normative reasoning
22///   where rules carry more weight than the facts they act on.
23/// - [`DefeatOrdering::WeakestLink`]: compares arguments on the full set
24///   of defeasible rules and ordinary premises they use. Appropriate for
25///   empirical reasoning where a chain is only as strong as its weakest
26///   link.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
28pub enum DefeatOrdering {
29    /// Last-link ordering. Default.
30    #[default]
31    LastLink,
32    /// Weakest-link ordering.
33    WeakestLink,
34}
35
36/// An ASPIC+ structured argumentation system.
37#[derive(Debug, Default)]
38pub struct StructuredSystem {
39    kb: KnowledgeBase,
40    rules: Vec<Rule>,
41    /// `(preferred, less_preferred)` pairs over defeasible rules. Effective
42    /// ordering is the transitive closure.
43    preferences: Vec<(RuleId, RuleId)>,
44    /// `(preferred, less_preferred)` pairs over ordinary premises. Effective
45    /// ordering is the transitive closure. Compared via last-premise
46    /// frontier when both arguments' last-defeasible-rule frontiers are
47    /// empty (per M&P 2014 Definition 3.21).
48    premise_preferences: Vec<(Literal, Literal)>,
49    /// Defeat resolution ordering. Default: LastLink.
50    ordering: DefeatOrdering,
51    next_rule_id: usize,
52}
53
54/// The combined output of building a `StructuredSystem`: constructed arguments,
55/// computed attacks, and the resulting abstract framework. Returned by
56/// [`StructuredSystem::build_framework`] so consumers can get all three
57/// from a single forward-chaining pass.
58#[derive(Debug)]
59pub struct BuildOutput {
60    /// All arguments constructed from the knowledge base and rule set.
61    pub arguments: Vec<Argument>,
62    /// All attacks between those arguments (before defeat resolution).
63    pub attacks: Vec<Attack>,
64    /// The abstract framework with edges for defeats (post-preference).
65    pub framework: ArgumentationFramework<ArgumentId>,
66    /// Copy of the rule set used to construct the arguments. Retained
67    /// for post-hoc analyses like [`Self::check_postulates`].
68    pub rules: Vec<Rule>,
69}
70
71impl BuildOutput {
72    /// Return the conclusions of every argument in the given extension.
73    ///
74    /// Useful for mapping a `HashSet<ArgumentId>` back to user-visible
75    /// literal content without manually walking `self.arguments`.
76    pub fn conclusions_in(&self, extension: &HashSet<ArgumentId>) -> HashSet<&Literal> {
77        self.arguments
78            .iter()
79            .filter(|a| extension.contains(&a.id))
80            .map(|a| &a.conclusion)
81            .collect()
82    }
83
84    /// Return the first argument whose conclusion equals `literal`, if any.
85    ///
86    /// When multiple arguments share a conclusion (e.g. two rules yielding
87    /// the same literal), this returns the first one in construction order.
88    /// Use [`Self::arguments_with_conclusion`] if you need all of them.
89    pub fn argument_by_conclusion(&self, literal: &Literal) -> Option<&Argument> {
90        self.arguments.iter().find(|a| &a.conclusion == literal)
91    }
92
93    /// Return every argument whose conclusion equals `literal`.
94    pub fn arguments_with_conclusion(&self, literal: &Literal) -> Vec<&Argument> {
95        self.arguments
96            .iter()
97            .filter(|a| &a.conclusion == literal)
98            .collect()
99    }
100
101    /// Check the Caminada-Amgoud rationality postulates against a given
102    /// extension. Returns a [`super::postulates::PostulateReport`] listing
103    /// any violations.
104    ///
105    /// A clean report (empty `violations`) means the extension satisfies
106    /// sub-argument closure, closure under strict rules, direct
107    /// consistency, and indirect consistency.
108    pub fn check_postulates(
109        &self,
110        extension: &HashSet<ArgumentId>,
111    ) -> super::postulates::PostulateReport {
112        super::postulates::check_postulates(&self.arguments, &self.rules, extension)
113    }
114}
115
116impl StructuredSystem {
117    /// Create a new empty system.
118    pub fn new() -> Self {
119        Self::default()
120    }
121
122    /// Create a new system with a specific defeat ordering.
123    pub fn with_ordering(ordering: DefeatOrdering) -> Self {
124        Self {
125            ordering,
126            ..Self::default()
127        }
128    }
129
130    /// Return the currently active defeat ordering.
131    pub fn ordering(&self) -> DefeatOrdering {
132        self.ordering
133    }
134
135    /// Mutable access to the knowledge base.
136    pub fn kb_mut(&mut self) -> &mut KnowledgeBase {
137        &mut self.kb
138    }
139
140    /// Convenience forwarder for [`KnowledgeBase::add_necessary`].
141    pub fn add_necessary(&mut self, l: Literal) {
142        self.kb.add_necessary(l);
143    }
144
145    /// Convenience forwarder for [`KnowledgeBase::add_ordinary`].
146    pub fn add_ordinary(&mut self, l: Literal) {
147        self.kb.add_ordinary(l);
148    }
149
150    /// Add a strict rule, returning its id.
151    pub fn add_strict_rule(&mut self, premises: Vec<Literal>, conclusion: Literal) -> RuleId {
152        let id = RuleId(self.next_rule_id);
153        self.next_rule_id += 1;
154        self.rules.push(Rule::strict(id, premises, conclusion));
155        id
156    }
157
158    /// Add a defeasible rule, returning its id.
159    pub fn add_defeasible_rule(&mut self, premises: Vec<Literal>, conclusion: Literal) -> RuleId {
160        let id = RuleId(self.next_rule_id);
161        self.next_rule_id += 1;
162        self.rules.push(Rule::defeasible(id, premises, conclusion));
163        id
164    }
165
166    /// Add an *undercut* rule targeting the defeasible rule `target`.
167    ///
168    /// This is the safe way to construct an undercut: it encodes the
169    /// conclusion as the reserved literal `¬__applicable_<target>`, which
170    /// [`super::attacks::compute_attacks`] recognises.
171    /// Consumers should never build this literal by hand — the `__applicable_`
172    /// prefix is reserved and must not be used in user atom names.
173    pub fn add_undercut_rule(&mut self, target: RuleId, premises: Vec<Literal>) -> RuleId {
174        let conclusion = Literal::undercut_marker(target.0);
175        self.add_defeasible_rule(premises, conclusion)
176    }
177
178    /// Record that rule `preferred` is (directly) preferred to rule `less_preferred`.
179    ///
180    /// The effective preference ordering is the transitive closure of these
181    /// pairs, computed on demand in `is_preferred`: a chain of direct
182    /// preferences `r3 > r2 > r1` implies `r3 > r1`.
183    ///
184    /// # Errors
185    ///
186    /// Returns [`crate::Error::Aspic`] if the preference would be reflexive
187    /// (`a > a`) or would create a cycle in the transitive closure (e.g.
188    /// adding `r2 > r1` when `r1 > r2` is already implied). Both cases
189    /// violate strict-partial-order semantics and would silently produce
190    /// contradictory `is_preferred` results.
191    pub fn prefer_rule(
192        &mut self,
193        preferred: RuleId,
194        less_preferred: RuleId,
195    ) -> Result<(), crate::Error> {
196        if preferred == less_preferred {
197            return Err(crate::Error::Aspic(format!(
198                "reflexive preference rejected: rule {:?} cannot be preferred to itself",
199                preferred
200            )));
201        }
202        if self.is_preferred(less_preferred, preferred) {
203            return Err(crate::Error::Aspic(format!(
204                "cyclic preference rejected: rule {:?} is already (transitively) preferred to {:?}",
205                less_preferred, preferred
206            )));
207        }
208        self.preferences.push((preferred, less_preferred));
209        Ok(())
210    }
211
212    /// Record that ordinary premise `preferred` is (directly) preferred to
213    /// ordinary premise `less_preferred`.
214    ///
215    /// Per M&P 2014 Definition 3.21, premise preferences are compared
216    /// under the last-link ordering only when both arguments have empty
217    /// last-defeasible-rule frontiers (i.e. they are pure-premise arguments
218    /// or strict-rule chains grounded in premises).
219    ///
220    /// Rejects reflexive `(x, x)` preferences and cyclic preferences
221    /// (where `less_preferred` is already transitively preferred to
222    /// `preferred`).
223    pub fn prefer_premise(
224        &mut self,
225        preferred: Literal,
226        less_preferred: Literal,
227    ) -> Result<(), crate::Error> {
228        if preferred == less_preferred {
229            return Err(crate::Error::Aspic(format!(
230                "reflexive premise preference rejected: {:?} cannot be preferred to itself",
231                preferred
232            )));
233        }
234        if self.is_premise_preferred(&less_preferred, &preferred) {
235            return Err(crate::Error::Aspic(format!(
236                "cyclic premise preference rejected: {:?} is already (transitively) preferred to {:?}",
237                less_preferred, preferred
238            )));
239        }
240        self.premise_preferences.push((preferred, less_preferred));
241        Ok(())
242    }
243
244    /// Whether ordinary premise `a` is strictly preferred to `b` under
245    /// the transitive closure of recorded premise preferences.
246    pub fn is_premise_preferred(&self, a: &Literal, b: &Literal) -> bool {
247        if a == b {
248            return false;
249        }
250        let mut visited: HashSet<&Literal> = HashSet::new();
251        let mut frontier: Vec<&Literal> = vec![a];
252        while let Some(current) = frontier.pop() {
253            if !visited.insert(current) {
254                continue;
255            }
256            for (p, lp) in &self.premise_preferences {
257                if p == current {
258                    if lp == b {
259                        return true;
260                    }
261                    frontier.push(lp);
262                }
263            }
264        }
265        false
266    }
267
268    /// Read-side accessor for the direct premise preference pairs.
269    pub fn premise_preferences(&self) -> &[(Literal, Literal)] {
270        &self.premise_preferences
271    }
272
273    /// Read-side accessor for the knowledge base (for debugging/visualization).
274    pub fn kb(&self) -> &KnowledgeBase {
275        &self.kb
276    }
277
278    /// Read-side accessor for the rule set.
279    pub fn rules(&self) -> &[Rule] {
280        &self.rules
281    }
282
283    /// Read-side accessor for the direct preference pairs (not transitively closed).
284    pub fn preferences(&self) -> &[(RuleId, RuleId)] {
285        &self.preferences
286    }
287
288    /// Whether rule `a` is strictly preferred to rule `b` under the transitive
289    /// closure of recorded preferences.
290    fn is_preferred(&self, a: RuleId, b: RuleId) -> bool {
291        // Self-preference is not a valid strict preference.
292        if a == b {
293            return false;
294        }
295        // BFS from `a` following preferred-to edges; return true if we reach `b`.
296        let mut visited: HashSet<RuleId> = HashSet::new();
297        let mut frontier: Vec<RuleId> = vec![a];
298        while let Some(current) = frontier.pop() {
299            if !visited.insert(current) {
300                continue;
301            }
302            for (p, lp) in &self.preferences {
303                if *p == current {
304                    if *lp == b {
305                        return true;
306                    }
307                    frontier.push(*lp);
308                }
309            }
310        }
311        false
312    }
313
314    /// Collect the "last-link frontier": all defeasible rules that are the
315    /// final defeasible step on some path from a premise to this argument's
316    /// conclusion. Per Modgil & Prakken 2014 §3.4.
317    ///
318    /// - If the top rule is defeasible, the frontier is `{top_rule}`.
319    /// - If the top rule is strict, the frontier is the union of the frontiers
320    ///   of the sub-arguments (skipping strict rules in search of the deepest
321    ///   defeasible step).
322    /// - If the argument is a premise leaf, the frontier is empty.
323    fn last_defeasible_frontier(&self, arg: &Argument, args: &[Argument]) -> Vec<RuleId> {
324        match &arg.origin {
325            Origin::Premise(_) => Vec::new(),
326            Origin::RuleApplication(rid) => {
327                if let Some(rule) = self.rules.iter().find(|r| r.id == *rid)
328                    && rule.is_defeasible()
329                {
330                    return vec![*rid];
331                }
332                // Strict top: recurse into sub-arguments.
333                let mut result = Vec::new();
334                for sub_id in &arg.sub_arguments {
335                    if let Some(sub) = args.iter().find(|a| a.id == *sub_id) {
336                        result.extend(self.last_defeasible_frontier(sub, args));
337                    }
338                }
339                result
340            }
341        }
342    }
343
344    /// Collect all defeasible rules used anywhere in an argument's
345    /// derivation tree (not just the last ones). Used by weakest-link
346    /// defeat ordering.
347    fn all_defeasible_rules(&self, arg: &Argument, args: &[Argument]) -> Vec<RuleId> {
348        let mut out = Vec::new();
349        if let Origin::RuleApplication(rid) = &arg.origin
350            && let Some(rule) = self.rules.iter().find(|r| r.id == *rid)
351            && rule.is_defeasible()
352        {
353            out.push(*rid);
354        }
355        for sub_id in &arg.sub_arguments {
356            if let Some(sub) = args.iter().find(|a| a.id == *sub_id) {
357                out.extend(self.all_defeasible_rules(sub, args));
358            }
359        }
360        out
361    }
362
363    /// Collect all ordinary (defeasible) premises used anywhere in an
364    /// argument's derivation tree. Used by weakest-link defeat ordering.
365    fn all_ordinary_premises(&self, arg: &Argument, args: &[Argument]) -> Vec<Literal> {
366        let mut out = Vec::new();
367        match &arg.origin {
368            Origin::Premise(p) => {
369                if p.is_defeasible() {
370                    out.push(p.literal().clone());
371                }
372            }
373            Origin::RuleApplication(_) => {
374                for sub_id in &arg.sub_arguments {
375                    if let Some(sub) = args.iter().find(|a| a.id == *sub_id) {
376                        out.extend(self.all_ordinary_premises(sub, args));
377                    }
378                }
379            }
380        }
381        out
382    }
383
384    /// Collect the "last-premise frontier": all ordinary premises that
385    /// lie at the leaves of this argument's derivation tree.
386    ///
387    /// Per M&P 2014 Definition 3.21, this frontier is compared under
388    /// last-link ordering when both arguments have empty last-defeasible-rule
389    /// frontiers. Necessary (indefeasible) premises are excluded because
390    /// they are not subject to preference comparison — they carry the
391    /// full force of the knowledge base.
392    fn last_premise_frontier(&self, arg: &Argument, args: &[Argument]) -> Vec<Literal> {
393        match &arg.origin {
394            Origin::Premise(p) => {
395                if p.is_defeasible() {
396                    vec![p.literal().clone()]
397                } else {
398                    Vec::new()
399                }
400            }
401            Origin::RuleApplication(_) => {
402                let mut out = Vec::new();
403                for sub_id in &arg.sub_arguments {
404                    if let Some(sub) = args.iter().find(|a| a.id == *sub_id) {
405                        out.extend(self.last_premise_frontier(sub, args));
406                    }
407                }
408                out
409            }
410        }
411    }
412
413    /// Whether an attack succeeds as a defeat under the last-link principle
414    /// with Elitist preference comparison (Modgil & Prakken 2014 §5.2).
415    ///
416    /// - Undercut always succeeds (regardless of preferences).
417    /// - Rebut and undermine succeed unless the target's last-link frontier
418    ///   contains a rule that is strictly preferred to *every* rule in the
419    ///   attacker's last-link frontier (Elitist ordering).
420    /// - When both sides have empty defeasible-rule frontiers, fall through
421    ///   to the last-premise frontier per M&P 2014 Def 3.21.
422    ///
423    /// For single-rule frontiers (the common case) this collapses to a direct
424    /// pairwise comparison `target_rule > attacker_rule`.
425    fn is_defeat(&self, attack: &Attack, args: &[Argument]) -> bool {
426        if attack.kind == AttackKind::Undercut {
427            return true;
428        }
429        let attacker = args
430            .iter()
431            .find(|a| a.id == attack.attacker)
432            .expect("attack references nonexistent attacker argument");
433        let target = args
434            .iter()
435            .find(|a| a.id == attack.target)
436            .expect("attack references nonexistent target argument");
437
438        // An attack succeeds as a defeat iff the attacker is NOT strictly
439        // less preferred than the target (i.e. target does not strictly
440        // dominate attacker). Per M&P 2014.
441        let attacker_strictly_less = match self.ordering {
442            DefeatOrdering::LastLink => self.last_link_prec(attacker, target, args),
443            DefeatOrdering::WeakestLink => self.weakest_link_prec(attacker, target, args),
444        };
445        !attacker_strictly_less
446    }
447
448    /// Elitist strict set comparison `Γ ◁Eli Γ'` per M&P 2014 Def 3.19.
449    ///
450    /// Returns true iff Γ is strictly less preferred than Γ' under the
451    /// Elitist set ordering. Definition 3.19 has three rules:
452    ///
453    /// 1. If `Γ = ∅`, then `Γ ⋬s Γ'` (the empty set is never strictly
454    ///    less than anything — by convention, "no defeasible commitments"
455    ///    is the strongest possible position).
456    /// 2. If `Γ' = ∅` and `Γ ≠ ∅`, then `Γ ⊴s Γ'` (and via the strict
457    ///    counterpart, `Γ ◁s Γ'`). Any non-empty set is strictly less
458    ///    preferred than the empty set.
459    /// 3. Otherwise, `Γ ⊴Eli Γ'` iff `∃X ∈ Γ. ∀Y ∈ Γ'. X ≤ Y` (some
460    ///    element of Γ is dominated by every element of Γ'). The strict
461    ///    counterpart `◁Eli` substitutes `<` for `≤`.
462    ///
463    /// The implementation uses the strict counterpart directly via
464    /// [`Self::is_preferred`], which returns true only on the strict
465    /// version of the rule preference relation.
466    fn rule_set_strict_lt(&self, gamma: &[RuleId], gamma_prime: &[RuleId]) -> bool {
467        if gamma.is_empty() {
468            return false;
469        }
470        if gamma_prime.is_empty() {
471            return true;
472        }
473        gamma
474            .iter()
475            .any(|x| gamma_prime.iter().all(|y| self.is_preferred(*y, *x)))
476    }
477
478    /// Same as [`Self::rule_set_strict_lt`] but over ordinary-premise sets.
479    fn premise_set_strict_lt(&self, gamma: &[Literal], gamma_prime: &[Literal]) -> bool {
480        if gamma.is_empty() {
481            return false;
482        }
483        if gamma_prime.is_empty() {
484            return true;
485        }
486        gamma
487            .iter()
488            .any(|x| gamma_prime.iter().all(|y| self.is_premise_preferred(y, x)))
489    }
490
491    /// Elitist non-strict set comparison `Γ ⊴Eli Γ'` per M&P 2014 Def 3.19.
492    ///
493    /// Returns true iff `∃X ∈ Γ. ∀Y ∈ Γ'. X ≤ Y`. Same edge cases as
494    /// [`Self::rule_set_strict_lt`]: empty `Γ` is never `⊴`; non-empty
495    /// `Γ` is `⊴` empty `Γ'`. The ≤ check uses `!is_preferred(x, y)`,
496    /// i.e. `x` is not strictly more preferred than `y` (ASPIC+ assumes
497    /// a total preorder over rules, so this is equivalent to `x ≤ y`).
498    fn rule_set_lte(&self, gamma: &[RuleId], gamma_prime: &[RuleId]) -> bool {
499        if gamma.is_empty() {
500            return false;
501        }
502        if gamma_prime.is_empty() {
503            return true;
504        }
505        gamma
506            .iter()
507            .any(|x| gamma_prime.iter().all(|y| !self.is_preferred(*x, *y)))
508    }
509
510    /// Same as [`Self::rule_set_lte`] but over ordinary-premise sets.
511    fn premise_set_lte(&self, gamma: &[Literal], gamma_prime: &[Literal]) -> bool {
512        if gamma.is_empty() {
513            return false;
514        }
515        if gamma_prime.is_empty() {
516            return true;
517        }
518        gamma
519            .iter()
520            .any(|x| gamma_prime.iter().all(|y| !self.is_premise_preferred(x, y)))
521    }
522
523    /// Last-link strict preference `A ≺ B` per M&P 2014 Def 3.21.
524    ///
525    /// A ≺ B iff:
526    /// - LastDefRules(A) ◁s LastDefRules(B), OR
527    /// - Both LastDefRules are empty AND Prem(A) ◁s Prem(B).
528    fn last_link_prec(&self, a: &Argument, b: &Argument, args: &[Argument]) -> bool {
529        let a_rules = self.last_defeasible_frontier(a, args);
530        let b_rules = self.last_defeasible_frontier(b, args);
531        if self.rule_set_strict_lt(&a_rules, &b_rules) {
532            return true;
533        }
534        if a_rules.is_empty() && b_rules.is_empty() {
535            let a_prems = self.last_premise_frontier(a, args);
536            let b_prems = self.last_premise_frontier(b, args);
537            return self.premise_set_strict_lt(&a_prems, &b_prems);
538        }
539        false
540    }
541
542    /// Weakest-link strict preference `A ≺ B` per M&P 2014 Def 3.23.
543    ///
544    /// Def 3.23 defines the non-strict ⪯ in three cases. The note after
545    /// 3.23 says one can "in the same way as Def 3.21" directly define
546    /// ≺ "in terms of ◁s." That note is unambiguous for Def 3.21's
547    /// disjunctive form (textual replacement of ⊴s by ◁s coincides with
548    /// the standard derivation `A ⪯ B ∧ B ⋠ A`), but for Def 3.23's
549    /// conjunctive case 3 the two readings diverge: the textual
550    /// replacement requires both dimensions to be strictly less, while
551    /// the standard derivation collapses (under a total preorder) to
552    /// `(Premp_A ⊴ Premp_B) ∧ (DefRules_A ⊴ DefRules_B)` together with
553    /// at least one of the two being strict. We use the standard
554    /// derivation: it allows tied premises with strictly weaker rules
555    /// (or vice versa) to yield `A ≺ B`, which matches the intuition
556    /// behind weakest-link reasoning and the broader ASPIC+ literature.
557    ///
558    /// Special cases (Def 3.23 (1) and (2) directly):
559    /// - Both arguments strict (no defeasible rules either side): compare
560    ///   only ordinary-premise sets.
561    /// - Both arguments firm (no ordinary premises either side): compare
562    ///   only defeasible-rule sets.
563    fn weakest_link_prec(&self, a: &Argument, b: &Argument, args: &[Argument]) -> bool {
564        let a_rules = self.all_defeasible_rules(a, args);
565        let b_rules = self.all_defeasible_rules(b, args);
566        let a_prems = self.all_ordinary_premises(a, args);
567        let b_prems = self.all_ordinary_premises(b, args);
568
569        let both_strict_arg = a_rules.is_empty() && b_rules.is_empty();
570        let both_firm = a_prems.is_empty() && b_prems.is_empty();
571
572        if both_strict_arg {
573            return self.premise_set_strict_lt(&a_prems, &b_prems);
574        }
575        if both_firm {
576            return self.rule_set_strict_lt(&a_rules, &b_rules);
577        }
578
579        let prems_lte = self.premise_set_lte(&a_prems, &b_prems);
580        let rules_lte = self.rule_set_lte(&a_rules, &b_rules);
581        let prems_lt = self.premise_set_strict_lt(&a_prems, &b_prems);
582        let rules_lt = self.rule_set_strict_lt(&a_rules, &b_rules);
583        prems_lte && rules_lte && (prems_lt || rules_lt)
584    }
585
586    /// Single-pass construction: build all arguments, compute all attacks,
587    /// resolve defeats, and emit a framework. Prefer this over calling
588    /// [`Self::to_framework`] and [`Self::arguments`] separately — each of
589    /// those does its own forward-chaining pass.
590    #[must_use = "build_framework returns a Result whose Ok carries the constructed framework, attacks, and arguments — discarding it is almost always a bug"]
591    pub fn build_framework(&self) -> Result<BuildOutput, crate::Error> {
592        let arguments = construct_arguments(&self.kb, &self.rules)?;
593        let attacks = compute_attacks(&arguments, &self.rules);
594        let mut framework = ArgumentationFramework::new();
595        for arg in &arguments {
596            framework.add_argument(arg.id);
597        }
598        for attack in &attacks {
599            if self.is_defeat(attack, &arguments) {
600                framework.add_attack(&attack.attacker, &attack.target)?;
601            }
602        }
603        Ok(BuildOutput {
604            arguments,
605            attacks,
606            framework,
607            rules: self.rules.clone(),
608        })
609    }
610
611    /// Construct arguments, compute attacks, resolve defeats, and emit an AF.
612    ///
613    /// Convenience wrapper over [`Self::build_framework`] that discards the
614    /// constructed arguments and attacks. Consumers that need those should
615    /// call `build_framework` directly.
616    #[must_use = "to_framework returns the constructed Dung AF — discarding it leaves no observable effect"]
617    pub fn to_framework(&self) -> Result<ArgumentationFramework<ArgumentId>, crate::Error> {
618        Ok(self.build_framework()?.framework)
619    }
620
621    /// Expose constructed arguments. Convenience wrapper over
622    /// [`Self::build_framework`]; see its docs for performance notes.
623    #[must_use = "arguments() returns the constructed argument list — discarding it leaves no observable effect"]
624    pub fn arguments(&self) -> Result<Vec<Argument>, crate::Error> {
625        Ok(self.build_framework()?.arguments)
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632    use crate::aspic::language::Literal;
633
634    #[test]
635    fn penguin_example_resolves_correctly() {
636        let mut system = StructuredSystem::new();
637        system.kb_mut().add_ordinary(Literal::atom("penguin"));
638        system.add_strict_rule(vec![Literal::atom("penguin")], Literal::atom("bird"));
639        let r1 = system.add_defeasible_rule(vec![Literal::atom("bird")], Literal::atom("flies"));
640        let r2 = system.add_defeasible_rule(vec![Literal::atom("penguin")], Literal::neg("flies"));
641        system.prefer_rule(r2, r1).unwrap();
642
643        let built = system.build_framework().unwrap();
644        let preferred = built.framework.preferred_extensions().unwrap();
645        assert_eq!(preferred.len(), 1);
646        let ext = &preferred[0];
647        let flies_arg = built
648            .arguments
649            .iter()
650            .find(|a| a.conclusion == Literal::atom("flies"));
651        let not_flies_arg = built
652            .arguments
653            .iter()
654            .find(|a| a.conclusion == Literal::neg("flies"));
655        if let (Some(f), Some(nf)) = (flies_arg, not_flies_arg) {
656            assert!(!ext.contains(&f.id));
657            assert!(ext.contains(&nf.id));
658        }
659    }
660
661    #[test]
662    fn transitive_preferences_are_respected() {
663        // Recorded: r3 > r2, r2 > r1. Expected: r3 > r1 via transitive closure.
664        let mut system = StructuredSystem::new();
665        system.kb_mut().add_ordinary(Literal::atom("p"));
666        system.kb_mut().add_ordinary(Literal::atom("q"));
667        system.kb_mut().add_ordinary(Literal::atom("r"));
668        let r1 = system.add_defeasible_rule(vec![Literal::atom("p")], Literal::atom("x"));
669        let r2 = system.add_defeasible_rule(vec![Literal::atom("q")], Literal::atom("y"));
670        let r3 = system.add_defeasible_rule(vec![Literal::atom("r")], Literal::atom("z"));
671        system.prefer_rule(r3, r2).unwrap();
672        system.prefer_rule(r2, r1).unwrap();
673        assert!(
674            system.is_preferred(r3, r1),
675            "transitive r3 > r1 should hold"
676        );
677    }
678
679    #[test]
680    fn undercut_helper_constructs_reserved_literal() {
681        let mut system = StructuredSystem::new();
682        system.kb_mut().add_ordinary(Literal::atom("p"));
683        let target = system.add_defeasible_rule(vec![Literal::atom("p")], Literal::atom("q"));
684        system.kb_mut().add_ordinary(Literal::atom("trigger"));
685        let uc = system.add_undercut_rule(target, vec![Literal::atom("trigger")]);
686        let uc_rule = system.rules().iter().find(|r| r.id == uc).unwrap();
687        assert_eq!(uc_rule.conclusion, Literal::undercut_marker(target.0));
688    }
689
690    #[test]
691    fn read_side_getters_expose_state() {
692        let mut system = StructuredSystem::new();
693        system.kb_mut().add_ordinary(Literal::atom("p"));
694        let r1 = system.add_defeasible_rule(vec![Literal::atom("p")], Literal::atom("q"));
695        let r2 = system.add_defeasible_rule(vec![Literal::atom("p")], Literal::atom("r"));
696        assert_eq!(system.kb().premises().len(), 1);
697        assert_eq!(system.rules().len(), 2);
698        assert!(system.preferences().is_empty());
699        system.prefer_rule(r1, r2).unwrap();
700        assert_eq!(system.preferences().len(), 1);
701    }
702
703    #[test]
704    fn empty_system_produces_empty_framework() {
705        let system = StructuredSystem::new();
706        let af = system.to_framework().unwrap();
707        assert_eq!(af.arguments().count(), 0);
708    }
709
710    #[test]
711    fn weakest_link_case3_succeeds_with_one_strict_dimension() {
712        // Regression test for the Def 3.23 case-(3) reading. Construction:
713        // - Premp: p_lo < p_hi (strict premise preference)
714        // - Rules: r_a: p_lo ⇒ q   and   r_b: p_hi ⇒ ¬q
715        //   No rule preference (r_a and r_b are incomparable / equal).
716        //
717        // For the argument A built from r_a vs B built from r_b under
718        // weakest-link, case 3 applies (both plausible and defeasible):
719        // - Premp(A) = {p_lo} ◁ {p_hi} = Premp(B)              (STRICT)
720        // - DefRules(A) = {r_a}, DefRules(B) = {r_b}, no order  (TIED)
721        //
722        // The pre-fix textual-replacement reading required BOTH dimensions
723        // strictly less, so it concluded A ⊀ B and let A defeat B. The
724        // standard derivation says A ≺ B (one dimension strict, other tied
725        // with ⊴ both ways), so A's attack must be filtered out and B
726        // strictly defeats A. We pin the latter.
727        let mut system = StructuredSystem::with_ordering(DefeatOrdering::WeakestLink);
728        system.kb_mut().add_ordinary(Literal::atom("p_lo"));
729        system.kb_mut().add_ordinary(Literal::atom("p_hi"));
730        system
731            .prefer_premise(Literal::atom("p_hi"), Literal::atom("p_lo"))
732            .unwrap();
733        let _r_a = system.add_defeasible_rule(vec![Literal::atom("p_lo")], Literal::atom("q"));
734        let _r_b = system.add_defeasible_rule(vec![Literal::atom("p_hi")], Literal::neg("q"));
735
736        let built = system.build_framework().unwrap();
737        let q_arg = built
738            .arguments
739            .iter()
740            .find(|a| a.conclusion == Literal::atom("q"))
741            .unwrap();
742        let nq_arg = built
743            .arguments
744            .iter()
745            .find(|a| a.conclusion == Literal::neg("q"))
746            .unwrap();
747
748        // B (¬q) strictly defeats A (q): the only surviving defeat edge is
749        // nq → q, with no q → nq.
750        assert!(
751            built.framework.attackers(&q_arg.id).contains(&&nq_arg.id),
752            "B (built from p_hi) should defeat A (built from p_lo)"
753        );
754        assert!(
755            !built.framework.attackers(&nq_arg.id).contains(&&q_arg.id),
756            "A's attack on B must be filtered out: A ≺ B by Def 3.23 case 3 \
757             (premise strict, rules tied)"
758        );
759
760        let preferred = built.framework.preferred_extensions().unwrap();
761        assert_eq!(preferred.len(), 1);
762        assert!(preferred[0].contains(&nq_arg.id));
763        assert!(!preferred[0].contains(&q_arg.id));
764    }
765
766    #[test]
767    fn elitist_single_rule_frontier_still_works() {
768        // Regression test for the quantifier change from ∃/∃ to Elitist ∃/∀.
769        // With single-rule frontiers on both sides, Elitist collapses to the
770        // same result as the old ∃/∃ code: strong rule's argument beats weak
771        // rule's argument. This test pins that behavior so future quantifier
772        // experiments don't silently break the penguin case.
773        let mut system = StructuredSystem::new();
774        system.kb_mut().add_ordinary(Literal::atom("p"));
775        system.kb_mut().add_ordinary(Literal::atom("q"));
776        let r_strong = system.add_defeasible_rule(vec![Literal::atom("p")], Literal::atom("x"));
777        let r_weak = system.add_defeasible_rule(vec![Literal::atom("q")], Literal::neg("x"));
778        system.prefer_rule(r_strong, r_weak).unwrap();
779
780        let built = system.build_framework().unwrap();
781        let preferred = built.framework.preferred_extensions().unwrap();
782        assert_eq!(preferred.len(), 1);
783        let ext = &preferred[0];
784        let x_arg = built
785            .arguments
786            .iter()
787            .find(|a| a.conclusion == Literal::atom("x"))
788            .unwrap();
789        let nx_arg = built
790            .arguments
791            .iter()
792            .find(|a| a.conclusion == Literal::neg("x"))
793            .unwrap();
794        assert!(ext.contains(&x_arg.id));
795        assert!(!ext.contains(&nx_arg.id));
796    }
797
798    #[test]
799    fn prefer_rule_rejects_cyclic_preferences() {
800        let mut system = StructuredSystem::new();
801        system.add_ordinary(Literal::atom("p"));
802        system.add_ordinary(Literal::atom("q"));
803        let r1 = system.add_defeasible_rule(vec![Literal::atom("p")], Literal::atom("x"));
804        let r2 = system.add_defeasible_rule(vec![Literal::atom("q")], Literal::atom("y"));
805        system.prefer_rule(r1, r2).unwrap();
806        // Adding r2 > r1 now would create a cycle.
807        let result = system.prefer_rule(r2, r1);
808        assert!(matches!(result, Err(crate::Error::Aspic(_))));
809    }
810
811    #[test]
812    fn prefer_rule_rejects_reflexive_preferences() {
813        let mut system = StructuredSystem::new();
814        system.add_ordinary(Literal::atom("p"));
815        let r1 = system.add_defeasible_rule(vec![Literal::atom("p")], Literal::atom("x"));
816        let result = system.prefer_rule(r1, r1);
817        assert!(matches!(result, Err(crate::Error::Aspic(_))));
818    }
819
820    #[test]
821    fn build_output_conclusions_in_extension_returns_literals() {
822        let mut sys = StructuredSystem::new();
823        sys.add_ordinary(Literal::atom("p"));
824        let _r = sys.add_defeasible_rule(vec![Literal::atom("p")], Literal::atom("q"));
825        let built = sys.build_framework().unwrap();
826        let grounded = built.framework.grounded_extension();
827        let concls = built.conclusions_in(&grounded);
828        assert!(concls.contains(&Literal::atom("p")));
829        assert!(concls.contains(&Literal::atom("q")));
830        assert_eq!(concls.len(), 2);
831    }
832
833    #[test]
834    fn build_output_argument_by_conclusion_finds_unique_matches() {
835        let mut sys = StructuredSystem::new();
836        sys.add_ordinary(Literal::atom("p"));
837        let _r = sys.add_defeasible_rule(vec![Literal::atom("p")], Literal::atom("q"));
838        let built = sys.build_framework().unwrap();
839        let q_arg = built.argument_by_conclusion(&Literal::atom("q"));
840        assert!(q_arg.is_some());
841        assert_eq!(q_arg.unwrap().conclusion, Literal::atom("q"));
842        let missing = built.argument_by_conclusion(&Literal::atom("never"));
843        assert!(missing.is_none());
844    }
845
846    #[test]
847    fn premise_preferences_support_transitive_closure() {
848        let mut sys = StructuredSystem::new();
849        sys.add_ordinary(Literal::atom("p"));
850        sys.add_ordinary(Literal::atom("q"));
851        sys.add_ordinary(Literal::atom("r"));
852        sys.prefer_premise(Literal::atom("p"), Literal::atom("q"))
853            .unwrap();
854        sys.prefer_premise(Literal::atom("q"), Literal::atom("r"))
855            .unwrap();
856        assert!(sys.is_premise_preferred(&Literal::atom("p"), &Literal::atom("r")));
857        assert!(!sys.is_premise_preferred(&Literal::atom("r"), &Literal::atom("p")));
858    }
859
860    #[test]
861    fn prefer_premise_rejects_reflexive() {
862        let mut sys = StructuredSystem::new();
863        sys.add_ordinary(Literal::atom("p"));
864        let result = sys.prefer_premise(Literal::atom("p"), Literal::atom("p"));
865        assert!(matches!(result, Err(crate::Error::Aspic(_))));
866    }
867
868    #[test]
869    fn prefer_premise_rejects_cyclic() {
870        let mut sys = StructuredSystem::new();
871        sys.add_ordinary(Literal::atom("p"));
872        sys.add_ordinary(Literal::atom("q"));
873        sys.prefer_premise(Literal::atom("p"), Literal::atom("q"))
874            .unwrap();
875        let result = sys.prefer_premise(Literal::atom("q"), Literal::atom("p"));
876        assert!(matches!(result, Err(crate::Error::Aspic(_))));
877    }
878
879    #[test]
880    fn premise_preference_blocks_undermine_when_target_premise_stronger() {
881        // Scenario: KB has s and u (both ordinary). Strict rule u → ¬s.
882        // Argument NS = u → ¬s undermines argument S = s.
883        //
884        // Without preferences: NS defeats S (target frontier {s} vs attacker
885        // frontier {u}, neither has rule frontier).
886        //
887        // With prefer_premise(s, u): NS does NOT defeat S because target's
888        // last-premise frontier {s} is preferred to attacker's {u}.
889        let mut sys = StructuredSystem::new();
890        sys.add_ordinary(Literal::atom("s"));
891        sys.add_ordinary(Literal::atom("u"));
892        sys.add_strict_rule(vec![Literal::atom("u")], Literal::neg("s"));
893        sys.prefer_premise(Literal::atom("s"), Literal::atom("u"))
894            .unwrap();
895
896        let built = sys.build_framework().unwrap();
897        let s_arg = built
898            .argument_by_conclusion(&Literal::atom("s"))
899            .expect("s premise argument");
900        let grounded = built.framework.grounded_extension();
901        assert!(
902            grounded.contains(&s_arg.id),
903            "expected s to survive under premise preference s > u, got {:?}",
904            grounded
905        );
906    }
907
908    #[test]
909    fn build_output_arguments_with_conclusion_returns_all_matches() {
910        let mut sys = StructuredSystem::new();
911        sys.add_ordinary(Literal::atom("a"));
912        sys.add_ordinary(Literal::atom("b"));
913        let _r1 = sys.add_defeasible_rule(vec![Literal::atom("a")], Literal::atom("target"));
914        let _r2 = sys.add_defeasible_rule(vec![Literal::atom("b")], Literal::atom("target"));
915        let built = sys.build_framework().unwrap();
916        let matches = built.arguments_with_conclusion(&Literal::atom("target"));
917        assert_eq!(matches.len(), 2);
918    }
919}