diff --git a/src/argumentation/core/dung.py b/src/argumentation/core/dung.py index 4a7d45a..5fa1a35 100644 --- a/src/argumentation/core/dung.py +++ b/src/argumentation/core/dung.py @@ -174,15 +174,67 @@ def admissible( def grounded_extension(framework: ArgumentationFramework) -> frozenset[str]: - """Compute the unique grounded extension. + """Compute the unique grounded extension: the least complete extension. - This is pure Dung grounded semantics: the least fixed point of the - characteristic function over ``defeats`` only. Attack metadata is - ignored here. + The characteristic function uses ``defeats``. Its least fixed point G is + contained in every complete extension, since each is a fixed point of it. + On a single-relation framework G is the Dung grounded extension. When + ``attacks`` differ from ``defeats``, conflict-freeness is measured on + attacks (Modgil & Prakken 2018, Def 14): G is then the least complete + extension if it is conflict-free on attacks, and otherwise no complete + extension exists, so a ``ValueError`` is raised instead of returning a + conflicting set. :func:`grounded_extensions` returns ``()`` instead. References: Dung 1995, Definition 20 + Theorem 25 (least fixed point). + Modgil & Prakken 2018, Definition 14. + """ + extensions = grounded_extensions(framework) + if not extensions: + raise ValueError( + "framework has no complete extension: the least fixed point of the " + "defeat-based characteristic function is not conflict-free on " + "attacks (Modgil & Prakken 2018, Def 14), and every complete " + "extension would contain it" + ) + return extensions[0] + + +def grounded_extensions( + framework: ArgumentationFramework, +) -> tuple[frozenset[str], ...]: + """Return ``(grounded,)``, or ``()`` when no complete extension exists. + + The tuple form matches :func:`extensions_for`: like complete and stable, + grounded has no extension on a mixed framework whose defeat-based least + fixed point conflicts on attacks (Modgil & Prakken 2018, Def 14). """ + least_fixed_point = _defeat_least_fixed_point(framework) + if framework.attacks is not None and not conflict_free( + least_fixed_point, framework.attacks + ): + return () + return (least_fixed_point,) + + +def attacks_resolved_by_defeats(framework: ArgumentationFramework) -> bool: + """Whether every attack is a defeat in at least one direction. + + Holds for every single-relation framework. On such mixed frameworks the + defeat-based least fixed point is conflict-free on attacks and contained + in every Def 14 admissible-maximal set, so grounded-based shortcuts and + reducts stay sound; without it they can be unsound (issue #90). + """ + if framework.attacks is None: + return True + return all( + edge in framework.defeats or (edge[1], edge[0]) in framework.defeats + for edge in framework.attacks + ) + + +def _defeat_least_fixed_point(framework: ArgumentationFramework) -> frozenset[str]: + """Least fixed point of the characteristic function over ``defeats``.""" attackers_index = predecessors_index(framework.defeats) targets_index = successors_index(framework.defeats) live_attackers = { @@ -269,20 +321,83 @@ def preferred_extensions(framework: ArgumentationFramework) -> list[frozenset[st """ if framework.attacks is None or framework.attacks == framework.defeats: return maximal_sets(complete_extensions(framework)) - attackers_index = predecessors_index(framework.defeats) - return maximal_sets( - [ - candidate - for candidate in _all_subsets(framework.arguments) + extensions, _nodes = _mixed_preferred_search(framework) + return extensions + + +def _mixed_preferred_search( + framework: ArgumentationFramework, +) -> tuple[list[frozenset[str]], int]: + """Maximal Def 14 admissible sets of a mixed framework, and the node count. + + Depth-first search over arguments in sorted order, trying ``in`` before + ``out``. A branch is cut when (1) an ``in`` argument has a defeater that + no remaining candidate can defeat, so no completion is admissible; + (2) every set the branch can still reach is contained in an admissible + set already found, so it cannot yield a new maximal one; or (3) an + ``out`` argument conflicts with nothing reachable and is already defended + by the ``in`` arguments, so adding it to any completion stays admissible + and no completion is maximal. Conflict-freeness uses attacks, defense uses + defeats (Modgil & Prakken 2018, Def 14). The node count is exposed for the + operational contract tests. + """ + attacks = framework.attacks if framework.attacks is not None else framework.defeats + order = sorted(framework.arguments) + conflicts: dict[str, set[str]] = {argument: set() for argument in order} + for source, target in attacks: + conflicts[source].add(target) + conflicts[target].add(source) + defeaters = predecessors_index(framework.defeats) + found: list[frozenset[str]] = [] + nodes = 0 + # Explicit stack instead of recursion: depth equals the argument count. + stack: list[tuple[int, frozenset[str], frozenset[str]]] = [ + (0, frozenset(), frozenset()) + ] + while stack: + index, chosen, excluded = stack.pop() + nodes += 1 + reachable = chosen | frozenset( + argument + for argument in order[index:] + if argument not in conflicts[argument] and not conflicts[argument] & chosen + ) + if any(reachable <= extension for extension in found): + continue + if not all( + any( + candidate in defeaters.get(defeater, frozenset()) + for candidate in reachable + ) + for argument in chosen + for defeater in defeaters.get(argument, frozenset()) + ): + continue + if any( + argument not in conflicts[argument] + and not conflicts[argument] & reachable + and all( + defeaters.get(defeater, frozenset()) & chosen + for defeater in defeaters.get(argument, frozenset()) + ) + for argument in excluded + ): + continue + if index == len(order): if admissible( - candidate, + chosen, framework.arguments, framework.defeats, attacks=framework.attacks, - attackers_index=attackers_index, - ) - ] - ) + attackers_index=defeaters, + ): + found.append(chosen) + continue + argument = order[index] + stack.append((index + 1, chosen, excluded | {argument})) + if argument not in conflicts[argument] and not conflicts[argument] & chosen: + stack.append((index + 1, chosen | {argument}, excluded)) + return maximal_sets(found), nodes def stable_extensions(framework: ArgumentationFramework) -> list[frozenset[str]]: @@ -689,10 +804,11 @@ def extensions_for( """Return extensions for the supported Dung semantics. Single-extension semantics (grounded, ideal) are returned as a 1-tuple so - every semantics yields a uniform ``tuple[frozenset[str], ...]``. + every semantics yields a uniform ``tuple[frozenset[str], ...]``. Grounded + is ``()`` when the framework has no complete extension (Def 14). """ if semantics == "grounded": - return (grounded_extension(framework),) + return grounded_extensions(framework) if semantics == "complete": return tuple(complete_extensions(framework)) if semantics == "preferred": diff --git a/src/argumentation/core/preprocessing.py b/src/argumentation/core/preprocessing.py index dcfcd98..85a3f61 100644 --- a/src/argumentation/core/preprocessing.py +++ b/src/argumentation/core/preprocessing.py @@ -60,6 +60,7 @@ from argumentation.core.dung import ( ArgumentationFramework, + attacks_resolved_by_defeats, grounded_extension, ) from argumentation.core.finite import predecessors_index, successors_index @@ -130,7 +131,13 @@ def simplify_af( removal is). When ``semantics`` is ``None`` the grounded reduct is applied -- callers are responsible for only calling this for supported semantics. """ - apply_grounded = semantics is None or semantics in GROUNDED_REDUCT_SEMANTICS + # On a mixed framework with an attack that is a defeat in neither direction, + # the defeat-based grounded set need not be conflict-free on attacks or + # contained in every extension (Modgil & Prakken 2018, Def 14; issue #90), + # so the grounded reduct is only applied when every attack is resolved. + apply_grounded = ( + semantics is None or semantics in GROUNDED_REDUCT_SEMANTICS + ) and attacks_resolved_by_defeats(framework) fixed_in: frozenset[str] = frozenset() fixed_out: frozenset[str] = frozenset() diff --git a/src/argumentation/core/scc_recursive.py b/src/argumentation/core/scc_recursive.py index 2dec56b..17c8ca4 100644 --- a/src/argumentation/core/scc_recursive.py +++ b/src/argumentation/core/scc_recursive.py @@ -56,6 +56,7 @@ _strongly_connected_components, _subframework, admissible, + attacks_resolved_by_defeats, characteristic_fn, complete_extensions, preferred_extensions, @@ -289,6 +290,17 @@ def scc_extensions( LAST_SOLVE.semantics = semantics LAST_SOLVE.decompose_requested = decompose + if not attacks_resolved_by_defeats(framework): + # SCCs are taken over defeats, so an attack that is a defeat in neither + # direction can link SCCs the recursion treats as independent, and the + # base preferred (maximal complete) differs from Def 14 preferred + # (maximal admissible) there (Modgil & Prakken 2018, Def 14; issue #90). + LAST_SOLVE.flat_fast_path = True + LAST_SOLVE.notes.append( + "attack that is a defeat in neither direction -> flat Def 14 solve" + ) + return _flat_enumerate(semantics, framework) + if not decompose: LAST_SOLVE.flat_fast_path = True LAST_SOLVE.notes.append("decompose=False -> flat base solve on whole AF") diff --git a/src/argumentation/semantics.py b/src/argumentation/semantics.py index 7809184..27f9ad6 100644 --- a/src/argumentation/semantics.py +++ b/src/argumentation/semantics.py @@ -17,7 +17,7 @@ ArgumentationFramework, cf2_extensions, complete_extensions, - grounded_extension, + grounded_extensions, ideal_extension, preferred_extensions, prudent_grounded_extension, @@ -70,7 +70,7 @@ def _dung_extensions( semantics: str, ) -> tuple[frozenset[str], ...]: if semantics == "grounded": - return (grounded_extension(framework),) + return grounded_extensions(framework) if semantics == "ideal": return (ideal_extension(framework),) if semantics == "complete": diff --git a/src/argumentation/solver_adapters/iccma_af.py b/src/argumentation/solver_adapters/iccma_af.py index 6ea34ee..046a4e6 100644 --- a/src/argumentation/solver_adapters/iccma_af.py +++ b/src/argumentation/solver_adapters/iccma_af.py @@ -13,7 +13,7 @@ admissible, characteristic_fn, conflict_free, - grounded_extension, + grounded_extensions, range_of, ) from argumentation.interop.iccma import write_af @@ -380,7 +380,7 @@ def _validate_witness_certificate( raise ICCMAOutputParseError("complete witness is not a complete extension") return if semantics == "grounded": - if witness != grounded_extension(framework): + if (witness,) != grounded_extensions(framework): raise ICCMAOutputParseError( "grounded witness is not the grounded extension" ) diff --git a/src/argumentation/solving/af_sat.py b/src/argumentation/solving/af_sat.py index 6be39da..a6b725a 100644 --- a/src/argumentation/solving/af_sat.py +++ b/src/argumentation/solving/af_sat.py @@ -11,6 +11,7 @@ from argumentation.core.dung import ( ArgumentationFramework, + attacks_resolved_by_defeats, grounded_extension, range_of, ) @@ -920,6 +921,11 @@ def _shortcut(self, query: str) -> bool | None: "preferred_skeptical_shortcut_self_attacking_query", False ) return False + if not attacks_resolved_by_defeats(self.framework): + # The remaining shortcuts assume every preferred extension contains + # the defeat-based grounded set, which fails when an attack is a + # defeat in neither direction (Modgil & Prakken 2018, Def 14). + return None attackers = self._attackers_of(query) if not attackers: self._emit_shortcut("preferred_skeptical_shortcut_unattacked_query", True) diff --git a/src/argumentation/solving/sat_encoding.py b/src/argumentation/solving/sat_encoding.py index 041e12b..8b81b76 100644 --- a/src/argumentation/solving/sat_encoding.py +++ b/src/argumentation/solving/sat_encoding.py @@ -11,7 +11,7 @@ from argumentation.core.dung import ( ArgumentationFramework, admissible, - grounded_extension, + grounded_extensions, ideal_extension, semi_stable_extensions, stage_extensions, @@ -34,7 +34,7 @@ def sat_extensions( if semantics == "admissible": return _sorted_extensions(_admissible_sets(framework)) if semantics == "grounded": - return (grounded_extension(framework),) + return grounded_extensions(framework) # complete / preferred / stable -> SCC-recursive layer (Wave B2): grounded-reduct # preprocessing composed with Baroni-Giacomin-Guida SCC decomposition. Transparent. if semantics in ("complete", "preferred", "stable"): diff --git a/src/argumentation/solving/solver.py b/src/argumentation/solving/solver.py index b07aabf..b800da2 100644 --- a/src/argumentation/solving/solver.py +++ b/src/argumentation/solving/solver.py @@ -33,9 +33,10 @@ from argumentation.structured.aspic.aspic import Literal from argumentation.core.dung import ( ArgumentationFramework, + attacks_resolved_by_defeats, cf2_extensions, complete_extensions, - grounded_extension, + grounded_extensions, ideal_extension, preferred_extensions, semi_stable_extensions, @@ -391,7 +392,13 @@ def solve_dung_single_extension( if sat is not None and sat.require_external: return _external_sat_unavailable() trace_sink, metadata, check_budget_seconds = _sat_options(sat) - find_single = _SAT_SINGLE_EXTENSION_FINDERS.get(semantics) + # The dedicated SAT kernels assume every attack is a defeat in some + # direction; otherwise use the Def 14 enumeration below (issue #90). + find_single = ( + _SAT_SINGLE_EXTENSION_FINDERS.get(semantics) + if attacks_resolved_by_defeats(framework) + else None + ) if find_single is not None: # Only complete/preferred finders accept an engine and are routed to # sat-core (SE-CO / SE-PR); stable/semi-stable/stage/ideal keep smt. @@ -453,7 +460,10 @@ def solve_dung_acceptance( if sat is not None and sat.require_external: return _external_sat_unavailable() trace_sink, metadata, check_budget_seconds = _sat_options(sat) - if requested_backend == "auto": + # The cone and dedicated SAT kernels assume every attack is a defeat in + # some direction; otherwise use the Def 14 enumeration (issue #90). + dedicated_kernels_apply = attacks_resolved_by_defeats(framework) + if requested_backend == "auto" and dedicated_kernels_apply: # Query-directed SCC-cone path (sound per the derivations in # experiments/2026-07-10-af-scc-acceptance.md); None means the # cone does not apply or is inconclusive -> flat path below. @@ -473,7 +483,11 @@ def solve_dung_acceptance( return _optional_dependency_unavailable(exc) if cone_result is not None: return cone_result - solve_dedicated = _dedicated_sat_acceptance_solver(semantics, task) + solve_dedicated = ( + _dedicated_sat_acceptance_solver(semantics, task) + if dedicated_kernels_apply + else None + ) if solve_dedicated is not None: try: return solve_dedicated( @@ -1157,7 +1171,7 @@ def _dung_extensions( semantics: str, ) -> list[frozenset[str]]: if semantics == "grounded": - return [grounded_extension(framework)] + return list(grounded_extensions(framework)) # complete / preferred / stable: route through the SCC-recursive layer # (Wave B2), which composes the Wave A grounded-reduct preprocessing with # Baroni-Giacomin-Guida SCC decomposition. Transparent: identical results, diff --git a/src/argumentation/structured/aspic/aspic_encoding.py b/src/argumentation/structured/aspic/aspic_encoding.py index 7fb07f6..b1df3b9 100644 --- a/src/argumentation/structured/aspic/aspic_encoding.py +++ b/src/argumentation/structured/aspic/aspic_encoding.py @@ -141,6 +141,10 @@ def solve_aspic_grounded( This is the tested direct package query surface. Its current backend is the materialized ASPIC-to-Dung reference path; optional ASP/clingo backends can attach to the same encoding/result contract in later slices. + + Raises ``ValueError`` when the projected framework has no complete + extension, which happens only outside Modgil & Prakken 2018's well-defined + domain (Def 14; e.g. strict rules not closed under transposition). """ from argumentation.core.dung import grounded_extension @@ -420,7 +424,7 @@ def _materialized_extensions(framework, semantics: str) -> tuple[frozenset[str], from argumentation.core import dung if semantics == "grounded": - return (dung.grounded_extension(framework),) + return dung.grounded_extensions(framework) if semantics == "admissible": return tuple( candidate diff --git a/tests/core/pre_def14_reference.py b/tests/core/pre_def14_reference.py new file mode 100644 index 0000000..9d4a0fd --- /dev/null +++ b/tests/core/pre_def14_reference.py @@ -0,0 +1,109 @@ +"""Verbatim copy of the core semantics before issue #90 (main 753569f). + +Used only by the single-relation differential test: frameworks whose attacks +are absent or equal their defeats must give the same extensions after the +Modgil & Prakken 2018 Def 14 change as before it. +""" + +from __future__ import annotations + +from collections import deque + +from argumentation.core.dung import ArgumentationFramework, admissible +from argumentation.core.finite import ( + iter_subsets_bitmask, + maximal_sets, + predecessors_index, + successors_index, +) + + +def grounded_extension(framework: ArgumentationFramework) -> frozenset[str]: + attackers_index = predecessors_index(framework.defeats) + targets_index = successors_index(framework.defeats) + live_attackers = { + argument: len(attackers_index.get(argument, frozenset())) + for argument in framework.arguments + } + queue = deque( + argument for argument in framework.arguments if live_attackers[argument] == 0 + ) + in_arguments: set[str] = set() + out_arguments: set[str] = set() + + while queue: + argument = queue.popleft() + if argument in in_arguments or argument in out_arguments: + continue + + in_arguments.add(argument) + for defeated in targets_index.get(argument, frozenset()): + if defeated in out_arguments: + continue + out_arguments.add(defeated) + for defended in targets_index.get(defeated, frozenset()): + live_attackers[defended] -= 1 + if ( + live_attackers[defended] == 0 + and defended not in in_arguments + and defended not in out_arguments + ): + queue.append(defended) + + return frozenset(in_arguments) + + +def complete_extensions(framework: ArgumentationFramework) -> list[frozenset[str]]: + from argumentation.core.labelling import ( + DEFAULT_COMPLETE_LABELLING_CANDIDATE_BUDGET, + complete_labellings, + ) + + attackers_index = predecessors_index(framework.defeats) + return [ + labelling.extension + for labelling in complete_labellings( + framework, + max_candidates=DEFAULT_COMPLETE_LABELLING_CANDIDATE_BUDGET, + ) + if admissible( + labelling.extension, + framework.arguments, + framework.defeats, + attacks=framework.attacks, + attackers_index=attackers_index, + ) + ] + + +def preferred_extensions(framework: ArgumentationFramework) -> list[frozenset[str]]: + if framework.attacks is None or framework.attacks == framework.defeats: + return maximal_sets(complete_extensions(framework)) + attackers_index = predecessors_index(framework.defeats) + return maximal_sets( + [ + candidate + for candidate in iter_subsets_bitmask(framework.arguments) + if admissible( + candidate, + framework.arguments, + framework.defeats, + attacks=framework.attacks, + attackers_index=attackers_index, + ) + ] + ) + + +def stable_extensions(framework: ArgumentationFramework) -> list[frozenset[str]]: + from argumentation.core.dung import conflict_free + from argumentation.core.labelling import stable_labellings + + cf_relation = ( + framework.attacks if framework.attacks is not None else framework.defeats + ) + return [ + labelling.extension + for labelling in stable_labellings(framework) + if conflict_free(labelling.extension, cf_relation) + ] diff --git a/tests/core/test_bipolar_argumentation.py b/tests/core/test_bipolar_argumentation.py index 4332f78..a5de040 100644 --- a/tests/core/test_bipolar_argumentation.py +++ b/tests/core/test_bipolar_argumentation.py @@ -163,13 +163,19 @@ def test_stable_extensions_with_attacks(self) -> None: exts = stable_extensions(framework) assert frozenset({"A", "B"}) not in exts - def test_grounded_extension_ignores_attack_metadata(self) -> None: + def test_grounded_extension_rejects_attack_conflicting_least_fixed_point( + self, + ) -> None: + # Issue #90: grounded is the least complete extension under Modgil & + # Prakken 2018 Def 14; {A, B} is not conflict-free on attacks, so no + # complete extension exists. framework = ArgumentationFramework( arguments=frozenset({"A", "B"}), defeats=frozenset(), attacks=frozenset({("A", "B")}), ) - assert grounded_extension(framework) == frozenset({"A", "B"}) + with pytest.raises(ValueError, match="no complete extension"): + grounded_extension(framework) class TestBipolarExtensions: diff --git a/tests/core/test_def14_mixed_semantics.py b/tests/core/test_def14_mixed_semantics.py new file mode 100644 index 0000000..17a5baa --- /dev/null +++ b/tests/core/test_def14_mixed_semantics.py @@ -0,0 +1,426 @@ +"""Core semantics on mixed attack/defeat frameworks (issue #90). + +Contract: Modgil & Prakken 2018, Def 14 (p.14; papers/Modgil_2018_General +AccountArgumentationPreferences/notes.md). A set is conflict-free when no +member attacks another; defense and admissibility use defeats; complete, +preferred and stable are built on those notions. Grounded is the least +complete extension. Single-relation frameworks keep Dung 1995 semantics. +""" + +from __future__ import annotations + +from itertools import combinations + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from argumentation.core import dung +from argumentation.core.dung import ( + ArgumentationFramework, + complete_extensions, + conflict_free, + extensions_for, + grounded_extension, + grounded_extensions, + preferred_extensions, + stable_extensions, +) +from argumentation.core.preprocessing import simplify_af +from argumentation.probabilistic.probabilistic import ( + ProbabilisticAF, + compute_probabilistic_acceptance, +) +from argumentation.solving.solver import solve_dung_acceptance, solve_dung_extensions +from argumentation.structured.aspic.aspic import ( + ArgumentationSystem, + ContrarinessFn, + GroundAtom, + KnowledgeBase, + Literal, + PreferenceConfig, + Rule, + build_abstract_framework, +) +from tests.core import pre_def14_reference as reference +from tests.core.test_dung import argumentation_frameworks + + +def _failed_rebuttal_pairs(pair_count: int) -> ArgumentationFramework: + """x_i and y_i attack each other; only y_i -> x_i succeeds as a defeat, + the shape a preference-filtered rebuttal has (M&P 2018, Def 9, p.12).""" + arguments = frozenset( + name for index in range(pair_count) for name in (f"x{index}", f"y{index}") + ) + attacks = frozenset( + edge + for index in range(pair_count) + for edge in ((f"x{index}", f"y{index}"), (f"y{index}", f"x{index}")) + ) + defeats = frozenset((f"y{index}", f"x{index}") for index in range(pair_count)) + return ArgumentationFramework(arguments, defeats, attacks=attacks) + + +def _forbid_subset_enumeration(monkeypatch: pytest.MonkeyPatch) -> None: + def forbidden(*args: object, **kwargs: object) -> object: + raise AssertionError("mixed preferred must not enumerate every subset") + + monkeypatch.setattr(dung, "_all_subsets", forbidden) + monkeypatch.setattr(dung, "iter_subsets_bitmask", forbidden) + + +def test_mixed_preferred_search_is_bounded_on_failed_rebuttal_pairs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Operational contract: 20 failed-rebuttal pairs (40 arguments, 2^40 + subsets) have the single preferred extension {y_0..y_19}; the search + visits a number of nodes polynomial in the argument count and never + enumerates subsets.""" + _forbid_subset_enumeration(monkeypatch) + framework = _failed_rebuttal_pairs(20) + + extensions, nodes = dung._mixed_preferred_search(framework) + + assert extensions == [frozenset(f"y{index}" for index in range(20))] + assert nodes <= 4 * len(framework.arguments) ** 2 + + +def test_mixed_preferred_search_is_bounded_on_isolated_arguments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Operational contract: 38 unattacked arguments plus one failed rebuttal + pair; every unattacked argument is in the unique preferred extension.""" + _forbid_subset_enumeration(monkeypatch) + isolated = frozenset(f"i{index}" for index in range(38)) + framework = ArgumentationFramework( + isolated | {"x", "y"}, + frozenset({("y", "x")}), + attacks=frozenset({("x", "y"), ("y", "x")}), + ) + + extensions, nodes = dung._mixed_preferred_search(framework) + + assert extensions == [isolated | {"y"}] + assert nodes <= 4 * len(framework.arguments) ** 2 + + +def test_mixed_preferred_public_api_uses_bounded_search( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Operational contract: preferred_extensions on a 40-argument mixed AF + goes through the search, not subset enumeration.""" + _forbid_subset_enumeration(monkeypatch) + + assert preferred_extensions(_failed_rebuttal_pairs(20)) == [ + frozenset(f"y{index}" for index in range(20)) + ] + + +def _issue_repro() -> ArgumentationFramework: + return ArgumentationFramework( + arguments=frozenset({"a", "b"}), + defeats=frozenset(), + attacks=frozenset({("a", "b")}), + ) + + +def test_grounded_raises_when_no_complete_extension_exists() -> None: + """Issue #90 repro: a attacks b but neither defeats the other, so both + are defended by every set, and any complete extension would contain the + attacking pair; there is no complete extension (Def 14, p.14).""" + framework = _issue_repro() + + with pytest.raises(ValueError, match="no complete extension"): + grounded_extension(framework) + assert complete_extensions(framework) == [] + assert stable_extensions(framework) == [] + assert sorted(preferred_extensions(framework), key=sorted) == [ + frozenset({"a"}), + frozenset({"b"}), + ] + + +def _lit(name: str, negated: bool = False) -> Literal: + return Literal(GroundAtom(name), negated=negated) + + +def _transposition_closed_aspic_framework() -> ArgumentationFramework: + """M&P 2018 well-defined system (closed under transposition, Def 12, + p.13): premises p < q, strict rules p -> ~q and q -> ~p. [p; p->~q] + attacks [q] without defeating it, and [q] does not attack back.""" + p, q, not_q, not_p = _lit("p"), _lit("q"), _lit("q", True), _lit("p", True) + system = ArgumentationSystem( + language=frozenset({p, q, not_q, not_p}), + contrariness=ContrarinessFn( + contradictories=frozenset({(q, not_q), (p, not_p)}) + ), + strict_rules=frozenset( + {Rule((p,), not_q, "strict"), Rule((q,), not_p, "strict")} + ), + defeasible_rules=frozenset(), + ) + knowledge_base = KnowledgeBase(axioms=frozenset(), premises=frozenset({p, q})) + preferences = PreferenceConfig( + rule_order=frozenset(), + premise_order=frozenset({(p, q)}), + comparison="elitist", + link="last", + ) + return build_abstract_framework(system, knowledge_base, preferences).framework + + +def test_aspic_transposition_closed_control() -> None: + """Issue #90 control: grounded exists, is conflict-free on attacks, is the + least complete extension, and preferred equals the maximal complete + extensions.""" + framework = _transposition_closed_aspic_framework() + assert framework.attacks is not None + assert framework.attacks != framework.defeats + + grounded = grounded_extension(framework) + completes = complete_extensions(framework) + + assert conflict_free(grounded, framework.attacks) + assert grounded in completes + assert all(grounded <= extension for extension in completes) + assert set(preferred_extensions(framework)) == set(_maximal(completes)) + + +def _maximal(sets: list[frozenset[str]]) -> list[frozenset[str]]: + return [candidate for candidate in sets if not any(candidate < s for s in sets)] + + +@st.composite +def pairwise_wellformed_mixed_frameworks(draw: st.DrawFn) -> ArgumentationFramework: + """Mixed AFs where defeats are attacks and every attack has a defeat in + at least one direction (the shape of a preference-filtered attack).""" + arguments = sorted( + draw(st.frozensets(st.sampled_from("abcdef"), min_size=1, max_size=6)) + ) + pairs = [(x, y) for x in arguments for y in arguments] + attacks = draw(st.frozensets(st.sampled_from(pairs), max_size=len(pairs))) + defeats: set[tuple[str, str]] = set() + for source, target in sorted(attacks): + if (target, source) in attacks and draw(st.booleans()): + continue + defeats.add((source, target)) + for source, target in sorted(attacks): + if (source, target) not in defeats and (target, source) not in defeats: + defeats.add((source, target)) + return ArgumentationFramework( + frozenset(arguments), frozenset(defeats), attacks=attacks + ) + + +@given(pairwise_wellformed_mixed_frameworks()) +@settings(max_examples=150, deadline=None) +def test_mixed_semantics_follow_def14(framework: ArgumentationFramework) -> None: + """Issue #90 property: on mixed AFs with a complete extension, grounded + is complete and least, preferred equals the maximal complete extensions, + and every extension is conflict-free on attacks (M&P 2018, Def 14).""" + assert framework.attacks is not None + completes = complete_extensions(framework) + preferred = preferred_extensions(framework) + stable = stable_extensions(framework) + + for extension in [*completes, *preferred, *stable]: + assert conflict_free(extension, framework.attacks) + assert set(preferred) == set(_brute_force_preferred(framework)) + if not completes: + with pytest.raises(ValueError, match="no complete extension"): + grounded_extension(framework) + return + grounded = grounded_extension(framework) + assert grounded in completes + assert all(grounded <= extension for extension in completes) + assert set(preferred) == set(_maximal(completes)) + + +@st.composite +def arbitrary_mixed_frameworks(draw: st.DrawFn) -> ArgumentationFramework: + """Any mixed AF with defeats contained in attacks, including attacks + that fail in both directions (outside M&P's well-defined domain).""" + arguments = sorted( + draw(st.frozensets(st.sampled_from("abcdef"), min_size=1, max_size=6)) + ) + pairs = [(x, y) for x in arguments for y in arguments] + attacks = draw(st.frozensets(st.sampled_from(pairs), max_size=len(pairs))) + defeats = ( + draw(st.frozensets(st.sampled_from(sorted(attacks)))) + if attacks + else frozenset() + ) + return ArgumentationFramework( + frozenset(arguments), frozenset(defeats), attacks=attacks + ) + + +@given(arbitrary_mixed_frameworks()) +@settings(max_examples=200, deadline=None) +def test_def14_preferred_and_grounded_on_arbitrary_mixed_frameworks( + framework: ArgumentationFramework, +) -> None: + """Issue #90: preferred is the set of maximal Def 14 admissible sets + (checked by brute force), and grounded is the least complete extension + when one exists and raises otherwise (M&P 2018, Def 14).""" + assert set(preferred_extensions(framework)) == set( + _brute_force_preferred(framework) + ) + completes = complete_extensions(framework) + if not completes: + with pytest.raises(ValueError, match="no complete extension"): + grounded_extension(framework) + return + grounded = grounded_extension(framework) + assert grounded in completes + assert all(grounded <= extension for extension in completes) + + +def _brute_force_preferred(framework: ArgumentationFramework) -> list[frozenset[str]]: + ordered = sorted(framework.arguments) + admissible_sets = [ + frozenset(combo) + for size in range(len(ordered) + 1) + for combo in combinations(ordered, size) + if dung.admissible( + frozenset(combo), + framework.arguments, + framework.defeats, + attacks=framework.attacks, + ) + ] + return _maximal(admissible_sets) + + +@given(argumentation_frameworks(max_args=6), st.booleans()) +@settings(max_examples=150, deadline=None) +def test_single_relation_semantics_unchanged( + framework: ArgumentationFramework, + attacks_equal_defeats: bool, +) -> None: + """Issue #90: frameworks with one relation (attacks None or equal to + defeats) give the same results as the pre-change implementation.""" + if attacks_equal_defeats: + framework = ArgumentationFramework( + framework.arguments, framework.defeats, attacks=framework.defeats + ) + + assert grounded_extension(framework) == reference.grounded_extension(framework) + assert set(complete_extensions(framework)) == set( + reference.complete_extensions(framework) + ) + assert set(preferred_extensions(framework)) == set( + reference.preferred_extensions(framework) + ) + assert set(stable_extensions(framework)) == set( + reference.stable_extensions(framework) + ) + + +def _unresolved_attack_framework() -> ArgumentationFramework: + """a attacks c without either defeating the other; a and b defeat each + other. Def 14 preferred: {a} and {b, c}; complete: {c} and {b, c}.""" + return ArgumentationFramework( + frozenset("abc"), + frozenset({("a", "b"), ("b", "a")}), + attacks=frozenset({("a", "b"), ("a", "c"), ("b", "a")}), + ) + + +@pytest.mark.parametrize("backend", ["native", "sat"]) +def test_solver_backends_follow_def14_on_unresolved_attack(backend: str) -> None: + """Issue #90: the solver layer returned {a, c}, which is not + conflict-free on attacks, because its grounded reduct fixed c in and its + SCC recursion over defeats ignored the attack a -> c.""" + framework = _unresolved_attack_framework() + + result = solve_dung_extensions(framework, semantics="preferred", backend=backend) + + assert set(result.extensions) == {frozenset({"a"}), frozenset({"b", "c"})} # type: ignore[union-attr] + + +@pytest.mark.parametrize("backend", ["native", "sat", "auto"]) +def test_preferred_skeptical_acceptance_follows_def14(backend: str) -> None: + """Issue #90: c has no defeaters but is not in the preferred extension + {a}, so it is not skeptically accepted; the SAT shortcut for unattacked + queries answered True.""" + result = solve_dung_acceptance( + _unresolved_attack_framework(), + semantics="preferred", + task="skeptical", + query="c", + backend=backend, + ) + + assert result.answer is False # type: ignore[union-attr] + + +def test_grounded_reduct_skipped_for_unresolved_attacks() -> None: + """Issue #90: fixing the defeat-based grounded set {c} would exclude the + preferred extension {a}, so the reduct is not applied.""" + reduct = simplify_af(_unresolved_attack_framework(), semantics="preferred") + + assert reduct.fixed_in == frozenset() + assert reduct.residual == _unresolved_attack_framework() + + +def test_probabilistic_worlds_without_grounded_extension_accept_nothing() -> None: + """Issue #90 caller audit: PrAF world sampling can drop the reverse attack + of a failed rebuttal, leaving attack a -> b with no defeat. That world has + no complete, hence no grounded, extension and accepts nothing (Li et al. + 2011, Eq 2, p.4); the other world has grounded {b}.""" + framework = ArgumentationFramework( + frozenset({"a", "b"}), + frozenset({("b", "a")}), + attacks=frozenset({("a", "b"), ("b", "a")}), + ) + praf = ProbabilisticAF( + framework, + {"a": 1.0, "b": 1.0}, + {("b", "a"): 0.5}, + p_attacks={("a", "b"): 1.0, ("b", "a"): 0.5}, + ) + + result = compute_probabilistic_acceptance( + praf, semantics="grounded", strategy="exact_enum" + ) + + assert result.acceptance_probs == pytest.approx({"a": 0.0, "b": 0.5}) + + +def test_grounded_extensions_tuple_is_empty_without_complete_extension() -> None: + """Issue #90: plural APIs report no grounded extension, like complete and + stable, instead of raising.""" + assert grounded_extensions(_issue_repro()) == () + assert extensions_for(_issue_repro(), "grounded") == () + + +@given(arbitrary_mixed_frameworks(), st.sampled_from(["native", "sat"])) +@settings(max_examples=60, deadline=None) +def test_solver_backends_agree_with_core_on_mixed_frameworks( + framework: ArgumentationFramework, + backend: str, +) -> None: + """Issue #90: every solver backend follows the core Def 14 semantics on + arbitrary mixed frameworks, for enumeration and acceptance.""" + query = sorted(framework.arguments)[0] + for semantics, expected in ( + ("grounded", list(grounded_extensions(framework))), + ("complete", complete_extensions(framework)), + ("preferred", preferred_extensions(framework)), + ("stable", stable_extensions(framework)), + ): + result = solve_dung_extensions(framework, semantics=semantics, backend=backend) + assert set(result.extensions) == set(expected) # type: ignore[union-attr] + for task, answer in ( + ("credulous", any(query in extension for extension in expected)), + ("skeptical", all(query in extension for extension in expected)), + ): + acceptance = solve_dung_acceptance( + framework, + semantics=semantics, + task=task, + query=query, + backend=backend, + ) + assert acceptance.answer is answer # type: ignore[union-attr] diff --git a/tests/core/test_dung.py b/tests/core/test_dung.py index d4fb449..ad232e6 100644 --- a/tests/core/test_dung.py +++ b/tests/core/test_dung.py @@ -150,14 +150,27 @@ def test_chain_of_four(self): ) assert result == frozenset({"A", "C"}) - def test_grounded_ignores_attack_metadata(self): - """Grounded semantics always uses defeats, even when attacks are present.""" + def test_grounded_rejects_attack_conflicting_least_fixed_point(self): + """Issue #90: grounded is the least complete extension (Modgil & + Prakken 2018 Def 14); the defeat-based fixed point {A, B} conflicts on + attacks, so there is no complete and hence no grounded extension.""" fw = ArgumentationFramework( arguments=frozenset({"A", "B"}), defeats=frozenset(), attacks=frozenset({("A", "B")}), ) - assert grounded_extension(fw) == frozenset({"A", "B"}) + with pytest.raises(ValueError, match="no complete extension"): + grounded_extension(fw) + + def test_grounded_uses_defeats_when_least_fixed_point_is_conflict_free(self): + """Issue #90 control: attack A -> B failed, B -> A succeeded as a + defeat; grounded is {B}, the least complete extension.""" + fw = ArgumentationFramework( + arguments=frozenset({"A", "B"}), + defeats=frozenset({("B", "A")}), + attacks=frozenset({("A", "B"), ("B", "A")}), + ) + assert grounded_extension(fw) == frozenset({"B"}) class TestFrameworkValidation: